| @@ -0,0 +1,33 @@ | |||
| package com.ffii.fpsms.modules.logistic.entity | |||
| import com.ffii.core.entity.BaseEntity | |||
| import jakarta.persistence.Column | |||
| import jakarta.persistence.Entity | |||
| import jakarta.persistence.Table | |||
| import jakarta.validation.constraints.NotNull | |||
| import jakarta.validation.constraints.Size | |||
| @Entity | |||
| @Table(name = "logistic") | |||
| open class Logistic : BaseEntity<Long>() { | |||
| @field:NotNull | |||
| @field:Size(max = 255) | |||
| @Column(name = "logisticName", nullable = false, length = 255) | |||
| open var logisticName: String? = null | |||
| @field:NotNull | |||
| @field:Size(max = 50) | |||
| @Column(name = "carPlate", nullable = false, length = 50) | |||
| open var carPlate: String? = null | |||
| @field:NotNull | |||
| @field:Size(max = 255) | |||
| @Column(name = "driverName", nullable = false, length = 255) | |||
| open var driverName: String? = null | |||
| @field:NotNull | |||
| @Column(name = "driverNumber", nullable = false) | |||
| open var driverNumber: Int? = null | |||
| } | |||
| @@ -0,0 +1,12 @@ | |||
| package com.ffii.fpsms.modules.logistic.entity | |||
| import com.ffii.core.support.AbstractRepository | |||
| import org.springframework.stereotype.Repository | |||
| @Repository | |||
| interface LogisticRepository : AbstractRepository<Logistic, Long> { | |||
| fun findAllByDeletedFalseOrderByIdAsc(): List<Logistic> | |||
| fun findByIdAndDeletedFalse(id: Long): Logistic? | |||
| fun findByCarPlateAndDeletedFalse(carPlate: String): Logistic? | |||
| } | |||
| @@ -0,0 +1,82 @@ | |||
| package com.ffii.fpsms.modules.logistic.service | |||
| import com.ffii.fpsms.modules.logistic.entity.Logistic | |||
| import com.ffii.fpsms.modules.logistic.entity.LogisticRepository | |||
| import com.ffii.fpsms.modules.logistic.web.models.SaveLogisticRequest | |||
| import jakarta.transaction.Transactional | |||
| import org.springframework.stereotype.Service | |||
| import org.springframework.web.server.ResponseStatusException | |||
| import org.springframework.http.HttpStatus | |||
| @Service | |||
| open class LogisticService( | |||
| private val logisticRepository: LogisticRepository, | |||
| ) { | |||
| open fun findAll(): List<Logistic> { | |||
| return logisticRepository.findAllByDeletedFalseOrderByIdAsc() | |||
| } | |||
| open fun findById(id: Long): Logistic? { | |||
| return logisticRepository.findByIdAndDeletedFalse(id) | |||
| } | |||
| open fun requireById(id: Long): Logistic { | |||
| return logisticRepository.findByIdAndDeletedFalse(id) | |||
| ?: throw ResponseStatusException(HttpStatus.NOT_FOUND, "Logistic not found with id: $id") | |||
| } | |||
| @Transactional | |||
| open fun save(request: SaveLogisticRequest): Logistic { | |||
| val entity = request.id?.let { requireById(it) } ?: Logistic() | |||
| entity.apply { | |||
| logisticName = request.logisticName.trim() | |||
| carPlate = request.carPlate.trim() | |||
| driverName = request.driverName.trim() | |||
| driverNumber = request.driverNumber | |||
| } | |||
| return logisticRepository.save(entity) | |||
| } | |||
| /** | |||
| * 批次「新增」物流主檔:同一交易內寫入,任一筆失敗則整批 rollback。 | |||
| * 供看板一次儲存多筆暫存主檔,避免逐筆 POST 中途失敗留下孤兒列。 | |||
| */ | |||
| @Transactional | |||
| open fun saveBatchCreate(requests: List<SaveLogisticRequest>): List<Logistic> { | |||
| if (requests.isEmpty()) return emptyList() | |||
| if (requests.size > 100) { | |||
| throw ResponseStatusException( | |||
| HttpStatus.BAD_REQUEST, | |||
| "Batch size exceeds limit (100)", | |||
| ) | |||
| } | |||
| requests.forEach { r -> | |||
| if (r.id != null) { | |||
| throw ResponseStatusException( | |||
| HttpStatus.BAD_REQUEST, | |||
| "save-batch only accepts new rows (id must be null)", | |||
| ) | |||
| } | |||
| } | |||
| return requests.map { req -> | |||
| val entity = Logistic().apply { | |||
| logisticName = req.logisticName.trim() | |||
| carPlate = req.carPlate.trim() | |||
| driverName = req.driverName.trim() | |||
| driverNumber = req.driverNumber | |||
| } | |||
| logisticRepository.save(entity) | |||
| } | |||
| } | |||
| @Transactional | |||
| open fun deleteById(id: Long): String { | |||
| val entity = requireById(id) | |||
| entity.deleted = true | |||
| logisticRepository.save(entity) | |||
| return "Logistic deleted successfully with id: $id" | |||
| } | |||
| } | |||
| @@ -0,0 +1,65 @@ | |||
| package com.ffii.fpsms.modules.logistic.web | |||
| import com.ffii.fpsms.modules.logistic.service.LogisticService | |||
| import com.ffii.fpsms.modules.logistic.web.models.DeleteLogisticRequest | |||
| import com.ffii.fpsms.modules.logistic.web.models.LogisticResponse | |||
| import com.ffii.fpsms.modules.logistic.web.models.SaveLogisticRequest | |||
| import com.ffii.fpsms.modules.logistic.web.models.SaveLogisticsBatchRequest | |||
| import com.ffii.fpsms.modules.master.web.models.MessageResponse | |||
| import jakarta.validation.Valid | |||
| import org.springframework.http.ResponseEntity | |||
| import org.springframework.web.bind.annotation.* | |||
| @RestController | |||
| @RequestMapping("/logistic") | |||
| class LogisticController( | |||
| private val logisticService: LogisticService, | |||
| ) { | |||
| @GetMapping("/all") | |||
| fun findAll(): List<LogisticResponse> { | |||
| return logisticService.findAll().map { it.toResponse() } | |||
| } | |||
| @GetMapping("/{id}") | |||
| fun findById(@PathVariable id: Long): LogisticResponse { | |||
| return logisticService.requireById(id).toResponse() | |||
| } | |||
| @PostMapping("/save") | |||
| fun save(@Valid @RequestBody request: SaveLogisticRequest): LogisticResponse { | |||
| return logisticService.save(request).toResponse() | |||
| } | |||
| /** 批次新增主檔;單一 transaction,與 [save] 分開避免誤用 id 更新混進批次。 */ | |||
| @PostMapping("/save-batch") | |||
| fun saveBatch(@Valid @RequestBody body: SaveLogisticsBatchRequest): List<LogisticResponse> { | |||
| return logisticService.saveBatchCreate(body.items).map { it.toResponse() } | |||
| } | |||
| @PostMapping("/delete") | |||
| fun delete(@Valid @RequestBody request: DeleteLogisticRequest): ResponseEntity<MessageResponse> { | |||
| val result = logisticService.deleteById(request.id) | |||
| return ResponseEntity.ok( | |||
| MessageResponse( | |||
| id = request.id, | |||
| name = null, | |||
| code = null, | |||
| type = "logistic", | |||
| message = result, | |||
| errorPosition = null, | |||
| entity = null, | |||
| ) | |||
| ) | |||
| } | |||
| private fun com.ffii.fpsms.modules.logistic.entity.Logistic.toResponse(): LogisticResponse { | |||
| return LogisticResponse( | |||
| id = this.id ?: 0L, | |||
| logisticName = this.logisticName ?: "", | |||
| carPlate = this.carPlate ?: "", | |||
| driverName = this.driverName ?: "", | |||
| driverNumber = this.driverNumber ?: 0, | |||
| ) | |||
| } | |||
| } | |||
| @@ -0,0 +1,9 @@ | |||
| package com.ffii.fpsms.modules.logistic.web.models | |||
| import jakarta.validation.constraints.NotNull | |||
| data class DeleteLogisticRequest( | |||
| @field:NotNull | |||
| val id: Long, | |||
| ) | |||
| @@ -0,0 +1,10 @@ | |||
| package com.ffii.fpsms.modules.logistic.web.models | |||
| data class LogisticResponse( | |||
| val id: Long, | |||
| val logisticName: String, | |||
| val carPlate: String, | |||
| val driverName: String, | |||
| val driverNumber: Int, | |||
| ) | |||
| @@ -0,0 +1,21 @@ | |||
| package com.ffii.fpsms.modules.logistic.web.models | |||
| import jakarta.validation.constraints.NotBlank | |||
| import jakarta.validation.constraints.NotNull | |||
| import jakarta.validation.constraints.Size | |||
| data class SaveLogisticRequest( | |||
| val id: Long? = null, | |||
| @field:NotBlank | |||
| @field:Size(max = 255) | |||
| val logisticName: String, | |||
| @field:NotBlank | |||
| @field:Size(max = 50) | |||
| val carPlate: String, | |||
| @field:NotBlank | |||
| @field:Size(max = 255) | |||
| val driverName: String, | |||
| @field:NotNull | |||
| val driverNumber: Int, | |||
| ) | |||
| @@ -0,0 +1,12 @@ | |||
| package com.ffii.fpsms.modules.logistic.web.models | |||
| import jakarta.validation.Valid | |||
| import jakarta.validation.constraints.NotEmpty | |||
| import jakarta.validation.constraints.Size | |||
| data class SaveLogisticsBatchRequest( | |||
| @field:NotEmpty | |||
| @field:Size(max = 100) | |||
| @field:Valid | |||
| val items: List<SaveLogisticRequest>, | |||
| ) | |||
| @@ -6,6 +6,7 @@ import com.ffii.fpsms.modules.master.entity.projections.ShopCombo | |||
| import com.ffii.fpsms.modules.master.enums.ShopType | |||
| import com.ffii.fpsms.modules.pickOrder.entity.Truck | |||
| import org.springframework.data.jpa.repository.Query | |||
| import org.springframework.data.repository.query.Param | |||
| import org.springframework.stereotype.Repository | |||
| @Repository | |||
| @@ -30,6 +31,15 @@ interface ShopRepository : AbstractRepository<Shop, Long> { | |||
| fun findByCode(code: String): Shop? | |||
| @Query( | |||
| """ | |||
| SELECT s FROM Shop s | |||
| WHERE s.deleted = false | |||
| AND s.code IN :codes | |||
| """ | |||
| ) | |||
| fun findAllByCodeInAndDeletedIsFalse(@Param("codes") codes: Collection<String>): List<Shop> | |||
| @Query( | |||
| nativeQuery = true, | |||
| value = """ | |||
| @@ -1,6 +1,7 @@ | |||
| package com.ffii.fpsms.modules.pickOrder.entity | |||
| import com.ffii.core.entity.BaseEntity | |||
| import com.ffii.fpsms.modules.logistic.entity.Logistic | |||
| import com.ffii.fpsms.modules.master.entity.Shop | |||
| import jakarta.persistence.* | |||
| import jakarta.validation.constraints.NotNull | |||
| @@ -42,4 +43,8 @@ open class Truck : BaseEntity<Long>() { | |||
| @Column(name = "remark") | |||
| open var remark: String? = null | |||
| } | |||
| @ManyToOne(fetch = FetchType.LAZY) | |||
| @JoinColumn(name = "logisticId") | |||
| open var logistic: Logistic? = null | |||
| } | |||
| @@ -0,0 +1,19 @@ | |||
| package com.ffii.fpsms.modules.pickOrder.entity | |||
| import com.ffii.core.entity.BaseEntity | |||
| import jakarta.persistence.* | |||
| import jakarta.validation.constraints.Size | |||
| @Entity | |||
| @Table(name = "truck_lane_version") | |||
| open class TruckLaneVersion : BaseEntity<Long>() { | |||
| @field:Size(max = 100) | |||
| @Column(name = "truckLanceCode", nullable = true, length = 100) | |||
| open var truckLanceCode: String? = null | |||
| @field:Size(max = 500) | |||
| @Column(name = "note", length = 500) | |||
| open var note: String? = null | |||
| } | |||
| @@ -0,0 +1,55 @@ | |||
| package com.ffii.fpsms.modules.pickOrder.entity | |||
| import com.ffii.core.entity.BaseEntity | |||
| import jakarta.persistence.* | |||
| import jakarta.validation.constraints.NotNull | |||
| import jakarta.validation.constraints.Size | |||
| @Entity | |||
| @Table(name = "truck_lane_version_line") | |||
| open class TruckLaneVersionLine : BaseEntity<Long>() { | |||
| @ManyToOne(fetch = FetchType.LAZY) | |||
| @JoinColumn(name = "truckLaneVersionId", nullable = false) | |||
| open var truckLaneVersion: TruckLaneVersion? = null | |||
| @field:NotNull | |||
| @Column(name = "truckRowId", nullable = false) | |||
| open var truckRowId: Long? = null | |||
| @field:Size(max = 100) | |||
| @Column(name = "truckLanceCode", length = 100) | |||
| open var truckLanceCode: String? = null | |||
| @field:Size(max = 50) | |||
| @Column(name = "shopCode", length = 50) | |||
| open var shopCode: String? = null | |||
| @field:Size(max = 255) | |||
| @Column(name = "branchName", length = 255) | |||
| open var branchName: String? = null | |||
| @field:Size(max = 255) | |||
| @Column(name = "districtReference", length = 255) | |||
| open var districtReference: String? = null | |||
| @Column(name = "loadingSequence") | |||
| open var loadingSequence: Int? = null | |||
| @field:Size(max = 30) | |||
| @Column(name = "departureTime", length = 30) | |||
| open var departureTime: String? = null | |||
| @field:NotNull | |||
| @field:Size(max = 10) | |||
| @Column(name = "storeId", nullable = false, length = 10) | |||
| open var storeId: String? = null | |||
| @field:Size(max = 255) | |||
| @Column(name = "remark", length = 255) | |||
| open var remark: String? = null | |||
| @Column(name = "logisticId") | |||
| open var logisticId: Long? = null | |||
| } | |||
| @@ -0,0 +1,10 @@ | |||
| package com.ffii.fpsms.modules.pickOrder.entity | |||
| import com.ffii.core.support.AbstractRepository | |||
| import org.springframework.stereotype.Repository | |||
| @Repository | |||
| interface TruckLaneVersionLineRepository : AbstractRepository<TruckLaneVersionLine, Long> { | |||
| fun findAllByTruckLaneVersion_IdAndDeletedFalseOrderByIdAsc(truckLaneVersionId: Long): List<TruckLaneVersionLine> | |||
| } | |||
| @@ -0,0 +1,12 @@ | |||
| package com.ffii.fpsms.modules.pickOrder.entity | |||
| import com.ffii.core.support.AbstractRepository | |||
| import org.springframework.stereotype.Repository | |||
| @Repository | |||
| interface TruckLaneVersionRepository : AbstractRepository<TruckLaneVersion, Long> { | |||
| fun findAllByTruckLanceCodeAndDeletedFalseOrderByCreatedDesc(truckLanceCode: String): List<TruckLaneVersion> | |||
| fun findAllByDeletedFalseOrderByCreatedDesc(): List<TruckLaneVersion> | |||
| fun findByIdAndDeletedFalse(id: Long): TruckLaneVersion? | |||
| } | |||
| @@ -1,6 +1,8 @@ | |||
| package com.ffii.fpsms.modules.pickOrder.entity | |||
| import com.ffii.core.support.AbstractRepository | |||
| import com.ffii.fpsms.modules.logistic.entity.Logistic | |||
| import org.springframework.data.jpa.repository.Modifying | |||
| import org.springframework.data.jpa.repository.Query | |||
| import org.springframework.data.repository.query.Param | |||
| import org.springframework.stereotype.Repository | |||
| @@ -32,8 +34,81 @@ interface TruckRepository : AbstractRepository<Truck, Long> { | |||
| fun findByTruckLanceCode(truckLanceCode: String): Truck? | |||
| @Query("SELECT t FROM Truck t WHERE t.truckLanceCode = :truckLanceCode AND t.deleted = false") | |||
| fun findAllByTruckLanceCodeAndDeletedFalse(@Param("truckLanceCode") truckLanceCode: String): List<Truck> | |||
| /** | |||
| * Same lane group as `findAllUniqueTruckLanceCodeAndRemarkCombinations`: | |||
| * remark NULL / blank belong to one bucket; non-blank matches exactly. | |||
| */ | |||
| @Query( | |||
| """ | |||
| SELECT DISTINCT t FROM Truck t | |||
| LEFT JOIN FETCH t.logistic | |||
| LEFT JOIN FETCH t.shop | |||
| WHERE t.truckLanceCode = :truckLanceCode | |||
| AND t.deleted = false | |||
| AND ( | |||
| (:blankRemark = true AND (t.remark IS NULL OR trim(t.remark) = '')) | |||
| OR (:blankRemark = false AND trim(t.remark) = :exactRemark) | |||
| ) | |||
| """ | |||
| ) | |||
| fun findAllByTruckLanceCodeAndRemarkAndDeletedFalse( | |||
| @Param("truckLanceCode") truckLanceCode: String, | |||
| @Param("blankRemark") blankRemark: Boolean, | |||
| @Param("exactRemark") exactRemark: String?, | |||
| ): List<Truck> | |||
| /** | |||
| * RouteBoard O(1) load: return all truck rows used by lanes, with logistic pre-fetched. | |||
| * Frontend groups by (truckLanceCode, normalizedRemark) where normalizedRemark is: | |||
| * - NULL / blank => "" | |||
| * - else TRIM(remark) | |||
| */ | |||
| @Query( | |||
| """ | |||
| SELECT t FROM Truck t | |||
| LEFT JOIN FETCH t.logistic | |||
| LEFT JOIN FETCH t.shop | |||
| WHERE t.deleted = false | |||
| AND t.truckLanceCode IS NOT NULL | |||
| AND trim(t.truckLanceCode) <> '' | |||
| ORDER BY t.truckLanceCode ASC, | |||
| CASE WHEN t.remark IS NULL OR trim(t.remark) = '' THEN '' ELSE trim(t.remark) END ASC, | |||
| t.loadingSequence ASC, | |||
| t.id ASC | |||
| """ | |||
| ) | |||
| fun findAllForRouteBoard(): List<Truck> | |||
| /** | |||
| * 單一 UPDATE 寫入整條 lane 的 logistic,避免先 JOIN FETCH 載入再逐列 save(大車線會極慢)。 | |||
| */ | |||
| @Modifying(clearAutomatically = true, flushAutomatically = true) | |||
| @Query( | |||
| """ | |||
| UPDATE Truck t SET t.logistic = :logistic | |||
| WHERE t.truckLanceCode = :truckLanceCode | |||
| AND t.deleted = false | |||
| AND ( | |||
| (:blankRemark = true AND (t.remark IS NULL OR trim(t.remark) = '')) | |||
| OR (:blankRemark = false AND trim(t.remark) = :exactRemark) | |||
| ) | |||
| """, | |||
| ) | |||
| fun bulkUpdateLogisticForLaneGroup( | |||
| @Param("logistic") logistic: Logistic?, | |||
| @Param("truckLanceCode") truckLanceCode: String, | |||
| @Param("blankRemark") blankRemark: Boolean, | |||
| @Param("exactRemark") exactRemark: String?, | |||
| ): Int | |||
| fun findAllByTruckLanceCodeAndStoreIdAndDeletedFalse(truckLanceCode: String, storeId: String): List<Truck> | |||
| fun findByShopNameAndStoreIdAndTruckLanceCode(shopName: String, storeId: String, truckLanceCode: String): Truck? | |||
| fun findByShopCodeAndStoreId(shopCode: String, storeId: String): Truck? | |||
| /** 同店同樓層重複列時取 id 最小一筆,避免 NonUniqueResultException */ | |||
| fun findFirstByShopCodeAndStoreIdAndDeletedFalseOrderByIdAsc( | |||
| shopCode: String, | |||
| storeId: String, | |||
| ): Truck? | |||
| fun findByShopIdAndStoreId(shopId: Long, storeId: String): Truck? | |||
| @@ -60,15 +135,17 @@ fun findByShopIdAndStoreIdAndDayOfWeek( | |||
| SELECT t.* | |||
| FROM truck t | |||
| INNER JOIN ( | |||
| SELECT TruckLanceCode, remark, MIN(id) as min_id | |||
| SELECT TruckLanceCode, | |||
| COALESCE(NULLIF(TRIM(remark), ''), '') AS remark_norm, | |||
| MIN(id) AS min_id | |||
| FROM truck | |||
| WHERE deleted = false | |||
| AND TruckLanceCode IS NOT NULL | |||
| GROUP BY TruckLanceCode, remark | |||
| GROUP BY TruckLanceCode, COALESCE(NULLIF(TRIM(remark), ''), '') | |||
| ) AS unique_combos | |||
| ON t.id = unique_combos.min_id | |||
| WHERE t.deleted = false | |||
| ORDER BY t.TruckLanceCode, t.remark | |||
| ORDER BY t.TruckLanceCode, COALESCE(NULLIF(TRIM(t.remark), ''), '') | |||
| """ | |||
| ) | |||
| fun findAllUniqueTruckLanceCodeAndRemarkCombinations(): List<Truck> | |||
| @@ -0,0 +1,202 @@ | |||
| package com.ffii.fpsms.modules.pickOrder.service | |||
| import org.apache.poi.ss.usermodel.BorderStyle | |||
| import org.apache.poi.ss.usermodel.FillPatternType | |||
| import org.apache.poi.ss.usermodel.HorizontalAlignment | |||
| import org.apache.poi.ss.usermodel.IndexedColors | |||
| import org.apache.poi.ss.usermodel.Sheet | |||
| import org.apache.poi.ss.usermodel.VerticalAlignment | |||
| import org.apache.poi.ss.usermodel.Workbook | |||
| import org.apache.poi.ss.util.CellRangeAddress | |||
| import org.apache.poi.ss.util.WorkbookUtil | |||
| import org.apache.poi.xssf.usermodel.XSSFCellStyle | |||
| import org.apache.poi.xssf.usermodel.XSSFFont | |||
| import org.apache.poi.xssf.usermodel.XSSFWorkbook | |||
| import java.net.URLDecoder | |||
| import java.nio.charset.StandardCharsets | |||
| /** | |||
| * MTMS 車線 Excel(PDF 圖1):每個車線一個 worksheet,格式版本 MTMS_ROUTE_V1。 | |||
| * laneId 與前端 [encodeLaneId] 一致:`encodeURIComponent(code)|encodeURIComponent(remark)`。 | |||
| */ | |||
| object RouteLaneExcelSupport { | |||
| const val FORMAT_MARKER = "MTMS_ROUTE_V1" | |||
| const val SEP = "|" | |||
| /** 0-based row indices */ | |||
| const val ROW_MARKER = 0 | |||
| const val ROW_STORE = 1 | |||
| const val ROW_DEPARTURE_DEFAULT = 2 | |||
| const val ROW_HEADER = 3 | |||
| const val ROW_FIRST_DATA = 4 | |||
| const val COL_META_A = 0 | |||
| const val COL_META_B = 1 | |||
| const val COL_META_C = 2 | |||
| const val COL_AREA_PLATE = 0 | |||
| const val COL_SHOP_NAME = 1 | |||
| const val COL_BRAND = 2 | |||
| const val COL_SHOP_CODE = 3 | |||
| const val COL_SCHEDULE = 4 | |||
| const val COL_DEPARTURE_ROW = 5 | |||
| fun decodeLaneId(laneId: String): Pair<String, String?>? { | |||
| val i = laneId.indexOf(SEP) | |||
| if (i < 0) return null | |||
| return try { | |||
| val code = URLDecoder.decode(laneId.substring(0, i), StandardCharsets.UTF_8).trim() | |||
| val rem = URLDecoder.decode(laneId.substring(i + SEP.length), StandardCharsets.UTF_8).trim() | |||
| if (code.isEmpty()) return null | |||
| code to if (rem.isEmpty()) null else rem | |||
| } catch (_: Exception) { | |||
| null | |||
| } | |||
| } | |||
| fun plateLabel(groupIndexZeroBased: Int): String { | |||
| val n = groupIndexZeroBased + 1 | |||
| val digits = arrayOf("一", "二", "三", "四", "五", "六", "七", "八", "九", "十") | |||
| val cn = when { | |||
| n in 1..10 -> digits[n - 1] | |||
| n in 11..19 -> "十" + digits[n - 11] | |||
| else -> "$n" | |||
| } | |||
| return "板$cn" | |||
| } | |||
| fun uniqueSheetName(workbook: Workbook, truckLanceCode: String, remark: String?): String { | |||
| val remarkPart = remark?.trim()?.takeIf { it.isNotEmpty() }?.let { "_${it.take(8)}" } ?: "" | |||
| val raw = (truckLanceCode.take(22) + remarkPart).take(31) | |||
| var base = WorkbookUtil.createSafeSheetName(raw).take(31) | |||
| if (base.isEmpty()) base = "Lane" | |||
| var name = base | |||
| var i = 0 | |||
| while (workbook.getSheet(name) != null) { | |||
| val suffix = "_$i" | |||
| val truncated = base.take((31 - suffix.length).coerceAtLeast(1)) | |||
| name = WorkbookUtil.createSafeSheetName(truncated + suffix).take(31) | |||
| i++ | |||
| } | |||
| return name | |||
| } | |||
| private data class RouteLaneExportStyles( | |||
| val metaKey: XSSFCellStyle, | |||
| val metaValue: XSSFCellStyle, | |||
| val header: XSSFCellStyle, | |||
| val data: XSSFCellStyle, | |||
| val dataAlt: XSSFCellStyle, | |||
| ) | |||
| private fun buildExportStyles(wb: XSSFWorkbook): RouteLaneExportStyles { | |||
| fun XSSFCellStyle.borders() { | |||
| borderTop = BorderStyle.THIN | |||
| borderBottom = BorderStyle.THIN | |||
| borderLeft = BorderStyle.THIN | |||
| borderRight = BorderStyle.THIN | |||
| } | |||
| val metaKey = (wb.createCellStyle() as XSSFCellStyle).apply { | |||
| alignment = HorizontalAlignment.LEFT | |||
| verticalAlignment = VerticalAlignment.CENTER | |||
| fillForegroundColor = IndexedColors.GREY_40_PERCENT.index | |||
| fillPattern = FillPatternType.SOLID_FOREGROUND | |||
| borders() | |||
| val f = wb.createFont() as XSSFFont | |||
| f.bold = true | |||
| f.fontHeightInPoints = 11 | |||
| setFont(f) | |||
| } | |||
| val metaValue = (wb.createCellStyle() as XSSFCellStyle).apply { | |||
| alignment = HorizontalAlignment.LEFT | |||
| verticalAlignment = VerticalAlignment.CENTER | |||
| fillForegroundColor = IndexedColors.WHITE.index | |||
| fillPattern = FillPatternType.SOLID_FOREGROUND | |||
| borders() | |||
| val f = wb.createFont() | |||
| f.fontHeightInPoints = 11 | |||
| setFont(f) | |||
| } | |||
| val headerFont = (wb.createFont() as XSSFFont).apply { | |||
| bold = true | |||
| fontHeightInPoints = 11 | |||
| color = IndexedColors.WHITE.index | |||
| } | |||
| val header = (wb.createCellStyle() as XSSFCellStyle).apply { | |||
| alignment = HorizontalAlignment.CENTER | |||
| verticalAlignment = VerticalAlignment.CENTER | |||
| fillForegroundColor = IndexedColors.ROYAL_BLUE.index | |||
| fillPattern = FillPatternType.SOLID_FOREGROUND | |||
| borders() | |||
| setFont(headerFont) | |||
| } | |||
| val data = (wb.createCellStyle() as XSSFCellStyle).apply { | |||
| alignment = HorizontalAlignment.LEFT | |||
| verticalAlignment = VerticalAlignment.CENTER | |||
| wrapText = true | |||
| borders() | |||
| val f = wb.createFont() | |||
| f.fontHeightInPoints = 11 | |||
| setFont(f) | |||
| } | |||
| val dataAlt = (wb.createCellStyle() as XSSFCellStyle).apply { | |||
| cloneStyleFrom(data) | |||
| fillPattern = FillPatternType.SOLID_FOREGROUND | |||
| fillForegroundColor = IndexedColors.GREY_25_PERCENT.index | |||
| } | |||
| return RouteLaneExportStyles(metaKey, metaValue, header, data, dataAlt) | |||
| } | |||
| /** | |||
| * 表頭/邊框/隔行底色/欄寬/凍結首列資料之上/AutoFilter。不改儲存格值(import 仍讀 raw)。 | |||
| */ | |||
| fun applyRouteLaneExportFinishing( | |||
| sheet: Sheet, | |||
| wb: XSSFWorkbook, | |||
| firstDataRow: Int, | |||
| lastDataRow: Int, | |||
| ) { | |||
| val st = buildExportStyles(wb) | |||
| for (r in intArrayOf(ROW_MARKER, ROW_STORE, ROW_DEPARTURE_DEFAULT)) { | |||
| val row = sheet.getRow(r) ?: continue | |||
| for (c in 0..COL_META_C) { | |||
| val cell = row.getCell(c) ?: continue | |||
| cell.cellStyle = if (c == COL_META_A) st.metaKey else st.metaValue | |||
| } | |||
| } | |||
| val headerRow = sheet.getRow(ROW_HEADER) | |||
| if (headerRow != null) { | |||
| for (c in COL_AREA_PLATE..COL_DEPARTURE_ROW) { | |||
| headerRow.getCell(c)?.cellStyle = st.header | |||
| } | |||
| } | |||
| if (lastDataRow >= firstDataRow) { | |||
| for (r in firstDataRow..lastDataRow) { | |||
| val alt = (r - firstDataRow) % 2 == 1 | |||
| val style = if (alt) st.dataAlt else st.data | |||
| val row = sheet.getRow(r) ?: continue | |||
| for (c in COL_AREA_PLATE..COL_DEPARTURE_ROW) { | |||
| row.getCell(c)?.cellStyle = style | |||
| } | |||
| } | |||
| } | |||
| sheet.setColumnWidth(COL_AREA_PLATE, 14 * 256) | |||
| sheet.setColumnWidth(COL_SHOP_NAME, 28 * 256) | |||
| sheet.setColumnWidth(COL_BRAND, 14 * 256) | |||
| sheet.setColumnWidth(COL_SHOP_CODE, 12 * 256) | |||
| sheet.setColumnWidth(COL_SCHEDULE, 12 * 256) | |||
| sheet.setColumnWidth(COL_DEPARTURE_ROW, 12 * 256) | |||
| sheet.createFreezePane(0, ROW_FIRST_DATA) | |||
| val filterLast = if (lastDataRow >= firstDataRow) lastDataRow else ROW_HEADER | |||
| sheet.setAutoFilter( | |||
| CellRangeAddress(ROW_HEADER, filterLast, COL_AREA_PLATE, COL_DEPARTURE_ROW), | |||
| ) | |||
| } | |||
| } | |||
| @@ -0,0 +1,359 @@ | |||
| package com.ffii.fpsms.modules.pickOrder.service | |||
| import org.apache.poi.ss.usermodel.BorderStyle | |||
| import org.apache.poi.ss.usermodel.FillPatternType | |||
| import org.apache.poi.ss.usermodel.HorizontalAlignment | |||
| import org.apache.poi.ss.usermodel.IndexedColors | |||
| import org.apache.poi.ss.usermodel.Sheet | |||
| import org.apache.poi.ss.usermodel.VerticalAlignment | |||
| import org.apache.poi.ss.util.CellRangeAddress | |||
| import org.apache.poi.ss.util.RegionUtil | |||
| import org.apache.poi.xssf.usermodel.XSSFCellStyle | |||
| import org.apache.poi.xssf.usermodel.XSSFFont | |||
| import org.apache.poi.xssf.usermodel.XSSFWorkbook | |||
| object RouteReportExcelSupport { | |||
| const val SHEET_NAME = "車線Report" | |||
| const val BLOCK_WIDTH = 2 // 每間物流公司一個 block:2 欄 | |||
| data class Styles( | |||
| val title: XSSFCellStyle, | |||
| val titlePreparedBy: XSSFCellStyle, | |||
| val company: XSSFCellStyle, | |||
| val plate: XSSFCellStyle, | |||
| val timeHeader: XSSFCellStyle, | |||
| val laneLeft: XSSFCellStyle, | |||
| val laneFill: XSSFCellStyle, | |||
| val district: XSSFCellStyle, | |||
| val shopNo: XSSFCellStyle, | |||
| val shopText: XSSFCellStyle, | |||
| val total: XSSFCellStyle, | |||
| val driverLabel: XSSFCellStyle, | |||
| val driverValue: XSSFCellStyle, | |||
| ) | |||
| private fun borders(st: XSSFCellStyle, border: BorderStyle = BorderStyle.THIN) { | |||
| st.borderTop = border | |||
| st.borderBottom = border | |||
| st.borderLeft = border | |||
| st.borderRight = border | |||
| } | |||
| fun buildStyles(wb: XSSFWorkbook): Styles { | |||
| fun font( | |||
| size: Short, | |||
| bold: Boolean = false, | |||
| color: Short? = null, | |||
| ): XSSFFont { | |||
| val f = wb.createFont() as XSSFFont | |||
| f.fontHeightInPoints = size | |||
| f.bold = bold | |||
| if (color != null) f.color = color | |||
| return f | |||
| } | |||
| val title = (wb.createCellStyle() as XSSFCellStyle).apply { | |||
| alignment = HorizontalAlignment.CENTER | |||
| verticalAlignment = VerticalAlignment.CENTER | |||
| borders(this, BorderStyle.MEDIUM) | |||
| setFont(font(16, bold = true)) | |||
| } | |||
| val titlePreparedBy = (wb.createCellStyle() as XSSFCellStyle).apply { | |||
| alignment = HorizontalAlignment.RIGHT | |||
| verticalAlignment = VerticalAlignment.CENTER | |||
| borders(this, BorderStyle.MEDIUM) | |||
| setFont(font(11, bold = true)) | |||
| } | |||
| val company = (wb.createCellStyle() as XSSFCellStyle).apply { | |||
| alignment = HorizontalAlignment.CENTER | |||
| verticalAlignment = VerticalAlignment.CENTER | |||
| fillForegroundColor = IndexedColors.GREY_25_PERCENT.index | |||
| fillPattern = FillPatternType.SOLID_FOREGROUND | |||
| borders(this, BorderStyle.MEDIUM) | |||
| setFont(font(12, bold = true)) | |||
| } | |||
| val plate = (wb.createCellStyle() as XSSFCellStyle).apply { | |||
| alignment = HorizontalAlignment.CENTER | |||
| verticalAlignment = VerticalAlignment.CENTER | |||
| borders(this, BorderStyle.THIN) | |||
| setFont(font(11, bold = true)) | |||
| } | |||
| val timeHeader = (wb.createCellStyle() as XSSFCellStyle).apply { | |||
| alignment = HorizontalAlignment.CENTER | |||
| verticalAlignment = VerticalAlignment.CENTER | |||
| fillForegroundColor = IndexedColors.LIGHT_YELLOW.index | |||
| fillPattern = FillPatternType.SOLID_FOREGROUND | |||
| borders(this, BorderStyle.MEDIUM) | |||
| setFont(font(11, bold = true)) | |||
| } | |||
| val laneLeft = (wb.createCellStyle() as XSSFCellStyle).apply { | |||
| alignment = HorizontalAlignment.CENTER | |||
| verticalAlignment = VerticalAlignment.CENTER | |||
| fillForegroundColor = IndexedColors.GREY_40_PERCENT.index | |||
| fillPattern = FillPatternType.SOLID_FOREGROUND | |||
| borders(this, BorderStyle.MEDIUM) | |||
| setFont(font(11, bold = true, color = IndexedColors.WHITE.index)) | |||
| } | |||
| val laneFill = (wb.createCellStyle() as XSSFCellStyle).apply { | |||
| alignment = HorizontalAlignment.LEFT | |||
| verticalAlignment = VerticalAlignment.CENTER | |||
| fillForegroundColor = IndexedColors.GREY_25_PERCENT.index | |||
| fillPattern = FillPatternType.SOLID_FOREGROUND | |||
| borders(this, BorderStyle.MEDIUM) | |||
| setFont(font(11, bold = true)) | |||
| } | |||
| val district = (wb.createCellStyle() as XSSFCellStyle).apply { | |||
| alignment = HorizontalAlignment.LEFT | |||
| verticalAlignment = VerticalAlignment.CENTER | |||
| fillForegroundColor = IndexedColors.LIGHT_CORNFLOWER_BLUE.index | |||
| fillPattern = FillPatternType.SOLID_FOREGROUND | |||
| borders(this, BorderStyle.THIN) | |||
| setFont(font(11, bold = true)) | |||
| } | |||
| val shopNo = (wb.createCellStyle() as XSSFCellStyle).apply { | |||
| alignment = HorizontalAlignment.RIGHT | |||
| verticalAlignment = VerticalAlignment.TOP | |||
| borders(this, BorderStyle.THIN) | |||
| setFont(font(11, bold = true)) | |||
| } | |||
| val shopText = (wb.createCellStyle() as XSSFCellStyle).apply { | |||
| alignment = HorizontalAlignment.LEFT | |||
| verticalAlignment = VerticalAlignment.TOP | |||
| wrapText = true | |||
| borders(this, BorderStyle.THIN) | |||
| setFont(font(11)) | |||
| } | |||
| val total = (wb.createCellStyle() as XSSFCellStyle).apply { | |||
| alignment = HorizontalAlignment.LEFT | |||
| verticalAlignment = VerticalAlignment.CENTER | |||
| fillForegroundColor = IndexedColors.GREY_25_PERCENT.index | |||
| fillPattern = FillPatternType.SOLID_FOREGROUND | |||
| borders(this, BorderStyle.MEDIUM) | |||
| setFont(font(11, bold = true)) | |||
| } | |||
| val driverLabel = (wb.createCellStyle() as XSSFCellStyle).apply { | |||
| alignment = HorizontalAlignment.CENTER | |||
| verticalAlignment = VerticalAlignment.CENTER | |||
| fillForegroundColor = IndexedColors.GREY_40_PERCENT.index | |||
| fillPattern = FillPatternType.SOLID_FOREGROUND | |||
| borders(this, BorderStyle.MEDIUM) | |||
| setFont(font(11, bold = true, color = IndexedColors.WHITE.index)) | |||
| } | |||
| val driverValue = (wb.createCellStyle() as XSSFCellStyle).apply { | |||
| alignment = HorizontalAlignment.LEFT | |||
| verticalAlignment = VerticalAlignment.CENTER | |||
| borders(this, BorderStyle.MEDIUM) | |||
| setFont(font(11, bold = true)) | |||
| } | |||
| return Styles( | |||
| title = title, | |||
| titlePreparedBy = titlePreparedBy, | |||
| company = company, | |||
| plate = plate, | |||
| timeHeader = timeHeader, | |||
| laneLeft = laneLeft, | |||
| laneFill = laneFill, | |||
| district = district, | |||
| shopNo = shopNo, | |||
| shopText = shopText, | |||
| total = total, | |||
| driverLabel = driverLabel, | |||
| driverValue = driverValue, | |||
| ) | |||
| } | |||
| private fun ensureRow(sheet: Sheet, r: Int) = sheet.getRow(r) ?: sheet.createRow(r) | |||
| private fun ensureCell(sheet: Sheet, r: Int, c: Int) = | |||
| ensureRow(sheet, r).let { row -> row.getCell(c) ?: row.createCell(c) } | |||
| fun styleRange( | |||
| sheet: Sheet, | |||
| row: Int, | |||
| firstCol: Int, | |||
| lastCol: Int, | |||
| style: XSSFCellStyle, | |||
| ) { | |||
| for (c in firstCol..lastCol) { | |||
| ensureCell(sheet, row, c).cellStyle = style | |||
| } | |||
| } | |||
| fun mergeAndStyle( | |||
| sheet: Sheet, | |||
| row: Int, | |||
| firstCol: Int, | |||
| lastCol: Int, | |||
| style: XSSFCellStyle, | |||
| border: BorderStyle = BorderStyle.MEDIUM, | |||
| ) { | |||
| for (c in firstCol..lastCol) { | |||
| ensureCell(sheet, row, c).cellStyle = style | |||
| } | |||
| // POI 不允許 merge 單一 cell(需 2+ cells)。此時只套 style + cell border 即可。 | |||
| if (firstCol == lastCol) return | |||
| val region = CellRangeAddress(row, row, firstCol, lastCol) | |||
| sheet.addMergedRegion(region) | |||
| RegionUtil.setBorderTop(border, region, sheet) | |||
| RegionUtil.setBorderBottom(border, region, sheet) | |||
| RegionUtil.setBorderLeft(border, region, sheet) | |||
| RegionUtil.setBorderRight(border, region, sheet) | |||
| } | |||
| fun applyColumnWidths(sheet: Sheet, blockIndex: Int) { | |||
| val base = blockIndex * BLOCK_WIDTH | |||
| sheet.setColumnWidth(base + 0, 10 * 256) | |||
| sheet.setColumnWidth(base + 1, 26 * 256) | |||
| } | |||
| fun writeTitle( | |||
| sheet: Sheet, | |||
| st: Styles, | |||
| titleText: String, | |||
| preparedByText: String, | |||
| totalBlocks: Int, | |||
| ) { | |||
| val lastCol = (totalBlocks * BLOCK_WIDTH - 1).coerceAtLeast(0) | |||
| val r = 0 | |||
| // 預留右邊 2 欄顯示「製表: xxx」 | |||
| val preparedCols = 1.coerceAtMost(lastCol + 1) | |||
| val preparedFirstCol = (lastCol - preparedCols + 1).coerceAtLeast(0) | |||
| val titleLastCol = (preparedFirstCol - 1).coerceAtLeast(0) | |||
| if (preparedFirstCol == 0) { | |||
| // 欄位不足:整行仍以 title style 輸出(避免 merge 範圍倒轉) | |||
| mergeAndStyle(sheet, r, 0, lastCol, st.title, BorderStyle.MEDIUM) | |||
| ensureCell(sheet, r, 0).setCellValue("$titleText $preparedByText") | |||
| } else { | |||
| mergeAndStyle(sheet, r, 0, titleLastCol, st.title, BorderStyle.MEDIUM) | |||
| ensureCell(sheet, r, 0).setCellValue(titleText) | |||
| mergeAndStyle(sheet, r, preparedFirstCol, lastCol, st.titlePreparedBy, BorderStyle.MEDIUM) | |||
| ensureCell(sheet, r, preparedFirstCol).setCellValue(preparedByText) | |||
| } | |||
| sheet.getRow(r)?.heightInPoints = 26f | |||
| } | |||
| data class BlockMeta( | |||
| val companyName: String, | |||
| val plate: String, | |||
| val driverName: String, | |||
| val driverNumber: String, | |||
| ) | |||
| /** | |||
| * @return 最後寫到的 row index(含) | |||
| */ | |||
| fun writeCompanyBlock( | |||
| sheet: Sheet, | |||
| st: Styles, | |||
| blockIndex: Int, | |||
| startRow: Int, | |||
| meta: BlockMeta, | |||
| groups: List<TimeGroup>, | |||
| totalShopCount: Int, | |||
| ): Int { | |||
| val baseCol = blockIndex * BLOCK_WIDTH | |||
| applyColumnWidths(sheet, blockIndex) | |||
| var r = startRow | |||
| // 公司名 | |||
| mergeAndStyle(sheet, r, baseCol, baseCol + 1, st.company, BorderStyle.MEDIUM) | |||
| ensureCell(sheet, r, baseCol).setCellValue(meta.companyName) | |||
| sheet.getRow(r)?.heightInPoints = 18f | |||
| r++ | |||
| // 車牌 | |||
| mergeAndStyle(sheet, r, baseCol, baseCol + 1, st.plate, BorderStyle.THIN) | |||
| ensureCell(sheet, r, baseCol).setCellValue(meta.plate) | |||
| r++ | |||
| for (tg in groups) { | |||
| mergeAndStyle(sheet, r, baseCol, baseCol + 1, st.timeHeader, BorderStyle.MEDIUM) | |||
| ensureCell(sheet, r, baseCol).setCellValue(tg.timeLabel) | |||
| r++ | |||
| for (lg in tg.lanes) { | |||
| // 車線標題:左一格強調,右三格補底 | |||
| ensureCell(sheet, r, baseCol).apply { | |||
| cellStyle = st.laneLeft | |||
| setCellValue(lg.laneCode) | |||
| } | |||
| // 2 欄版:右側只剩 1 格(不 merge) | |||
| ensureCell(sheet, r, baseCol + 1).cellStyle = st.laneFill | |||
| r++ | |||
| for (dg in lg.districts) { | |||
| mergeAndStyle(sheet, r, baseCol, baseCol + 1, st.district, BorderStyle.THIN) | |||
| ensureCell(sheet, r, baseCol).setCellValue(dg.districtLabel) | |||
| r++ | |||
| var idx = 1 | |||
| for (s in dg.shops) { | |||
| ensureCell(sheet, r, baseCol).apply { | |||
| cellStyle = st.shopNo | |||
| setCellValue("$idx.") | |||
| } | |||
| // shop row 不做 merge:避免 merged regions 爆量導致寫檔/開檔變慢 | |||
| styleRange(sheet, r, baseCol + 1, baseCol + 1, st.shopText) | |||
| ensureCell(sheet, r, baseCol + 1).setCellValue(s) | |||
| val lines = (s.count { it == '\n' } + 1).coerceAtLeast(1) | |||
| val h = (16f * lines).coerceIn(18f, 72f) | |||
| sheet.getRow(r)?.heightInPoints = h | |||
| r++ | |||
| idx++ | |||
| } | |||
| } | |||
| } | |||
| } | |||
| // 分店數目 | |||
| mergeAndStyle(sheet, r, baseCol, baseCol + 1, st.total, BorderStyle.MEDIUM) | |||
| ensureCell(sheet, r, baseCol).setCellValue("分店數目:$totalShopCount") | |||
| r++ | |||
| // 車長 / driver | |||
| ensureCell(sheet, r, baseCol).cellStyle = st.driverLabel | |||
| ensureCell(sheet, r, baseCol).setCellValue("車長") | |||
| ensureCell(sheet, r, baseCol + 1).cellStyle = st.driverValue | |||
| ensureCell(sheet, r, baseCol + 1).setCellValue(meta.driverName) | |||
| r++ | |||
| // driver number | |||
| // 2 欄版:電話/司機號碼跨兩欄合併成一格(像截圖的大白格) | |||
| mergeAndStyle(sheet, r, baseCol, baseCol + 1, st.driverValue, BorderStyle.MEDIUM) | |||
| ensureCell(sheet, r, baseCol).setCellValue(meta.driverNumber) | |||
| r++ | |||
| return r - 1 | |||
| } | |||
| data class TimeGroup( | |||
| val timeLabel: String, | |||
| val lanes: List<LaneGroup>, | |||
| ) | |||
| data class LaneGroup( | |||
| val laneCode: String, | |||
| val districts: List<DistrictGroup>, | |||
| ) | |||
| data class DistrictGroup( | |||
| val districtLabel: String, | |||
| val shops: List<String>, | |||
| ) | |||
| } | |||
| @@ -0,0 +1,194 @@ | |||
| package com.ffii.fpsms.modules.pickOrder.service | |||
| import org.apache.poi.ss.usermodel.BorderStyle | |||
| import org.apache.poi.ss.usermodel.FillPatternType | |||
| import org.apache.poi.ss.usermodel.HorizontalAlignment | |||
| import org.apache.poi.ss.usermodel.IndexedColors | |||
| import org.apache.poi.ss.usermodel.Sheet | |||
| import org.apache.poi.ss.usermodel.VerticalAlignment | |||
| import org.apache.poi.ss.util.CellRangeAddress | |||
| import org.apache.poi.ss.util.RegionUtil | |||
| import org.apache.poi.xssf.usermodel.XSSFCellStyle | |||
| import org.apache.poi.xssf.usermodel.XSSFFont | |||
| import org.apache.poi.xssf.usermodel.XSSFWorkbook | |||
| object TruckLaneVersionReportExcelSupport { | |||
| const val SUMMARY_SHEET = "版本異動報告" | |||
| private data class Styles( | |||
| val title: XSSFCellStyle, | |||
| val metaKey: XSSFCellStyle, | |||
| val metaVal: XSSFCellStyle, | |||
| val header: XSSFCellStyle, | |||
| val normal: XSSFCellStyle, | |||
| val added: XSSFCellStyle, | |||
| val deleted: XSSFCellStyle, | |||
| val moved: XSSFCellStyle, | |||
| val edited: XSSFCellStyle, | |||
| val highlight: XSSFCellStyle, | |||
| ) | |||
| private fun buildStyles(wb: XSSFWorkbook): Styles { | |||
| fun font(size: Short, bold: Boolean = false, color: Short? = null): XSSFFont { | |||
| val f = wb.createFont() as XSSFFont | |||
| f.fontHeightInPoints = size | |||
| f.bold = bold | |||
| if (color != null) f.color = color | |||
| return f | |||
| } | |||
| fun style( | |||
| align: HorizontalAlignment, | |||
| vAlign: VerticalAlignment = VerticalAlignment.CENTER, | |||
| bg: Short? = null, | |||
| bold: Boolean = false, | |||
| size: Short = 11, | |||
| border: BorderStyle = BorderStyle.THIN, | |||
| ): XSSFCellStyle { | |||
| return (wb.createCellStyle() as XSSFCellStyle).apply { | |||
| alignment = align | |||
| verticalAlignment = vAlign | |||
| borderTop = border | |||
| borderBottom = border | |||
| borderLeft = border | |||
| borderRight = border | |||
| if (bg != null) { | |||
| fillForegroundColor = bg | |||
| fillPattern = FillPatternType.SOLID_FOREGROUND | |||
| } | |||
| setFont(font(size, bold = bold)) | |||
| wrapText = true | |||
| } | |||
| } | |||
| val title = style(HorizontalAlignment.CENTER, bg = IndexedColors.WHITE.index, bold = true, size = 16, border = BorderStyle.MEDIUM) | |||
| val metaKey = style(HorizontalAlignment.LEFT, bg = IndexedColors.GREY_25_PERCENT.index, bold = true, border = BorderStyle.THIN) | |||
| val metaVal = style(HorizontalAlignment.LEFT, bg = IndexedColors.WHITE.index, bold = false, border = BorderStyle.THIN) | |||
| val header = (wb.createCellStyle() as XSSFCellStyle).apply { | |||
| alignment = HorizontalAlignment.CENTER | |||
| verticalAlignment = VerticalAlignment.CENTER | |||
| fillForegroundColor = IndexedColors.ROYAL_BLUE.index | |||
| fillPattern = FillPatternType.SOLID_FOREGROUND | |||
| borderTop = BorderStyle.MEDIUM | |||
| borderBottom = BorderStyle.MEDIUM | |||
| borderLeft = BorderStyle.MEDIUM | |||
| borderRight = BorderStyle.MEDIUM | |||
| setFont(font(11, bold = true, color = IndexedColors.WHITE.index)) | |||
| } | |||
| val normal = style(HorizontalAlignment.LEFT) | |||
| val added = style(HorizontalAlignment.LEFT, bg = IndexedColors.LIGHT_GREEN.index, border = BorderStyle.THIN) | |||
| val deleted = style(HorizontalAlignment.LEFT, bg = IndexedColors.ROSE.index, border = BorderStyle.THIN) | |||
| val moved = style(HorizontalAlignment.LEFT, bg = IndexedColors.LIGHT_YELLOW.index, border = BorderStyle.THIN) | |||
| val edited = style(HorizontalAlignment.LEFT, bg = IndexedColors.GREY_25_PERCENT.index, border = BorderStyle.THIN) | |||
| val highlight = style(HorizontalAlignment.LEFT, bg = IndexedColors.LIGHT_ORANGE.index, border = BorderStyle.THIN, bold = true) | |||
| return Styles(title, metaKey, metaVal, header, normal, added, deleted, moved, edited, highlight) | |||
| } | |||
| private fun ensureRow(sheet: Sheet, r: Int) = sheet.getRow(r) ?: sheet.createRow(r) | |||
| private fun cell(sheet: Sheet, r: Int, c: Int) = ensureRow(sheet, r).getCell(c) ?: ensureRow(sheet, r).createCell(c) | |||
| private fun mergeRow(sheet: Sheet, r: Int, c0: Int, c1: Int, style: XSSFCellStyle) { | |||
| for (c in c0..c1) cell(sheet, r, c).cellStyle = style | |||
| if (c0 == c1) return | |||
| val region = CellRangeAddress(r, r, c0, c1) | |||
| sheet.addMergedRegion(region) | |||
| RegionUtil.setBorderTop(BorderStyle.MEDIUM, region, sheet) | |||
| RegionUtil.setBorderBottom(BorderStyle.MEDIUM, region, sheet) | |||
| RegionUtil.setBorderLeft(BorderStyle.MEDIUM, region, sheet) | |||
| RegionUtil.setBorderRight(BorderStyle.MEDIUM, region, sheet) | |||
| } | |||
| data class SummaryMeta( | |||
| val title: String, | |||
| val editor: String, | |||
| val created: String, | |||
| val fromVersionId: Long, | |||
| val toVersionId: Long, | |||
| val note: String?, | |||
| val statsText: String, | |||
| ) | |||
| enum class RowType { ADDED, DELETED, MOVED, EDITED } | |||
| data class SummaryRow( | |||
| val type: RowType, | |||
| val shopName: String, | |||
| val shopCode: String, | |||
| val fromLane: String, | |||
| val toLane: String, | |||
| val changeText: String, | |||
| /** 欄位名集合,用於高亮「變更資訊」cell */ | |||
| val changedFields: Set<String> = emptySet(), | |||
| ) | |||
| fun writeSummarySheet(wb: XSSFWorkbook, meta: SummaryMeta, rows: List<SummaryRow>) { | |||
| val st = buildStyles(wb) | |||
| val sheet = wb.createSheet(SUMMARY_SHEET) | |||
| // column widths | |||
| sheet.setColumnWidth(0, 10 * 256) // type | |||
| sheet.setColumnWidth(1, 22 * 256) // shop | |||
| sheet.setColumnWidth(2, 12 * 256) // code | |||
| sheet.setColumnWidth(3, 18 * 256) // from | |||
| sheet.setColumnWidth(4, 18 * 256) // to | |||
| sheet.setColumnWidth(5, 60 * 256) // text | |||
| var r = 0 | |||
| mergeRow(sheet, r, 0, 5, st.title) | |||
| cell(sheet, r, 0).setCellValue(meta.title) | |||
| sheet.getRow(r)?.heightInPoints = 26f | |||
| r++ | |||
| fun metaRow(k: String, v: String) { | |||
| cell(sheet, r, 0).apply { cellStyle = st.metaKey; setCellValue(k) } | |||
| mergeRow(sheet, r, 1, 5, st.metaVal) | |||
| cell(sheet, r, 1).setCellValue(v) | |||
| r++ | |||
| } | |||
| metaRow("編輯者", meta.editor) | |||
| metaRow("建立時間", meta.created) | |||
| metaRow("版本", "from #${meta.fromVersionId} → to #${meta.toVersionId}") | |||
| metaRow("摘要", meta.statsText) | |||
| if (!meta.note.isNullOrBlank()) metaRow("備註", meta.note.trim()) | |||
| r++ | |||
| // header | |||
| val headerRowIndex = r | |||
| val headers = listOf("類型", "分店", "代碼", "From 車線", "To 車線", "變更資訊") | |||
| for (c in headers.indices) { | |||
| cell(sheet, r, c).apply { cellStyle = st.header; setCellValue(headers[c]) } | |||
| } | |||
| sheet.getRow(r)?.heightInPoints = 18f | |||
| r++ | |||
| for (row in rows) { | |||
| val baseStyle = | |||
| when (row.type) { | |||
| RowType.ADDED -> st.added | |||
| RowType.DELETED -> st.deleted | |||
| RowType.MOVED -> st.moved | |||
| RowType.EDITED -> st.edited | |||
| } | |||
| fun set(c: Int, v: String, highlight: Boolean = false) { | |||
| cell(sheet, r, c).apply { | |||
| cellStyle = if (highlight) st.highlight else baseStyle | |||
| setCellValue(v) | |||
| } | |||
| } | |||
| set(0, row.type.name) | |||
| set(1, row.shopName) | |||
| set(2, row.shopCode) | |||
| set(3, row.fromLane, highlight = row.type == RowType.MOVED || row.type == RowType.ADDED || row.type == RowType.DELETED) | |||
| set(4, row.toLane, highlight = row.type == RowType.MOVED || row.type == RowType.ADDED || row.type == RowType.DELETED) | |||
| set(5, row.changeText, highlight = row.changedFields.isNotEmpty()) | |||
| r++ | |||
| } | |||
| sheet.createFreezePane(0, headerRowIndex + 1) | |||
| } | |||
| } | |||
| @@ -0,0 +1,300 @@ | |||
| package com.ffii.fpsms.modules.pickOrder.service | |||
| import org.apache.poi.ss.usermodel.BorderStyle | |||
| import org.apache.poi.ss.usermodel.FillPatternType | |||
| import org.apache.poi.ss.usermodel.HorizontalAlignment | |||
| import org.apache.poi.ss.usermodel.IndexedColors | |||
| import org.apache.poi.ss.usermodel.Sheet | |||
| import org.apache.poi.ss.usermodel.VerticalAlignment | |||
| import org.apache.poi.ss.util.CellRangeAddress | |||
| import org.apache.poi.ss.util.RegionUtil | |||
| import org.apache.poi.xssf.usermodel.XSSFCellStyle | |||
| import org.apache.poi.xssf.usermodel.XSSFFont | |||
| import org.apache.poi.xssf.usermodel.XSSFWorkbook | |||
| /** | |||
| * 版本 Log 用:輸出「車線報告」版面(同正常 RouteReport),但把異動的 shop row 高亮。 | |||
| * | |||
| * 2 欄 block:左序號 / label、右內容。 | |||
| */ | |||
| object TruckLaneVersionRouteReportExcelSupport { | |||
| const val SHEET_NAME = "車線報告(版本)" | |||
| const val BLOCK_WIDTH = 2 | |||
| data class Styles( | |||
| val title: XSSFCellStyle, | |||
| val titlePreparedBy: XSSFCellStyle, | |||
| val company: XSSFCellStyle, | |||
| val plate: XSSFCellStyle, | |||
| val timeHeader: XSSFCellStyle, | |||
| val laneLeft: XSSFCellStyle, | |||
| val laneFill: XSSFCellStyle, | |||
| val district: XSSFCellStyle, | |||
| val shopNo: XSSFCellStyle, | |||
| val shopText: XSSFCellStyle, | |||
| val shopNoChanged: XSSFCellStyle, | |||
| val shopTextChanged: XSSFCellStyle, | |||
| val total: XSSFCellStyle, | |||
| val driverLabel: XSSFCellStyle, | |||
| val driverValue: XSSFCellStyle, | |||
| ) | |||
| fun buildStyles(wb: XSSFWorkbook): Styles { | |||
| fun font(size: Short, bold: Boolean = false, color: Short? = null): XSSFFont { | |||
| val f = wb.createFont() as XSSFFont | |||
| f.fontHeightInPoints = size | |||
| f.bold = bold | |||
| if (color != null) f.color = color | |||
| return f | |||
| } | |||
| fun borders(st: XSSFCellStyle, border: BorderStyle) { | |||
| st.borderTop = border | |||
| st.borderBottom = border | |||
| st.borderLeft = border | |||
| st.borderRight = border | |||
| } | |||
| fun baseCell( | |||
| align: HorizontalAlignment, | |||
| bg: Short? = null, | |||
| bold: Boolean = false, | |||
| size: Short = 11, | |||
| border: BorderStyle = BorderStyle.THIN, | |||
| wrap: Boolean = false, | |||
| ): XSSFCellStyle { | |||
| return (wb.createCellStyle() as XSSFCellStyle).apply { | |||
| alignment = align | |||
| verticalAlignment = VerticalAlignment.CENTER | |||
| borders(this, border) | |||
| if (bg != null) { | |||
| fillForegroundColor = bg | |||
| fillPattern = FillPatternType.SOLID_FOREGROUND | |||
| } | |||
| setFont(font(size, bold = bold)) | |||
| wrapText = wrap | |||
| } | |||
| } | |||
| val title = baseCell(HorizontalAlignment.CENTER, bold = true, size = 16, border = BorderStyle.MEDIUM) | |||
| val titlePreparedBy = baseCell(HorizontalAlignment.RIGHT, bold = true, size = 11, border = BorderStyle.MEDIUM) | |||
| val company = baseCell( | |||
| HorizontalAlignment.CENTER, | |||
| bg = IndexedColors.GREY_25_PERCENT.index, | |||
| bold = true, | |||
| size = 12, | |||
| border = BorderStyle.MEDIUM, | |||
| ) | |||
| val plate = baseCell(HorizontalAlignment.CENTER, bold = true, border = BorderStyle.THIN) | |||
| val timeHeader = baseCell( | |||
| HorizontalAlignment.CENTER, | |||
| bg = IndexedColors.LIGHT_YELLOW.index, | |||
| bold = true, | |||
| border = BorderStyle.MEDIUM, | |||
| ) | |||
| val laneLeft = baseCell( | |||
| HorizontalAlignment.CENTER, | |||
| bg = IndexedColors.GREY_40_PERCENT.index, | |||
| bold = true, | |||
| border = BorderStyle.MEDIUM, | |||
| ).apply { setFont(font(11, bold = true, color = IndexedColors.WHITE.index)) } | |||
| val laneFill = baseCell( | |||
| HorizontalAlignment.LEFT, | |||
| bg = IndexedColors.GREY_25_PERCENT.index, | |||
| bold = true, | |||
| border = BorderStyle.MEDIUM, | |||
| ) | |||
| val district = baseCell( | |||
| HorizontalAlignment.LEFT, | |||
| bg = IndexedColors.LIGHT_CORNFLOWER_BLUE.index, | |||
| bold = true, | |||
| border = BorderStyle.THIN, | |||
| ) | |||
| val shopNo = baseCell(HorizontalAlignment.RIGHT, border = BorderStyle.THIN).apply { | |||
| verticalAlignment = VerticalAlignment.TOP | |||
| setFont(font(11, bold = true)) | |||
| } | |||
| val shopText = baseCell(HorizontalAlignment.LEFT, border = BorderStyle.THIN, wrap = true).apply { | |||
| verticalAlignment = VerticalAlignment.TOP | |||
| } | |||
| val shopNoChanged = (wb.createCellStyle() as XSSFCellStyle).apply { | |||
| cloneStyleFrom(shopNo) | |||
| fillForegroundColor = IndexedColors.LIGHT_ORANGE.index | |||
| fillPattern = FillPatternType.SOLID_FOREGROUND | |||
| } | |||
| val shopTextChanged = (wb.createCellStyle() as XSSFCellStyle).apply { | |||
| cloneStyleFrom(shopText) | |||
| fillForegroundColor = IndexedColors.LIGHT_ORANGE.index | |||
| fillPattern = FillPatternType.SOLID_FOREGROUND | |||
| val f = wb.createFont() | |||
| f.fontHeightInPoints = 11 | |||
| f.bold = true | |||
| setFont(f) | |||
| } | |||
| val total = baseCell(HorizontalAlignment.LEFT, bg = IndexedColors.GREY_25_PERCENT.index, bold = true, border = BorderStyle.MEDIUM) | |||
| val driverLabel = baseCell( | |||
| HorizontalAlignment.CENTER, | |||
| bg = IndexedColors.GREY_40_PERCENT.index, | |||
| bold = true, | |||
| border = BorderStyle.MEDIUM, | |||
| ).apply { setFont(font(11, bold = true, color = IndexedColors.WHITE.index)) } | |||
| val driverValue = baseCell(HorizontalAlignment.LEFT, bold = true, border = BorderStyle.MEDIUM) | |||
| return Styles( | |||
| title = title, | |||
| titlePreparedBy = titlePreparedBy, | |||
| company = company, | |||
| plate = plate, | |||
| timeHeader = timeHeader, | |||
| laneLeft = laneLeft, | |||
| laneFill = laneFill, | |||
| district = district, | |||
| shopNo = shopNo, | |||
| shopText = shopText, | |||
| shopNoChanged = shopNoChanged, | |||
| shopTextChanged = shopTextChanged, | |||
| total = total, | |||
| driverLabel = driverLabel, | |||
| driverValue = driverValue, | |||
| ) | |||
| } | |||
| private fun ensureRow(sheet: Sheet, r: Int) = sheet.getRow(r) ?: sheet.createRow(r) | |||
| private fun cell(sheet: Sheet, r: Int, c: Int) = ensureRow(sheet, r).getCell(c) ?: ensureRow(sheet, r).createCell(c) | |||
| private fun mergeAndStyle(sheet: Sheet, r: Int, c0: Int, c1: Int, style: XSSFCellStyle, border: BorderStyle) { | |||
| for (c in c0..c1) cell(sheet, r, c).cellStyle = style | |||
| if (c0 == c1) return | |||
| val region = CellRangeAddress(r, r, c0, c1) | |||
| sheet.addMergedRegion(region) | |||
| RegionUtil.setBorderTop(border, region, sheet) | |||
| RegionUtil.setBorderBottom(border, region, sheet) | |||
| RegionUtil.setBorderLeft(border, region, sheet) | |||
| RegionUtil.setBorderRight(border, region, sheet) | |||
| } | |||
| fun applyColumnWidths(sheet: Sheet, blockIndex: Int) { | |||
| val base = blockIndex * BLOCK_WIDTH | |||
| sheet.setColumnWidth(base + 0, 10 * 256) | |||
| sheet.setColumnWidth(base + 1, 30 * 256) | |||
| } | |||
| data class BlockMeta( | |||
| val companyName: String, | |||
| val plate: String, | |||
| val driverName: String, | |||
| val driverNumber: String, | |||
| ) | |||
| data class ShopRow( | |||
| val truckRowId: Long, | |||
| val text: String, | |||
| val changed: Boolean, | |||
| ) | |||
| data class DistrictGroup( | |||
| val district: String, | |||
| val shops: List<ShopRow>, | |||
| ) | |||
| data class LaneGroup( | |||
| val laneLabel: String, | |||
| val districts: List<DistrictGroup>, | |||
| ) | |||
| data class TimeGroup( | |||
| val timeLabel: String, | |||
| val lanes: List<LaneGroup>, | |||
| ) | |||
| fun writeTitle(sheet: Sheet, st: Styles, titleText: String, preparedByText: String, totalBlocks: Int) { | |||
| val lastCol = (totalBlocks * BLOCK_WIDTH - 1).coerceAtLeast(0) | |||
| val r = 0 | |||
| val preparedCols = 1.coerceAtMost(lastCol + 1) | |||
| val preparedFirstCol = (lastCol - preparedCols + 1).coerceAtLeast(0) | |||
| val titleLastCol = (preparedFirstCol - 1).coerceAtLeast(0) | |||
| if (preparedFirstCol == 0) { | |||
| mergeAndStyle(sheet, r, 0, lastCol, st.title, BorderStyle.MEDIUM) | |||
| cell(sheet, r, 0).setCellValue("$titleText $preparedByText") | |||
| } else { | |||
| mergeAndStyle(sheet, r, 0, titleLastCol, st.title, BorderStyle.MEDIUM) | |||
| cell(sheet, r, 0).setCellValue(titleText) | |||
| mergeAndStyle(sheet, r, preparedFirstCol, lastCol, st.titlePreparedBy, BorderStyle.MEDIUM) | |||
| cell(sheet, r, preparedFirstCol).setCellValue(preparedByText) | |||
| } | |||
| sheet.getRow(r)?.heightInPoints = 26f | |||
| } | |||
| fun writeCompanyBlock( | |||
| sheet: Sheet, | |||
| st: Styles, | |||
| blockIndex: Int, | |||
| startRow: Int, | |||
| meta: BlockMeta, | |||
| groups: List<TimeGroup>, | |||
| totalShopCount: Int, | |||
| ): Int { | |||
| val baseCol = blockIndex * BLOCK_WIDTH | |||
| applyColumnWidths(sheet, blockIndex) | |||
| var r = startRow | |||
| mergeAndStyle(sheet, r, baseCol, baseCol + 1, st.company, BorderStyle.MEDIUM) | |||
| cell(sheet, r, baseCol).setCellValue(meta.companyName) | |||
| r++ | |||
| mergeAndStyle(sheet, r, baseCol, baseCol + 1, st.plate, BorderStyle.THIN) | |||
| cell(sheet, r, baseCol).setCellValue(meta.plate) | |||
| r++ | |||
| for (tg in groups) { | |||
| mergeAndStyle(sheet, r, baseCol, baseCol + 1, st.timeHeader, BorderStyle.MEDIUM) | |||
| cell(sheet, r, baseCol).setCellValue(tg.timeLabel) | |||
| r++ | |||
| for (lg in tg.lanes) { | |||
| cell(sheet, r, baseCol).apply { cellStyle = st.laneLeft; setCellValue(lg.laneLabel) } | |||
| cell(sheet, r, baseCol + 1).cellStyle = st.laneFill | |||
| r++ | |||
| for (dg in lg.districts) { | |||
| mergeAndStyle(sheet, r, baseCol, baseCol + 1, st.district, BorderStyle.THIN) | |||
| cell(sheet, r, baseCol).setCellValue(dg.district) | |||
| r++ | |||
| var idx = 1 | |||
| for (s in dg.shops) { | |||
| val noStyle = if (s.changed) st.shopNoChanged else st.shopNo | |||
| val txtStyle = if (s.changed) st.shopTextChanged else st.shopText | |||
| cell(sheet, r, baseCol).apply { cellStyle = noStyle; setCellValue("$idx.") } | |||
| cell(sheet, r, baseCol + 1).apply { cellStyle = txtStyle; setCellValue(s.text) } | |||
| val lines = (s.text.count { it == '\n' } + 1).coerceAtLeast(1) | |||
| sheet.getRow(r)?.heightInPoints = (16f * lines).coerceIn(18f, 90f) | |||
| r++ | |||
| idx++ | |||
| } | |||
| } | |||
| } | |||
| } | |||
| mergeAndStyle(sheet, r, baseCol, baseCol + 1, st.total, BorderStyle.MEDIUM) | |||
| cell(sheet, r, baseCol).setCellValue("分店數目:$totalShopCount") | |||
| r++ | |||
| cell(sheet, r, baseCol).apply { cellStyle = st.driverLabel; setCellValue("車長") } | |||
| cell(sheet, r, baseCol + 1).apply { cellStyle = st.driverValue; setCellValue(meta.driverName) } | |||
| r++ | |||
| mergeAndStyle(sheet, r, baseCol, baseCol + 1, st.driverValue, BorderStyle.MEDIUM) | |||
| cell(sheet, r, baseCol).setCellValue(meta.driverNumber) | |||
| r++ | |||
| return r - 1 | |||
| } | |||
| } | |||
| @@ -0,0 +1,306 @@ | |||
| package com.ffii.fpsms.modules.pickOrder.service | |||
| import com.ffii.fpsms.modules.logistic.entity.LogisticRepository | |||
| import com.ffii.fpsms.modules.pickOrder.entity.* | |||
| import com.ffii.fpsms.modules.pickOrder.web.models.LogisticMasterDiffLine | |||
| import com.ffii.fpsms.modules.pickOrder.web.models.* | |||
| import jakarta.transaction.Transactional | |||
| import org.springframework.http.HttpStatus | |||
| import org.springframework.stereotype.Service | |||
| import org.springframework.web.server.ResponseStatusException | |||
| import java.time.LocalTime | |||
| @Service | |||
| open class TruckLaneVersionService( | |||
| private val truckRepository: TruckRepository, | |||
| private val truckLaneVersionRepository: TruckLaneVersionRepository, | |||
| private val truckLaneVersionLineRepository: TruckLaneVersionLineRepository, | |||
| private val logisticRepository: LogisticRepository, | |||
| ) { | |||
| private fun toResponse(v: TruckLaneVersion): TruckLaneVersionResponse = | |||
| TruckLaneVersionResponse( | |||
| id = v.id ?: 0, | |||
| truckLanceCode = v.truckLanceCode ?: "", | |||
| note = v.note, | |||
| created = v.created?.toString(), | |||
| modifiedBy = v.modifiedBy, | |||
| ) | |||
| /** | |||
| * 全看板 snapshot:`TruckLaneVersion.truckLanceCode` 為空(建立 snapshot 時未指定單線)。 | |||
| * 另:若 line 上出現多種 `truckLanceCode`,視為全看板誤標成單線的舊資料,仍應對「整個 findAllForRouteBoard」做 extras 軟刪。 | |||
| */ | |||
| private fun isFullBoardSnapshot( | |||
| version: TruckLaneVersion, | |||
| lines: List<TruckLaneVersionLine>, | |||
| ): Boolean { | |||
| if (version.truckLanceCode.isNullOrBlank()) return true | |||
| val distinctLaneCodes = | |||
| lines.mapNotNull { it.truckLanceCode?.trim()?.takeIf { c -> c.isNotEmpty() } }.distinct() | |||
| return distinctLaneCodes.size > 1 | |||
| } | |||
| @Transactional | |||
| open fun createSnapshot(request: CreateTruckLaneSnapshotRequest): TruckLaneVersionResponse { | |||
| val lane = request.truckLanceCode?.trim()?.takeIf { it.isNotEmpty() } | |||
| val version = TruckLaneVersion().apply { | |||
| this.truckLanceCode = lane | |||
| this.note = request.note?.trim() | |||
| } | |||
| val savedVersion = truckLaneVersionRepository.save(version) | |||
| val rows = | |||
| if (lane != null) { | |||
| truckRepository.findAllByTruckLanceCodeAndDeletedFalse(lane) | |||
| } else { | |||
| truckRepository.findAllForRouteBoard() | |||
| } | |||
| val lines = rows.map { t -> | |||
| TruckLaneVersionLine().apply { | |||
| this.truckLaneVersion = savedVersion | |||
| this.truckRowId = t.id | |||
| this.truckLanceCode = t.truckLanceCode | |||
| this.shopCode = t.shopCode | |||
| this.branchName = t.shopName | |||
| this.districtReference = t.districtReference | |||
| this.loadingSequence = t.loadingSequence | |||
| this.departureTime = t.departureTime?.toString() | |||
| this.storeId = t.storeId?.trim()?.takeIf { it.isNotEmpty() } ?: "-" | |||
| this.remark = t.remark | |||
| this.logisticId = t.logistic?.id | |||
| } | |||
| } | |||
| if (lines.isNotEmpty()) { | |||
| truckLaneVersionLineRepository.saveAll(lines) | |||
| } | |||
| return toResponse(savedVersion) | |||
| } | |||
| open fun listVersionsByLane(truckLanceCode: String): List<TruckLaneVersionResponse> { | |||
| val lane = truckLanceCode.trim() | |||
| return truckLaneVersionRepository | |||
| .findAllByTruckLanceCodeAndDeletedFalseOrderByCreatedDesc(lane) | |||
| .map(::toResponse) | |||
| } | |||
| open fun listAllVersions(): List<TruckLaneVersionResponse> { | |||
| return truckLaneVersionRepository | |||
| .findAllByDeletedFalseOrderByCreatedDesc() | |||
| .map(::toResponse) | |||
| } | |||
| open fun getVersionLines(versionId: Long): List<TruckLaneVersionLineResponse> { | |||
| return truckLaneVersionLineRepository | |||
| .findAllByTruckLaneVersion_IdAndDeletedFalseOrderByIdAsc(versionId) | |||
| .map { | |||
| TruckLaneVersionLineResponse( | |||
| truckRowId = it.truckRowId ?: 0, | |||
| truckLanceCode = it.truckLanceCode, | |||
| shopCode = it.shopCode, | |||
| branchName = it.branchName, | |||
| districtReference = it.districtReference, | |||
| loadingSequence = it.loadingSequence, | |||
| departureTime = it.departureTime, | |||
| storeId = it.storeId ?: "", | |||
| remark = it.remark, | |||
| logisticId = it.logisticId, | |||
| ) | |||
| } | |||
| } | |||
| open fun diff(fromVersionId: Long, toVersionId: Long): TruckLaneVersionDiffResponse { | |||
| val fromLines = truckLaneVersionLineRepository | |||
| .findAllByTruckLaneVersion_IdAndDeletedFalseOrderByIdAsc(fromVersionId) | |||
| val toLines = truckLaneVersionLineRepository | |||
| .findAllByTruckLaneVersion_IdAndDeletedFalseOrderByIdAsc(toVersionId) | |||
| val fromByRow = fromLines.associateBy { it.truckRowId ?: -1 } | |||
| val toByRow = toLines.associateBy { it.truckRowId ?: -1 } | |||
| val allKeys = (fromByRow.keys + toByRow.keys).filter { it > 0 }.sorted() | |||
| val changed = mutableListOf<TruckLaneVersionDiffLine>() | |||
| fun s(v: Any?): String? = v?.toString() | |||
| allKeys.forEach { key -> | |||
| val a = fromByRow[key] | |||
| val b = toByRow[key] | |||
| val changes = mutableListOf<DiffFieldChange>() | |||
| if (s(a?.truckLanceCode) != s(b?.truckLanceCode)) changes.add(DiffFieldChange("truckLanceCode", s(a?.truckLanceCode), s(b?.truckLanceCode))) | |||
| if (s(a?.shopCode) != s(b?.shopCode)) changes.add(DiffFieldChange("shopCode", s(a?.shopCode), s(b?.shopCode))) | |||
| if (s(a?.branchName) != s(b?.branchName)) changes.add(DiffFieldChange("branchName", s(a?.branchName), s(b?.branchName))) | |||
| if (s(a?.districtReference) != s(b?.districtReference)) changes.add(DiffFieldChange("districtReference", s(a?.districtReference), s(b?.districtReference))) | |||
| if (s(a?.loadingSequence) != s(b?.loadingSequence)) changes.add(DiffFieldChange("loadingSequence", s(a?.loadingSequence), s(b?.loadingSequence))) | |||
| if (s(a?.departureTime) != s(b?.departureTime)) changes.add(DiffFieldChange("departureTime", s(a?.departureTime), s(b?.departureTime))) | |||
| if (s(a?.storeId) != s(b?.storeId)) changes.add(DiffFieldChange("storeId", s(a?.storeId), s(b?.storeId))) | |||
| if (s(a?.remark) != s(b?.remark)) changes.add(DiffFieldChange("remark", s(a?.remark), s(b?.remark))) | |||
| if (s(a?.logisticId) != s(b?.logisticId)) changes.add(DiffFieldChange("logisticId", s(a?.logisticId), s(b?.logisticId))) | |||
| if (changes.isNotEmpty()) { | |||
| changed.add( | |||
| TruckLaneVersionDiffLine( | |||
| truckRowId = key, | |||
| shopCode = b?.shopCode ?: a?.shopCode, | |||
| changes = changes, | |||
| ) | |||
| ) | |||
| } | |||
| } | |||
| val fromV = truckLaneVersionRepository.findByIdAndDeletedFalse(fromVersionId) | |||
| val toV = truckLaneVersionRepository.findByIdAndDeletedFalse(toVersionId) | |||
| val logisticMasterChanges = | |||
| if (fromV != null && toV != null) { | |||
| diffLogisticMastersBetweenVersions(fromV, toV) | |||
| } else { | |||
| emptyList() | |||
| } | |||
| return TruckLaneVersionDiffResponse( | |||
| fromVersionId = fromVersionId, | |||
| toVersionId = toVersionId, | |||
| changed = changed, | |||
| logisticMasterChanges = logisticMasterChanges, | |||
| ) | |||
| } | |||
| /** | |||
| * 物流主檔在兩個版本快照時間之間的新增/修改(含尚未指派到任何 truck 列者)。 | |||
| */ | |||
| private fun diffLogisticMastersBetweenVersions( | |||
| fromVersion: TruckLaneVersion, | |||
| toVersion: TruckLaneVersion, | |||
| ): List<LogisticMasterDiffLine> { | |||
| val fromAt = fromVersion.created ?: return emptyList() | |||
| val toAt = toVersion.created ?: return emptyList() | |||
| if (!toAt.isAfter(fromAt)) return emptyList() | |||
| fun inOpenInterval(ts: java.time.LocalDateTime?): Boolean { | |||
| if (ts == null) return false | |||
| return ts.isAfter(fromAt) && !ts.isAfter(toAt) | |||
| } | |||
| val out = ArrayList<LogisticMasterDiffLine>() | |||
| for (l in logisticRepository.findAllByDeletedFalseOrderByIdAsc()) { | |||
| val id = l.id ?: continue | |||
| val name = l.logisticName?.trim().orEmpty().ifEmpty { "—" } | |||
| val plate = l.carPlate?.trim().orEmpty().ifEmpty { "—" } | |||
| val created = l.created | |||
| val modified = l.modified | |||
| if (inOpenInterval(created)) { | |||
| out.add( | |||
| LogisticMasterDiffLine( | |||
| logisticId = id, | |||
| type = "ADDED", | |||
| logisticName = name, | |||
| carPlate = plate, | |||
| changeText = "新增物流公司:$name($plate)", | |||
| ), | |||
| ) | |||
| continue | |||
| } | |||
| if (created != null && !created.isAfter(fromAt) && inOpenInterval(modified)) { | |||
| out.add( | |||
| LogisticMasterDiffLine( | |||
| logisticId = id, | |||
| type = "EDITED", | |||
| logisticName = name, | |||
| carPlate = plate, | |||
| changeText = "修改物流公司:$name($plate)", | |||
| ), | |||
| ) | |||
| } | |||
| } | |||
| return out | |||
| } | |||
| @Transactional | |||
| open fun updateNote(versionId: Long, note: String?): TruckLaneVersionResponse { | |||
| val v = truckLaneVersionRepository.findByIdAndDeletedFalse(versionId) | |||
| ?: throw ResponseStatusException(HttpStatus.NOT_FOUND, "Version not found: $versionId") | |||
| val trimmed = note?.trim()?.takeIf { it.isNotEmpty() } | |||
| v.note = trimmed | |||
| return toResponse(truckLaneVersionRepository.save(v)) | |||
| } | |||
| @Transactional | |||
| open fun restore(versionId: Long): String { | |||
| val version = truckLaneVersionRepository.findByIdAndDeletedFalse(versionId) | |||
| ?: throw ResponseStatusException(HttpStatus.NOT_FOUND, "Version not found: $versionId") | |||
| val lines = truckLaneVersionLineRepository.findAllByTruckLaneVersion_IdAndDeletedFalseOrderByIdAsc(versionId) | |||
| if (lines.isEmpty()) return "No lines to restore for versionId=$versionId" | |||
| val snapshottedIds = lines.mapNotNull { it.truckRowId }.filter { it > 0 }.toSet() | |||
| if (snapshottedIds.isEmpty()) { | |||
| return "No valid truckRowIds in snapshot for versionId=$versionId" | |||
| } | |||
| val fullBoard = isFullBoardSnapshot(version, lines) | |||
| if (fullBoard) { | |||
| val currentAll = truckRepository.findAllForRouteBoard() | |||
| val extras = currentAll.filter { t -> t.id != null && t.id !in snapshottedIds } | |||
| extras.forEach { it.deleted = true } | |||
| if (extras.isNotEmpty()) { | |||
| truckRepository.saveAll(extras) | |||
| } | |||
| } else { | |||
| val lane = version.truckLanceCode!!.trim() | |||
| if (lane.isNotEmpty()) { | |||
| val currentLane = truckRepository.findAllByTruckLanceCodeAndDeletedFalse(lane) | |||
| val extras = currentLane.filter { t -> t.id != null && t.id !in snapshottedIds } | |||
| extras.forEach { it.deleted = true } | |||
| if (extras.isNotEmpty()) { | |||
| truckRepository.saveAll(extras) | |||
| } | |||
| } | |||
| } | |||
| val trucksById = truckRepository.findAllById(snapshottedIds.toList()).associateBy { it.id } | |||
| val updated = lines.mapNotNull { line -> | |||
| val truckId = line.truckRowId ?: return@mapNotNull null | |||
| if (truckId <= 0) return@mapNotNull null | |||
| val truck = trucksById[truckId] ?: return@mapNotNull null | |||
| truck.deleted = false | |||
| truck.apply { | |||
| // Restore only the fields we snapshot. | |||
| this.truckLanceCode = line.truckLanceCode ?: version.truckLanceCode | |||
| this.loadingSequence = line.loadingSequence | |||
| this.districtReference = line.districtReference | |||
| val sid = line.storeId?.trim()?.takeUnless { it.isEmpty() || it == "-" } | |||
| if (sid != null) this.storeId = sid | |||
| this.shopCode = line.shopCode | |||
| this.shopName = line.branchName | |||
| this.remark = line.remark | |||
| this.departureTime = | |||
| line.departureTime?.trim()?.takeIf { it.isNotEmpty() }?.let { LocalTime.parse(it) } | |||
| val lid = line.logisticId | |||
| this.logistic = | |||
| if (lid != null && lid > 0) { | |||
| logisticRepository.findByIdAndDeletedFalse(lid) | |||
| } else { | |||
| null | |||
| } | |||
| } | |||
| } | |||
| if (updated.isNotEmpty()) { | |||
| truckRepository.saveAll(updated) | |||
| } | |||
| createSnapshot( | |||
| CreateTruckLaneSnapshotRequest( | |||
| truckLanceCode = null, | |||
| note = "restore from versionId=$versionId", | |||
| ) | |||
| ) | |||
| return "Restored versionId=$versionId" | |||
| } | |||
| } | |||
| @@ -7,7 +7,11 @@ import org.springframework.web.bind.ServletRequestBindingException | |||
| import jakarta.servlet.http.HttpServletRequest | |||
| import org.apache.poi.ss.usermodel.Workbook | |||
| import org.apache.poi.xssf.usermodel.XSSFWorkbook | |||
| import org.springframework.http.ContentDisposition | |||
| import org.springframework.http.HttpHeaders | |||
| import org.springframework.http.MediaType | |||
| import org.springframework.http.ResponseEntity | |||
| import java.nio.charset.StandardCharsets | |||
| import org.springframework.web.bind.annotation.* | |||
| import org.springframework.web.multipart.MultipartHttpServletRequest | |||
| @@ -17,12 +21,19 @@ import com.ffii.fpsms.modules.pickOrder.web.models.UpdateTruckShopDetailsRequest | |||
| import com.ffii.fpsms.modules.pickOrder.service.TruckService | |||
| import com.ffii.fpsms.modules.pickOrder.entity.TruckRepository | |||
| import com.ffii.fpsms.modules.pickOrder.web.models.SaveTruckLane | |||
| import com.ffii.fpsms.modules.pickOrder.web.models.TruckLaneCombinationResponse | |||
| import com.ffii.fpsms.modules.pickOrder.web.models.ExportRouteLanesRequest | |||
| import com.ffii.fpsms.modules.pickOrder.web.models.ExportRouteReportRequest | |||
| import com.ffii.fpsms.modules.pickOrder.web.models.ExportTruckLaneVersionReportExcelRequest | |||
| import com.ffii.fpsms.modules.pickOrder.web.models.UpdateLaneLogisticRequest | |||
| import com.ffii.fpsms.modules.pickOrder.web.models.ParseRouteLanesExcelResponse | |||
| import com.ffii.fpsms.modules.pickOrder.web.models.deleteTruckLane | |||
| import com.ffii.fpsms.modules.pickOrder.web.models.toLaneCombinationResponse | |||
| import jakarta.validation.Valid | |||
| @RestController | |||
| @RequestMapping("/truck") | |||
| class TruckController( | |||
| open class TruckController( | |||
| private val truckService: TruckService, | |||
| private val truckRepository: TruckRepository, | |||
| ) { | |||
| @@ -80,6 +91,142 @@ class TruckController( | |||
| } | |||
| } | |||
| /** | |||
| * PDF 圖1:多車線匯出;每個 laneId(encodeLaneId)一個 worksheet,格式 MTMS_ROUTE_V1。 | |||
| */ | |||
| @PostMapping( | |||
| "/exportRouteLanesExcel", | |||
| consumes = [MediaType.APPLICATION_JSON_VALUE], | |||
| produces = ["application/vnd.openxmlformats-officedocument.spreadsheetml.sheet"], | |||
| ) | |||
| fun exportRouteLanesExcel(@RequestBody req: ExportRouteLanesRequest): ResponseEntity<ByteArray> { | |||
| val bytes = truckService.exportRouteLanesExcelBytes(req.laneIds) | |||
| val filename = "MTMS_車線_${System.currentTimeMillis()}.xlsx" | |||
| val disposition = ContentDisposition.attachment() | |||
| .filename(filename, StandardCharsets.UTF_8) | |||
| .build() | |||
| return ResponseEntity.ok() | |||
| .header(HttpHeaders.CONTENT_DISPOSITION, disposition.toString()) | |||
| .contentType(MediaType.parseMediaType("application/vnd.openxmlformats-officedocument.spreadsheetml.sheet")) | |||
| .body(bytes) | |||
| } | |||
| /** | |||
| * 圖2:車線 Report(單一 sheet;每間物流公司一個水平區塊) | |||
| */ | |||
| @PostMapping( | |||
| "/exportRouteReportExcel", | |||
| consumes = [MediaType.APPLICATION_JSON_VALUE], | |||
| produces = ["application/vnd.openxmlformats-officedocument.spreadsheetml.sheet"], | |||
| ) | |||
| fun exportRouteReportExcel( | |||
| request: HttpServletRequest, | |||
| @RequestBody req: ExportRouteReportRequest, | |||
| ): ResponseEntity<ByteArray> { | |||
| val preparedBy = request.userPrincipal?.name?.trim().takeUnless { it.isNullOrBlank() } ?: "current user" | |||
| val bytes = truckService.exportRouteReportExcelBytes(req.laneIds, preparedBy) | |||
| val filename = truckService.buildRouteReportFilename() | |||
| val disposition = ContentDisposition.attachment() | |||
| .filename(filename, StandardCharsets.UTF_8) | |||
| .build() | |||
| return ResponseEntity.ok() | |||
| .header(HttpHeaders.CONTENT_DISPOSITION, disposition.toString()) | |||
| .contentType(MediaType.parseMediaType("application/vnd.openxmlformats-officedocument.spreadsheetml.sheet")) | |||
| .body(bytes) | |||
| } | |||
| @PostMapping( | |||
| "/exportTruckLaneVersionReportExcel", | |||
| consumes = [MediaType.APPLICATION_JSON_VALUE], | |||
| produces = ["application/vnd.openxmlformats-officedocument.spreadsheetml.sheet"], | |||
| ) | |||
| open fun exportTruckLaneVersionReportExcel( | |||
| request: HttpServletRequest, | |||
| @RequestBody req: ExportTruckLaneVersionReportExcelRequest, | |||
| ): ResponseEntity<ByteArray> { | |||
| val preparedBy = request.userPrincipal?.name?.trim().takeUnless { it.isNullOrBlank() } ?: "current user" | |||
| val bytes = truckService.exportTruckLaneVersionReportExcelBytes( | |||
| TruckService.ExportTruckLaneVersionReportInput( | |||
| fromVersionId = req.fromVersionId, | |||
| toVersionId = req.toVersionId, | |||
| preparedBy = preparedBy, | |||
| ), | |||
| ) | |||
| val filename = "車線版本報告_${System.currentTimeMillis()}.xlsx" | |||
| val disposition = ContentDisposition.attachment() | |||
| .filename(filename, StandardCharsets.UTF_8) | |||
| .build() | |||
| return ResponseEntity.ok() | |||
| .header(HttpHeaders.CONTENT_DISPOSITION, disposition.toString()) | |||
| .contentType(MediaType.parseMediaType("application/vnd.openxmlformats-officedocument.spreadsheetml.sheet")) | |||
| .body(bytes) | |||
| } | |||
| /** 與 [importRouteLanesExcel] 同一格式;僅解析、不寫入 DB(看板 staged import 預覽)。 */ | |||
| @PostMapping("/parseRouteLanesExcel") | |||
| @Throws(ServletRequestBindingException::class) | |||
| fun parseRouteLanesExcel(request: HttpServletRequest): ResponseEntity<ParseRouteLanesExcelResponse> { | |||
| var workbook: Workbook? = null | |||
| try { | |||
| val multipartFile = (request as MultipartHttpServletRequest).getFile("multipartFileList") | |||
| workbook = XSSFWorkbook(multipartFile?.inputStream) | |||
| return ResponseEntity.ok(truckService.parseRouteLanesExcel(workbook)) | |||
| } catch (e: Exception) { | |||
| println("Error reading Excel file: ${e.message}") | |||
| return ResponseEntity.badRequest().body( | |||
| ParseRouteLanesExcelResponse(0, 0, emptyList()), | |||
| ) | |||
| } finally { | |||
| try { | |||
| workbook?.close() | |||
| } catch (_: Exception) { | |||
| } | |||
| } | |||
| } | |||
| /** 與 [exportRouteLanesExcel] 同一格式;一個檔案內多 sheet,每 sheet 一條車線。 */ | |||
| @PostMapping("/importRouteLanesExcel") | |||
| @Throws(ServletRequestBindingException::class) | |||
| fun importRouteLanesExcel(request: HttpServletRequest): ResponseEntity<*> { | |||
| var workbook: Workbook? = null | |||
| try { | |||
| val multipartFile = (request as MultipartHttpServletRequest).getFile("multipartFileList") | |||
| workbook = XSSFWorkbook(multipartFile?.inputStream) | |||
| } catch (e: Exception) { | |||
| println("Error reading Excel file: ${e.message}") | |||
| return ResponseEntity.badRequest().body( | |||
| MessageResponse( | |||
| id = null, | |||
| name = null, | |||
| code = null, | |||
| type = "truck", | |||
| message = "Error reading Excel file: ${e.message}", | |||
| errorPosition = null, | |||
| entity = null, | |||
| ), | |||
| ) | |||
| } | |||
| try { | |||
| val result = truckService.importRouteLanesExcel(workbook) | |||
| return ResponseEntity.ok( | |||
| MessageResponse( | |||
| id = null, | |||
| name = null, | |||
| code = null, | |||
| type = "truck", | |||
| message = result, | |||
| errorPosition = null, | |||
| entity = null, | |||
| ), | |||
| ) | |||
| } finally { | |||
| try { | |||
| workbook?.close() | |||
| } catch (_: Exception) { | |||
| } | |||
| } | |||
| } | |||
| @PostMapping("/importExcel") | |||
| @Throws(ServletRequestBindingException::class) | |||
| fun importExcel(request: HttpServletRequest): ResponseEntity<*> { | |||
| @@ -103,18 +250,25 @@ class TruckController( | |||
| ) | |||
| } | |||
| val result = truckService.importExcel(workbook) | |||
| return ResponseEntity.ok( | |||
| MessageResponse( | |||
| id = null, | |||
| name = null, | |||
| code = null, | |||
| type = "truck", | |||
| message = result, | |||
| errorPosition = null, | |||
| entity = null | |||
| try { | |||
| val result = truckService.importExcel(workbook) | |||
| return ResponseEntity.ok( | |||
| MessageResponse( | |||
| id = null, | |||
| name = null, | |||
| code = null, | |||
| type = "truck", | |||
| message = result, | |||
| errorPosition = null, | |||
| entity = null | |||
| ) | |||
| ) | |||
| ) | |||
| } finally { | |||
| try { | |||
| workbook?.close() | |||
| } catch (_: Exception) { | |||
| } | |||
| } | |||
| } | |||
| @GetMapping("/findTruckLane/{shopId}") | |||
| @@ -136,7 +290,7 @@ class TruckController( | |||
| type = "truck", | |||
| message = if (truck != null) "Truck lane updated successfully" else "Truck lane not found", | |||
| errorPosition = null, | |||
| entity = truck | |||
| entity = null | |||
| ) | |||
| } catch (e: Exception) { | |||
| return MessageResponse( | |||
| @@ -151,6 +305,32 @@ class TruckController( | |||
| } | |||
| } | |||
| @PostMapping("/updateLaneLogistic") | |||
| fun updateLaneLogistic(@Valid @RequestBody request: UpdateLaneLogisticRequest): MessageResponse { | |||
| try { | |||
| val n = truckService.updateLogisticForEntireLane(request) | |||
| return MessageResponse( | |||
| id = null, | |||
| name = null, | |||
| code = request.truckLanceCode, | |||
| type = "truck", | |||
| message = "Updated logistic for $n truck row(s)", | |||
| errorPosition = null, | |||
| entity = null, | |||
| ) | |||
| } catch (e: Exception) { | |||
| return MessageResponse( | |||
| id = null, | |||
| name = null, | |||
| code = null, | |||
| type = "truck", | |||
| message = "Error: ${e.message}", | |||
| errorPosition = null, | |||
| entity = null, | |||
| ) | |||
| } | |||
| } | |||
| @PostMapping("/deleteTruckLane") | |||
| fun deleteTruckLane(@Valid @RequestBody request: deleteTruckLane): MessageResponse { | |||
| try { | |||
| @@ -178,8 +358,10 @@ class TruckController( | |||
| } | |||
| @GetMapping("/findAllUniqueTruckLanceCodeAndRemarkCombinations") | |||
| fun findAllUniqueTruckLanceCodeAndRemarkCombinations(): List<Truck> { | |||
| return truckService.findAllUniqueTruckLanceCodeAndRemarkCombinations() | |||
| fun findAllUniqueTruckLanceCodeAndRemarkCombinations(): List<TruckLaneCombinationResponse> { | |||
| return truckService | |||
| .findAllUniqueTruckLanceCodeAndRemarkCombinations() | |||
| .map { it.toLaneCombinationResponse() } | |||
| } | |||
| @@ -193,6 +375,27 @@ class TruckController( | |||
| return truckService.findAllByTruckLanceCodeAndDeletedFalse(truckLanceCode) | |||
| } | |||
| /** | |||
| * Filter trucks by the same (truckLanceCode, remark) group as the unique-combinations query. | |||
| * Omit `remark` or pass empty for rows with NULL/empty remark. | |||
| */ | |||
| @GetMapping("/findAllByTruckLanceCodeAndRemarkAndDeletedFalse") | |||
| fun findAllByTruckLanceCodeAndRemarkAndDeletedFalse( | |||
| @RequestParam truckLanceCode: String, | |||
| @RequestParam(required = false) remark: String?, | |||
| ): List<Truck> { | |||
| return truckService.findAllByTruckLanceCodeAndRemarkAndDeletedFalse(truckLanceCode, remark) | |||
| } | |||
| /** | |||
| * RouteBoard O(1) load: return all truck rows (deleted=false) once. | |||
| * Frontend groups by (truckLanceCode, normalizedRemark). | |||
| */ | |||
| @GetMapping("/findAllForRouteBoard") | |||
| fun findAllForRouteBoard(): List<Truck> { | |||
| return truckService.findAllForRouteBoard() | |||
| } | |||
| @GetMapping("/findAllUniqueShopNamesAndCodesFromTrucks") | |||
| fun findAllUniqueShopNamesAndCodesFromTrucks(): List<Map<String, String>> { | |||
| return truckService.findAllUniqueShopNamesAndCodesFromTrucks() | |||
| @@ -0,0 +1,73 @@ | |||
| package com.ffii.fpsms.modules.pickOrder.web | |||
| import com.ffii.fpsms.modules.master.web.models.MessageResponse | |||
| import com.ffii.fpsms.modules.pickOrder.service.TruckLaneVersionService | |||
| import com.ffii.fpsms.modules.pickOrder.web.models.CreateTruckLaneSnapshotRequest | |||
| import com.ffii.fpsms.modules.pickOrder.web.models.TruckLaneVersionDiffResponse | |||
| import com.ffii.fpsms.modules.pickOrder.web.models.TruckLaneVersionLineResponse | |||
| import com.ffii.fpsms.modules.pickOrder.web.models.TruckLaneVersionResponse | |||
| import com.ffii.fpsms.modules.pickOrder.web.models.UpdateTruckLaneVersionNoteRequest | |||
| import jakarta.validation.Valid | |||
| import org.springframework.http.ResponseEntity | |||
| import org.springframework.web.bind.annotation.* | |||
| @RestController | |||
| @RequestMapping("/truckLaneVersion") | |||
| class TruckLaneVersionController( | |||
| private val truckLaneVersionService: TruckLaneVersionService, | |||
| ) { | |||
| @PostMapping("/snapshot") | |||
| fun createSnapshot(@Valid @RequestBody request: CreateTruckLaneSnapshotRequest): TruckLaneVersionResponse { | |||
| return truckLaneVersionService.createSnapshot(request) | |||
| } | |||
| @GetMapping | |||
| fun listVersions( | |||
| @RequestParam(required = false) truckLanceCode: String?, | |||
| ): List<TruckLaneVersionResponse> { | |||
| val lane = truckLanceCode?.trim()?.takeIf { it.isNotEmpty() } | |||
| return if (lane != null) { | |||
| truckLaneVersionService.listVersionsByLane(lane) | |||
| } else { | |||
| truckLaneVersionService.listAllVersions() | |||
| } | |||
| } | |||
| @GetMapping("/{versionId}/lines") | |||
| fun getLines(@PathVariable versionId: Long): List<TruckLaneVersionLineResponse> { | |||
| return truckLaneVersionService.getVersionLines(versionId) | |||
| } | |||
| @PatchMapping("/{versionId}/note") | |||
| fun updateNote( | |||
| @PathVariable versionId: Long, | |||
| @Valid @RequestBody request: UpdateTruckLaneVersionNoteRequest, | |||
| ): TruckLaneVersionResponse { | |||
| return truckLaneVersionService.updateNote(versionId, request.note) | |||
| } | |||
| @GetMapping("/diff") | |||
| fun diff( | |||
| @RequestParam fromVersionId: Long, | |||
| @RequestParam toVersionId: Long, | |||
| ): TruckLaneVersionDiffResponse { | |||
| return truckLaneVersionService.diff(fromVersionId, toVersionId) | |||
| } | |||
| @PostMapping("/{versionId}/restore") | |||
| fun restore(@PathVariable versionId: Long): ResponseEntity<MessageResponse> { | |||
| val msg = truckLaneVersionService.restore(versionId) | |||
| return ResponseEntity.ok( | |||
| MessageResponse( | |||
| id = null, | |||
| name = null, | |||
| code = null, | |||
| type = "OK", | |||
| message = msg, | |||
| errorPosition = null, | |||
| entity = null, | |||
| ) | |||
| ) | |||
| } | |||
| } | |||
| @@ -0,0 +1,5 @@ | |||
| package com.ffii.fpsms.modules.pickOrder.web.models | |||
| data class ExportRouteLanesRequest( | |||
| val laneIds: List<String>, | |||
| ) | |||
| @@ -0,0 +1,11 @@ | |||
| package com.ffii.fpsms.modules.pickOrder.web.models | |||
| /** | |||
| * 匯出「車線 Report」(圖2):單一 workbook/單 sheet。 | |||
| * laneIds 與前端 encodeLaneId 一致:encodeURIComponent(code)|encodeURIComponent(remark)。 | |||
| * 若 laneIds 為空,視為匯出 RouteBoard 全部車線。 | |||
| */ | |||
| data class ExportRouteReportRequest( | |||
| val laneIds: List<String> = emptyList(), | |||
| ) | |||
| @@ -0,0 +1,7 @@ | |||
| package com.ffii.fpsms.modules.pickOrder.web.models | |||
| data class ExportTruckLaneVersionReportExcelRequest( | |||
| val fromVersionId: Long, | |||
| val toVersionId: Long, | |||
| ) | |||
| @@ -0,0 +1,22 @@ | |||
| package com.ffii.fpsms.modules.pickOrder.web.models | |||
| /** Preview row for staged route Excel import (no DB write). */ | |||
| data class RouteLaneImportPreviewRow( | |||
| val truckRowId: Long?, | |||
| val truckLanceCode: String, | |||
| val remark: String?, | |||
| val storeId: String, | |||
| val departureTime: String, | |||
| val shopId: Long, | |||
| val shopName: String, | |||
| val shopCode: String, | |||
| val loadingSequence: Int, | |||
| val districtReference: String?, | |||
| val logisticId: Long?, | |||
| ) | |||
| data class ParseRouteLanesExcelResponse( | |||
| val sheetCount: Int, | |||
| val rowCount: Int, | |||
| val rows: List<RouteLaneImportPreviewRow>, | |||
| ) | |||
| @@ -1,4 +1,5 @@ | |||
| package com.ffii.fpsms.modules.pickOrder.web.models | |||
| import jakarta.validation.constraints.NotBlank | |||
| import java.time.LocalTime | |||
| data class SaveTruckRequest( | |||
| val id: Long? = null, | |||
| @@ -11,6 +12,7 @@ data class SaveTruckRequest( | |||
| val loadingSequence: Int, | |||
| val remark: String? = null, | |||
| val districtReference: String? = null, | |||
| val logisticId: Long? = null, | |||
| ) | |||
| data class SaveTruckLane( | |||
| val id: Long, | |||
| @@ -19,7 +21,10 @@ data class SaveTruckLane( | |||
| val loadingSequence: Long, | |||
| val districtReference: String?, | |||
| val storeId: String, | |||
| val remark: String? = null | |||
| val remark: String? = null, | |||
| val logisticId: Long? = null, | |||
| /** When true, apply [logisticId] (including null to clear); when false, leave truck.logistic unchanged. */ | |||
| val updateLogistic: Boolean = false, | |||
| ) | |||
| data class deleteTruckLane( | |||
| val id: Long | |||
| @@ -39,4 +44,13 @@ data class CreateTruckWithoutShopRequest( | |||
| val loadingSequence: Int = 0, | |||
| val districtReference: String? = null, | |||
| val remark: String? = null, | |||
| val logisticId: Long? = null, | |||
| ) | |||
| /** 單一 transaction 更新同 (truckLanceCode, remark) 桶內所有 truck 的 logistic。 */ | |||
| data class UpdateLaneLogisticRequest( | |||
| @field:NotBlank | |||
| val truckLanceCode: String, | |||
| val remark: String? = null, | |||
| val logisticId: Long? = null, | |||
| ) | |||
| @@ -0,0 +1,34 @@ | |||
| package com.ffii.fpsms.modules.pickOrder.web.models | |||
| import com.ffii.fpsms.modules.pickOrder.entity.Truck | |||
| import java.time.LocalTime | |||
| /** | |||
| * 僅供 `findAllUniqueTruckLanceCodeAndRemarkCombinations` 回傳,避免序列化 JPA | |||
| * 關聯(shop / logistic)產生超大或非法 JSON。 | |||
| */ | |||
| data class TruckLaneCombinationResponse( | |||
| val id: Long, | |||
| val truckLanceCode: String?, | |||
| val departureTime: LocalTime?, | |||
| val loadingSequence: Int?, | |||
| val districtReference: String?, | |||
| val storeId: String?, | |||
| val remark: String?, | |||
| val shopName: String?, | |||
| val shopCode: String?, | |||
| ) | |||
| fun Truck.toLaneCombinationResponse(): TruckLaneCombinationResponse { | |||
| return TruckLaneCombinationResponse( | |||
| id = this.id ?: 0L, | |||
| truckLanceCode = this.truckLanceCode, | |||
| departureTime = this.departureTime, | |||
| loadingSequence = this.loadingSequence, | |||
| districtReference = this.districtReference, | |||
| storeId = this.storeId, | |||
| remark = this.remark, | |||
| shopName = this.shopName, | |||
| shopCode = this.shopCode, | |||
| ) | |||
| } | |||
| @@ -0,0 +1,65 @@ | |||
| package com.ffii.fpsms.modules.pickOrder.web.models | |||
| import jakarta.validation.constraints.Size | |||
| data class CreateTruckLaneSnapshotRequest( | |||
| @field:Size(max = 100) | |||
| val truckLanceCode: String? = null, | |||
| @field:Size(max = 500) | |||
| val note: String? = null, | |||
| ) | |||
| data class RestoreTruckLaneSnapshotRequest( | |||
| val versionId: Long, | |||
| ) | |||
| data class TruckLaneVersionResponse( | |||
| val id: Long, | |||
| val truckLanceCode: String, | |||
| val note: String?, | |||
| val created: String?, | |||
| /** BaseEntity `modifiedBy`:最後寫入此快照的使用者(通常為 JWT name / staffNo) */ | |||
| val modifiedBy: String?, | |||
| ) | |||
| data class TruckLaneVersionLineResponse( | |||
| val truckRowId: Long, | |||
| val truckLanceCode: String?, | |||
| val shopCode: String?, | |||
| val branchName: String?, | |||
| val districtReference: String?, | |||
| val loadingSequence: Int?, | |||
| val departureTime: String?, | |||
| val storeId: String, | |||
| val remark: String?, | |||
| val logisticId: Long?, | |||
| ) | |||
| data class DiffFieldChange( | |||
| val field: String, | |||
| val from: String?, | |||
| val to: String?, | |||
| ) | |||
| data class TruckLaneVersionDiffLine( | |||
| val truckRowId: Long, | |||
| val shopCode: String?, | |||
| val changes: List<DiffFieldChange>, | |||
| ) | |||
| /** 物流主檔異動(版本區間內新增/修改;不依賴 truck 列是否已指派) */ | |||
| data class LogisticMasterDiffLine( | |||
| val logisticId: Long, | |||
| val type: String, | |||
| val logisticName: String, | |||
| val carPlate: String, | |||
| val changeText: String, | |||
| ) | |||
| data class TruckLaneVersionDiffResponse( | |||
| val fromVersionId: Long, | |||
| val toVersionId: Long, | |||
| val changed: List<TruckLaneVersionDiffLine>, | |||
| val logisticMasterChanges: List<LogisticMasterDiffLine> = emptyList(), | |||
| ) | |||
| @@ -0,0 +1,8 @@ | |||
| package com.ffii.fpsms.modules.pickOrder.web.models | |||
| import jakarta.validation.constraints.Size | |||
| data class UpdateTruckLaneVersionNoteRequest( | |||
| @field:Size(max = 500) | |||
| val note: String? = null, | |||
| ) | |||
| @@ -0,0 +1,130 @@ | |||
| -- liquibase formatted sql | |||
| -- changeset 2fi:20260430_03_truck_lane_version_snapshot | |||
| -- preconditions onFail:MARK_RAN | |||
| -- precondition-sql-check expectedResult:0 SELECT COUNT(*) FROM information_schema.tables WHERE table_schema = DATABASE() AND table_name = 'truck_lane_version' | |||
| CREATE TABLE IF NOT EXISTS `truck_lane_version` | |||
| ( | |||
| `id` BIGINT NOT NULL AUTO_INCREMENT, | |||
| `created` DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP, | |||
| `createdBy` VARCHAR(30) NULL DEFAULT NULL, | |||
| `version` INT NOT NULL DEFAULT '0', | |||
| `modified` DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP, | |||
| `modifiedBy` VARCHAR(30) NULL DEFAULT NULL, | |||
| `deleted` TINYINT(1) NOT NULL DEFAULT '0', | |||
| `storeId` VARCHAR(10) NOT NULL, | |||
| `truckLanceCode` VARCHAR(100) NOT NULL, | |||
| `note` VARCHAR(500) NULL DEFAULT NULL, | |||
| CONSTRAINT pk_truck_lane_version PRIMARY KEY (`id`) | |||
| ); | |||
| -- When upgrading an existing database, CREATE TABLE IF NOT EXISTS will not add missing columns. | |||
| -- Old DB snapshots might already have `truck_lane_version` without `storeId`, which would break the index creation below. | |||
| SET @col_tlv_storeId := ( | |||
| SELECT COUNT(*) | |||
| FROM information_schema.columns | |||
| WHERE table_schema = DATABASE() | |||
| AND table_name = 'truck_lane_version' | |||
| AND column_name = 'storeId' | |||
| ); | |||
| SET @sql_add_tlv_storeId := IF( | |||
| @col_tlv_storeId = 0, | |||
| 'ALTER TABLE `truck_lane_version` ADD COLUMN `storeId` VARCHAR(10) NULL DEFAULT NULL AFTER `deleted`', | |||
| 'SELECT 1' | |||
| ); | |||
| PREPARE stmt_add_tlv_storeId FROM @sql_add_tlv_storeId; | |||
| EXECUTE stmt_add_tlv_storeId; | |||
| DEALLOCATE PREPARE stmt_add_tlv_storeId; | |||
| SET @idx_tlv := ( | |||
| SELECT COUNT(*) | |||
| FROM information_schema.statistics | |||
| WHERE table_schema = DATABASE() | |||
| AND table_name = 'truck_lane_version' | |||
| AND index_name = 'idx_tlv_lane' | |||
| ); | |||
| SET @col_tlv_truckLanceCode := ( | |||
| SELECT COUNT(*) | |||
| FROM information_schema.columns | |||
| WHERE table_schema = DATABASE() | |||
| AND table_name = 'truck_lane_version' | |||
| AND column_name = 'truckLanceCode' | |||
| ); | |||
| SET @sql_tlv := IF( | |||
| @idx_tlv = 0 AND @col_tlv_storeId > 0 AND @col_tlv_truckLanceCode > 0, | |||
| 'CREATE INDEX idx_tlv_lane ON `truck_lane_version` (`storeId`, `truckLanceCode`, `created`)', | |||
| 'SELECT 1' | |||
| ); | |||
| PREPARE stmt_tlv FROM @sql_tlv; | |||
| EXECUTE stmt_tlv; | |||
| DEALLOCATE PREPARE stmt_tlv; | |||
| CREATE TABLE IF NOT EXISTS `truck_lane_version_line` | |||
| ( | |||
| `id` BIGINT NOT NULL AUTO_INCREMENT, | |||
| `created` DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP, | |||
| `createdBy` VARCHAR(30) NULL DEFAULT NULL, | |||
| `version` INT NOT NULL DEFAULT '0', | |||
| `modified` DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP, | |||
| `modifiedBy` VARCHAR(30) NULL DEFAULT NULL, | |||
| `deleted` TINYINT(1) NOT NULL DEFAULT '0', | |||
| `truckLaneVersionId` BIGINT NOT NULL, | |||
| `truckRowId` BIGINT NOT NULL, | |||
| `shopCode` VARCHAR(50) NULL DEFAULT NULL, | |||
| `branchName` VARCHAR(255) NULL DEFAULT NULL, | |||
| `districtReference` VARCHAR(255) NULL DEFAULT NULL, | |||
| `loadingSequence` INT NULL DEFAULT NULL, | |||
| `departureTime` VARCHAR(30) NULL DEFAULT NULL, | |||
| `storeId` VARCHAR(10) NOT NULL, | |||
| `remark` VARCHAR(255) NULL DEFAULT NULL, | |||
| CONSTRAINT pk_truck_lane_version_line PRIMARY KEY (`id`), | |||
| CONSTRAINT fk_tlvl_version FOREIGN KEY (`truckLaneVersionId`) REFERENCES `truck_lane_version` (`id`) | |||
| ); | |||
| SET @col_tlvl_storeId := ( | |||
| SELECT COUNT(*) | |||
| FROM information_schema.columns | |||
| WHERE table_schema = DATABASE() | |||
| AND table_name = 'truck_lane_version_line' | |||
| AND column_name = 'storeId' | |||
| ); | |||
| SET @sql_add_tlvl_storeId := IF( | |||
| @col_tlvl_storeId = 0, | |||
| 'ALTER TABLE `truck_lane_version_line` ADD COLUMN `storeId` VARCHAR(10) NULL DEFAULT NULL AFTER `departureTime`', | |||
| 'SELECT 1' | |||
| ); | |||
| PREPARE stmt_add_tlvl_storeId FROM @sql_add_tlvl_storeId; | |||
| EXECUTE stmt_add_tlvl_storeId; | |||
| DEALLOCATE PREPARE stmt_add_tlvl_storeId; | |||
| SET @idx_tlvl_v := ( | |||
| SELECT COUNT(*) | |||
| FROM information_schema.statistics | |||
| WHERE table_schema = DATABASE() | |||
| AND table_name = 'truck_lane_version_line' | |||
| AND index_name = 'idx_tlvl_version' | |||
| ); | |||
| SET @sql_tlvl_v := IF( | |||
| @idx_tlvl_v = 0, | |||
| 'CREATE INDEX idx_tlvl_version ON `truck_lane_version_line` (`truckLaneVersionId`)', | |||
| 'SELECT 1' | |||
| ); | |||
| PREPARE stmt_tlvl_v FROM @sql_tlvl_v; | |||
| EXECUTE stmt_tlvl_v; | |||
| DEALLOCATE PREPARE stmt_tlvl_v; | |||
| SET @idx_tlvl_tr := ( | |||
| SELECT COUNT(*) | |||
| FROM information_schema.statistics | |||
| WHERE table_schema = DATABASE() | |||
| AND table_name = 'truck_lane_version_line' | |||
| AND index_name = 'idx_tlvl_truck_row' | |||
| ); | |||
| SET @sql_tlvl_tr := IF( | |||
| @idx_tlvl_tr = 0, | |||
| 'CREATE INDEX idx_tlvl_truck_row ON `truck_lane_version_line` (`truckRowId`)', | |||
| 'SELECT 1' | |||
| ); | |||
| PREPARE stmt_tlvl_tr FROM @sql_tlvl_tr; | |||
| EXECUTE stmt_tlvl_tr; | |||
| DEALLOCATE PREPARE stmt_tlvl_tr; | |||
| @@ -0,0 +1,46 @@ | |||
| -- liquibase formatted sql | |||
| -- changeset 2fi:20260430_04_truck_lane_version_snapshot_patch | |||
| -- preconditions onFail:MARK_RAN | |||
| -- precondition-sql-check expectedResult:0 SELECT COUNT(*) FROM information_schema.columns WHERE table_schema = DATABASE() AND table_name = 'truck_lane_version_line' AND column_name = 'truckLanceCode' | |||
| ALTER TABLE `truck_lane_version` | |||
| MODIFY COLUMN `truckLanceCode` VARCHAR(100) NULL; | |||
| SET @col_tlvl_storeId := ( | |||
| SELECT COUNT(*) | |||
| FROM information_schema.columns | |||
| WHERE table_schema = DATABASE() | |||
| AND table_name = 'truck_lane_version_line' | |||
| AND column_name = 'storeId' | |||
| ); | |||
| SET @col_tlvl_tlc := ( | |||
| SELECT COUNT(*) | |||
| FROM information_schema.columns | |||
| WHERE table_schema = DATABASE() | |||
| AND table_name = 'truck_lane_version_line' | |||
| AND column_name = 'truckLanceCode' | |||
| ); | |||
| SET @sql_add_tlc := IF( | |||
| @col_tlvl_tlc = 0, | |||
| 'ALTER TABLE `truck_lane_version_line` ADD COLUMN `truckLanceCode` VARCHAR(100) NULL DEFAULT NULL AFTER `truckRowId`', | |||
| 'SELECT 1' | |||
| ); | |||
| PREPARE stmt_add_tlc FROM @sql_add_tlc; | |||
| EXECUTE stmt_add_tlc; | |||
| DEALLOCATE PREPARE stmt_add_tlc; | |||
| SET @idx_tlvl_lane := ( | |||
| SELECT COUNT(*) | |||
| FROM information_schema.statistics | |||
| WHERE table_schema = DATABASE() | |||
| AND table_name = 'truck_lane_version_line' | |||
| AND index_name = 'idx_tlvl_lane' | |||
| ); | |||
| SET @sql_tlvl_lane := IF( | |||
| @idx_tlvl_lane = 0 AND @col_tlvl_storeId > 0 AND @col_tlvl_tlc > 0, | |||
| 'CREATE INDEX idx_tlvl_lane ON `truck_lane_version_line` (`storeId`, `truckLanceCode`)', | |||
| 'SELECT 1' | |||
| ); | |||
| PREPARE stmt_tlvl_lane FROM @sql_tlvl_lane; | |||
| EXECUTE stmt_tlvl_lane; | |||
| DEALLOCATE PREPARE stmt_tlvl_lane; | |||
| @@ -0,0 +1,10 @@ | |||
| -- liquibase formatted sql | |||
| -- changeset 2fi:20260504_01_truck_add_logistic_id | |||
| -- preconditions onFail:MARK_RAN | |||
| -- precondition-sql-check expectedResult:0 SELECT COUNT(*) FROM information_schema.columns WHERE table_schema = DATABASE() AND table_name = 'truck' AND column_name = 'logisticId' | |||
| ALTER TABLE `truck` | |||
| ADD COLUMN `logisticId` INT NULL; | |||
| ALTER TABLE `truck` | |||
| ADD CONSTRAINT `fk_truck_logistic` FOREIGN KEY (`logisticId`) REFERENCES `logistic` (`id`); | |||
| @@ -0,0 +1,52 @@ | |||
| -- liquibase formatted sql | |||
| -- changeset 2fi:20260505_01_truck_lane_version_drop_store_id | |||
| -- preconditions onFail:MARK_RAN | |||
| -- precondition-sql-check expectedResult:1 SELECT COUNT(*) FROM information_schema.columns WHERE table_schema = DATABASE() AND table_name = 'truck_lane_version' AND column_name = 'storeId' | |||
| SET @idx_tlv := ( | |||
| SELECT COUNT(*) | |||
| FROM information_schema.statistics | |||
| WHERE table_schema = DATABASE() | |||
| AND table_name = 'truck_lane_version' | |||
| AND index_name = 'idx_tlv_lane' | |||
| ); | |||
| SET @sql_drop_idx := IF( | |||
| @idx_tlv > 0, | |||
| 'DROP INDEX idx_tlv_lane ON `truck_lane_version`', | |||
| 'SELECT 1' | |||
| ); | |||
| PREPARE stmt_drop_idx FROM @sql_drop_idx; | |||
| EXECUTE stmt_drop_idx; | |||
| DEALLOCATE PREPARE stmt_drop_idx; | |||
| SET @col_tlv_sid := ( | |||
| SELECT COUNT(*) | |||
| FROM information_schema.columns | |||
| WHERE table_schema = DATABASE() | |||
| AND table_name = 'truck_lane_version' | |||
| AND column_name = 'storeId' | |||
| ); | |||
| SET @sql_drop_col := IF( | |||
| @col_tlv_sid > 0, | |||
| 'ALTER TABLE `truck_lane_version` DROP COLUMN `storeId`', | |||
| 'SELECT 1' | |||
| ); | |||
| PREPARE stmt_drop_col FROM @sql_drop_col; | |||
| EXECUTE stmt_drop_col; | |||
| DEALLOCATE PREPARE stmt_drop_col; | |||
| SET @idx_new := ( | |||
| SELECT COUNT(*) | |||
| FROM information_schema.statistics | |||
| WHERE table_schema = DATABASE() | |||
| AND table_name = 'truck_lane_version' | |||
| AND index_name = 'idx_tlv_lane_created' | |||
| ); | |||
| SET @sql_new_idx := IF( | |||
| @idx_new = 0, | |||
| 'CREATE INDEX idx_tlv_lane_created ON `truck_lane_version` (`truckLanceCode`, `created`)', | |||
| 'SELECT 1' | |||
| ); | |||
| PREPARE stmt_new_idx FROM @sql_new_idx; | |||
| EXECUTE stmt_new_idx; | |||
| DEALLOCATE PREPARE stmt_new_idx; | |||
| @@ -0,0 +1,37 @@ | |||
| -- liquibase formatted sql | |||
| -- changeset 2fi:20260507_01_truck_lane_version_line_add_logisticId | |||
| -- preconditions onFail:MARK_RAN | |||
| -- precondition-sql-check expectedResult:0 SELECT COUNT(*) FROM information_schema.columns WHERE table_schema = DATABASE() AND table_name = 'truck_lane_version_line' AND column_name = 'logisticId' | |||
| SET @col_tlvl_lid := ( | |||
| SELECT COUNT(*) | |||
| FROM information_schema.columns | |||
| WHERE table_schema = DATABASE() | |||
| AND table_name = 'truck_lane_version_line' | |||
| AND column_name = 'logisticId' | |||
| ); | |||
| SET @sql_add_tlvl_lid := IF( | |||
| @col_tlvl_lid = 0, | |||
| 'ALTER TABLE `truck_lane_version_line` ADD COLUMN `logisticId` BIGINT NULL DEFAULT NULL AFTER `remark`', | |||
| 'SELECT 1' | |||
| ); | |||
| PREPARE stmt_add_tlvl_lid FROM @sql_add_tlvl_lid; | |||
| EXECUTE stmt_add_tlvl_lid; | |||
| DEALLOCATE PREPARE stmt_add_tlvl_lid; | |||
| SET @idx_tlvl_lid := ( | |||
| SELECT COUNT(*) | |||
| FROM information_schema.statistics | |||
| WHERE table_schema = DATABASE() | |||
| AND table_name = 'truck_lane_version_line' | |||
| AND index_name = 'idx_tlvl_logisticId' | |||
| ); | |||
| SET @sql_tlvl_lid := IF( | |||
| @idx_tlvl_lid = 0, | |||
| 'CREATE INDEX idx_tlvl_logisticId ON `truck_lane_version_line` (`logisticId`)', | |||
| 'SELECT 1' | |||
| ); | |||
| PREPARE stmt_tlvl_lid FROM @sql_tlvl_lid; | |||
| EXECUTE stmt_tlvl_lid; | |||
| DEALLOCATE PREPARE stmt_tlvl_lid; | |||