| @@ -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.master.enums.ShopType | ||||
| import com.ffii.fpsms.modules.pickOrder.entity.Truck | import com.ffii.fpsms.modules.pickOrder.entity.Truck | ||||
| import org.springframework.data.jpa.repository.Query | import org.springframework.data.jpa.repository.Query | ||||
| import org.springframework.data.repository.query.Param | |||||
| import org.springframework.stereotype.Repository | import org.springframework.stereotype.Repository | ||||
| @Repository | @Repository | ||||
| @@ -30,6 +31,15 @@ interface ShopRepository : AbstractRepository<Shop, Long> { | |||||
| fun findByCode(code: String): Shop? | 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( | @Query( | ||||
| nativeQuery = true, | nativeQuery = true, | ||||
| value = """ | value = """ | ||||
| @@ -1,6 +1,7 @@ | |||||
| package com.ffii.fpsms.modules.pickOrder.entity | package com.ffii.fpsms.modules.pickOrder.entity | ||||
| import com.ffii.core.entity.BaseEntity | import com.ffii.core.entity.BaseEntity | ||||
| import com.ffii.fpsms.modules.logistic.entity.Logistic | |||||
| import com.ffii.fpsms.modules.master.entity.Shop | import com.ffii.fpsms.modules.master.entity.Shop | ||||
| import jakarta.persistence.* | import jakarta.persistence.* | ||||
| import jakarta.validation.constraints.NotNull | import jakarta.validation.constraints.NotNull | ||||
| @@ -42,4 +43,8 @@ open class Truck : BaseEntity<Long>() { | |||||
| @Column(name = "remark") | @Column(name = "remark") | ||||
| open var remark: String? = null | 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 | package com.ffii.fpsms.modules.pickOrder.entity | ||||
| import com.ffii.core.support.AbstractRepository | 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.jpa.repository.Query | ||||
| import org.springframework.data.repository.query.Param | import org.springframework.data.repository.query.Param | ||||
| import org.springframework.stereotype.Repository | import org.springframework.stereotype.Repository | ||||
| @@ -32,8 +34,81 @@ interface TruckRepository : AbstractRepository<Truck, Long> { | |||||
| fun findByTruckLanceCode(truckLanceCode: String): Truck? | fun findByTruckLanceCode(truckLanceCode: String): Truck? | ||||
| @Query("SELECT t FROM Truck t WHERE t.truckLanceCode = :truckLanceCode AND t.deleted = false") | @Query("SELECT t FROM Truck t WHERE t.truckLanceCode = :truckLanceCode AND t.deleted = false") | ||||
| fun findAllByTruckLanceCodeAndDeletedFalse(@Param("truckLanceCode") truckLanceCode: String): List<Truck> | 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 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? | fun findByShopIdAndStoreId(shopId: Long, storeId: String): Truck? | ||||
| @@ -60,15 +135,17 @@ fun findByShopIdAndStoreIdAndDayOfWeek( | |||||
| SELECT t.* | SELECT t.* | ||||
| FROM truck t | FROM truck t | ||||
| INNER JOIN ( | 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 | FROM truck | ||||
| WHERE deleted = false | WHERE deleted = false | ||||
| AND TruckLanceCode IS NOT NULL | AND TruckLanceCode IS NOT NULL | ||||
| GROUP BY TruckLanceCode, remark | |||||
| GROUP BY TruckLanceCode, COALESCE(NULLIF(TRIM(remark), ''), '') | |||||
| ) AS unique_combos | ) AS unique_combos | ||||
| ON t.id = unique_combos.min_id | ON t.id = unique_combos.min_id | ||||
| WHERE t.deleted = false | WHERE t.deleted = false | ||||
| ORDER BY t.TruckLanceCode, t.remark | |||||
| ORDER BY t.TruckLanceCode, COALESCE(NULLIF(TRIM(t.remark), ''), '') | |||||
| """ | """ | ||||
| ) | ) | ||||
| fun findAllUniqueTruckLanceCodeAndRemarkCombinations(): List<Truck> | 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 jakarta.servlet.http.HttpServletRequest | ||||
| import org.apache.poi.ss.usermodel.Workbook | import org.apache.poi.ss.usermodel.Workbook | ||||
| import org.apache.poi.xssf.usermodel.XSSFWorkbook | 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 org.springframework.http.ResponseEntity | ||||
| import java.nio.charset.StandardCharsets | |||||
| import org.springframework.web.bind.annotation.* | import org.springframework.web.bind.annotation.* | ||||
| import org.springframework.web.multipart.MultipartHttpServletRequest | 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.service.TruckService | ||||
| import com.ffii.fpsms.modules.pickOrder.entity.TruckRepository | 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.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.deleteTruckLane | ||||
| import com.ffii.fpsms.modules.pickOrder.web.models.toLaneCombinationResponse | |||||
| import jakarta.validation.Valid | import jakarta.validation.Valid | ||||
| @RestController | @RestController | ||||
| @RequestMapping("/truck") | @RequestMapping("/truck") | ||||
| class TruckController( | |||||
| open class TruckController( | |||||
| private val truckService: TruckService, | private val truckService: TruckService, | ||||
| private val truckRepository: TruckRepository, | 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") | @PostMapping("/importExcel") | ||||
| @Throws(ServletRequestBindingException::class) | @Throws(ServletRequestBindingException::class) | ||||
| fun importExcel(request: HttpServletRequest): ResponseEntity<*> { | 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}") | @GetMapping("/findTruckLane/{shopId}") | ||||
| @@ -136,7 +290,7 @@ class TruckController( | |||||
| type = "truck", | type = "truck", | ||||
| message = if (truck != null) "Truck lane updated successfully" else "Truck lane not found", | message = if (truck != null) "Truck lane updated successfully" else "Truck lane not found", | ||||
| errorPosition = null, | errorPosition = null, | ||||
| entity = truck | |||||
| entity = null | |||||
| ) | ) | ||||
| } catch (e: Exception) { | } catch (e: Exception) { | ||||
| return MessageResponse( | 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") | @PostMapping("/deleteTruckLane") | ||||
| fun deleteTruckLane(@Valid @RequestBody request: deleteTruckLane): MessageResponse { | fun deleteTruckLane(@Valid @RequestBody request: deleteTruckLane): MessageResponse { | ||||
| try { | try { | ||||
| @@ -178,8 +358,10 @@ class TruckController( | |||||
| } | } | ||||
| @GetMapping("/findAllUniqueTruckLanceCodeAndRemarkCombinations") | @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) | 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") | @GetMapping("/findAllUniqueShopNamesAndCodesFromTrucks") | ||||
| fun findAllUniqueShopNamesAndCodesFromTrucks(): List<Map<String, String>> { | fun findAllUniqueShopNamesAndCodesFromTrucks(): List<Map<String, String>> { | ||||
| return truckService.findAllUniqueShopNamesAndCodesFromTrucks() | 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 | package com.ffii.fpsms.modules.pickOrder.web.models | ||||
| import jakarta.validation.constraints.NotBlank | |||||
| import java.time.LocalTime | import java.time.LocalTime | ||||
| data class SaveTruckRequest( | data class SaveTruckRequest( | ||||
| val id: Long? = null, | val id: Long? = null, | ||||
| @@ -11,6 +12,7 @@ data class SaveTruckRequest( | |||||
| val loadingSequence: Int, | val loadingSequence: Int, | ||||
| val remark: String? = null, | val remark: String? = null, | ||||
| val districtReference: String? = null, | val districtReference: String? = null, | ||||
| val logisticId: Long? = null, | |||||
| ) | ) | ||||
| data class SaveTruckLane( | data class SaveTruckLane( | ||||
| val id: Long, | val id: Long, | ||||
| @@ -19,7 +21,10 @@ data class SaveTruckLane( | |||||
| val loadingSequence: Long, | val loadingSequence: Long, | ||||
| val districtReference: String?, | val districtReference: String?, | ||||
| val storeId: 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( | data class deleteTruckLane( | ||||
| val id: Long | val id: Long | ||||
| @@ -39,4 +44,13 @@ data class CreateTruckWithoutShopRequest( | |||||
| val loadingSequence: Int = 0, | val loadingSequence: Int = 0, | ||||
| val districtReference: String? = null, | val districtReference: String? = null, | ||||
| val remark: 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; | |||||