From 57ab57dd65ea453dad31100248628cdc4c5ccec4 Mon Sep 17 00:00:00 2001 From: tommy Date: Mon, 18 May 2026 14:14:13 +0800 Subject: [PATCH] routeboard --- .../fpsms/modules/logistic/entity/Logistic.kt | 33 + .../logistic/entity/LogisticRepository.kt | 12 + .../logistic/service/LogisticService.kt | 82 + .../logistic/web/LogisticController.kt | 65 + .../web/models/DeleteLogisticRequest.kt | 9 + .../logistic/web/models/LogisticResponse.kt | 10 + .../web/models/SaveLogisticRequest.kt | 21 + .../web/models/SaveLogisticsBatchRequest.kt | 12 + .../modules/master/entity/ShopRepository.kt | 10 + .../fpsms/modules/pickOrder/entity/Truck.kt | 7 +- .../pickOrder/entity/TruckLaneVersion.kt | 19 + .../pickOrder/entity/TruckLaneVersionLine.kt | 55 + .../entity/TruckLaneVersionLineRepository.kt | 10 + .../entity/TruckLaneVersionRepository.kt | 12 + .../pickOrder/entity/TruckRepository.kt | 85 +- .../service/RouteLaneExcelSupport.kt | 202 +++ .../service/RouteReportExcelSupport.kt | 359 +++++ .../TruckLaneVersionReportExcelSupport.kt | 194 +++ ...TruckLaneVersionRouteReportExcelSupport.kt | 300 ++++ .../service/TruckLaneVersionService.kt | 306 ++++ .../modules/pickOrder/service/TruckService.kt | 1340 ++++++++++++++++- .../modules/pickOrder/web/TruckController.kt | 233 ++- .../web/TruckLaneVersionController.kt | 73 + .../web/models/ExportRouteLanesRequest.kt | 5 + .../web/models/ExportRouteReportRequest.kt | 11 + ...xportTruckLaneVersionReportExcelRequest.kt | 7 + .../web/models/ParseRouteLanesExcelModels.kt | 22 + .../pickOrder/web/models/SaveTruckRequest.kt | 16 +- .../models/TruckLaneCombinationResponse.kt | 34 + .../web/models/TruckLaneVersionModels.kt | 65 + .../UpdateTruckLaneVersionNoteRequest.kt | 8 + .../01_truck_lane_version_snapshot.sql | 130 ++ .../02_truck_lane_version_snapshot_patch.sql | 46 + .../01_truck_add_logistic_id.sql | 10 + .../01_truck_lane_version_drop_store_id.sql | 52 + ...ruck_lane_version_line_add_logistic_id.sql | 37 + 36 files changed, 3840 insertions(+), 52 deletions(-) create mode 100644 src/main/java/com/ffii/fpsms/modules/logistic/entity/Logistic.kt create mode 100644 src/main/java/com/ffii/fpsms/modules/logistic/entity/LogisticRepository.kt create mode 100644 src/main/java/com/ffii/fpsms/modules/logistic/service/LogisticService.kt create mode 100644 src/main/java/com/ffii/fpsms/modules/logistic/web/LogisticController.kt create mode 100644 src/main/java/com/ffii/fpsms/modules/logistic/web/models/DeleteLogisticRequest.kt create mode 100644 src/main/java/com/ffii/fpsms/modules/logistic/web/models/LogisticResponse.kt create mode 100644 src/main/java/com/ffii/fpsms/modules/logistic/web/models/SaveLogisticRequest.kt create mode 100644 src/main/java/com/ffii/fpsms/modules/logistic/web/models/SaveLogisticsBatchRequest.kt create mode 100644 src/main/java/com/ffii/fpsms/modules/pickOrder/entity/TruckLaneVersion.kt create mode 100644 src/main/java/com/ffii/fpsms/modules/pickOrder/entity/TruckLaneVersionLine.kt create mode 100644 src/main/java/com/ffii/fpsms/modules/pickOrder/entity/TruckLaneVersionLineRepository.kt create mode 100644 src/main/java/com/ffii/fpsms/modules/pickOrder/entity/TruckLaneVersionRepository.kt create mode 100644 src/main/java/com/ffii/fpsms/modules/pickOrder/service/RouteLaneExcelSupport.kt create mode 100644 src/main/java/com/ffii/fpsms/modules/pickOrder/service/RouteReportExcelSupport.kt create mode 100644 src/main/java/com/ffii/fpsms/modules/pickOrder/service/TruckLaneVersionReportExcelSupport.kt create mode 100644 src/main/java/com/ffii/fpsms/modules/pickOrder/service/TruckLaneVersionRouteReportExcelSupport.kt create mode 100644 src/main/java/com/ffii/fpsms/modules/pickOrder/service/TruckLaneVersionService.kt create mode 100644 src/main/java/com/ffii/fpsms/modules/pickOrder/web/TruckLaneVersionController.kt create mode 100644 src/main/java/com/ffii/fpsms/modules/pickOrder/web/models/ExportRouteLanesRequest.kt create mode 100644 src/main/java/com/ffii/fpsms/modules/pickOrder/web/models/ExportRouteReportRequest.kt create mode 100644 src/main/java/com/ffii/fpsms/modules/pickOrder/web/models/ExportTruckLaneVersionReportExcelRequest.kt create mode 100644 src/main/java/com/ffii/fpsms/modules/pickOrder/web/models/ParseRouteLanesExcelModels.kt create mode 100644 src/main/java/com/ffii/fpsms/modules/pickOrder/web/models/TruckLaneCombinationResponse.kt create mode 100644 src/main/java/com/ffii/fpsms/modules/pickOrder/web/models/TruckLaneVersionModels.kt create mode 100644 src/main/java/com/ffii/fpsms/modules/pickOrder/web/models/UpdateTruckLaneVersionNoteRequest.kt create mode 100644 src/main/resources/db/changelog/changes/20260430_02_2fi/01_truck_lane_version_snapshot.sql create mode 100644 src/main/resources/db/changelog/changes/20260430_02_2fi/02_truck_lane_version_snapshot_patch.sql create mode 100644 src/main/resources/db/changelog/changes/20260504_01_2fi/01_truck_add_logistic_id.sql create mode 100644 src/main/resources/db/changelog/changes/20260505_01_2fi/01_truck_lane_version_drop_store_id.sql create mode 100644 src/main/resources/db/changelog/changes/20260507_01_2fi/01_truck_lane_version_line_add_logistic_id.sql diff --git a/src/main/java/com/ffii/fpsms/modules/logistic/entity/Logistic.kt b/src/main/java/com/ffii/fpsms/modules/logistic/entity/Logistic.kt new file mode 100644 index 0000000..f96f0fc --- /dev/null +++ b/src/main/java/com/ffii/fpsms/modules/logistic/entity/Logistic.kt @@ -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() { + + @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 +} + diff --git a/src/main/java/com/ffii/fpsms/modules/logistic/entity/LogisticRepository.kt b/src/main/java/com/ffii/fpsms/modules/logistic/entity/LogisticRepository.kt new file mode 100644 index 0000000..4304fbb --- /dev/null +++ b/src/main/java/com/ffii/fpsms/modules/logistic/entity/LogisticRepository.kt @@ -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 { + fun findAllByDeletedFalseOrderByIdAsc(): List + fun findByIdAndDeletedFalse(id: Long): Logistic? + fun findByCarPlateAndDeletedFalse(carPlate: String): Logistic? +} + diff --git a/src/main/java/com/ffii/fpsms/modules/logistic/service/LogisticService.kt b/src/main/java/com/ffii/fpsms/modules/logistic/service/LogisticService.kt new file mode 100644 index 0000000..807d4c5 --- /dev/null +++ b/src/main/java/com/ffii/fpsms/modules/logistic/service/LogisticService.kt @@ -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 { + 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): List { + 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" + } +} + diff --git a/src/main/java/com/ffii/fpsms/modules/logistic/web/LogisticController.kt b/src/main/java/com/ffii/fpsms/modules/logistic/web/LogisticController.kt new file mode 100644 index 0000000..2e65e06 --- /dev/null +++ b/src/main/java/com/ffii/fpsms/modules/logistic/web/LogisticController.kt @@ -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 { + 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 { + return logisticService.saveBatchCreate(body.items).map { it.toResponse() } + } + + @PostMapping("/delete") + fun delete(@Valid @RequestBody request: DeleteLogisticRequest): ResponseEntity { + 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, + ) + } +} + diff --git a/src/main/java/com/ffii/fpsms/modules/logistic/web/models/DeleteLogisticRequest.kt b/src/main/java/com/ffii/fpsms/modules/logistic/web/models/DeleteLogisticRequest.kt new file mode 100644 index 0000000..3b0eb42 --- /dev/null +++ b/src/main/java/com/ffii/fpsms/modules/logistic/web/models/DeleteLogisticRequest.kt @@ -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, +) + diff --git a/src/main/java/com/ffii/fpsms/modules/logistic/web/models/LogisticResponse.kt b/src/main/java/com/ffii/fpsms/modules/logistic/web/models/LogisticResponse.kt new file mode 100644 index 0000000..c991245 --- /dev/null +++ b/src/main/java/com/ffii/fpsms/modules/logistic/web/models/LogisticResponse.kt @@ -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, +) + diff --git a/src/main/java/com/ffii/fpsms/modules/logistic/web/models/SaveLogisticRequest.kt b/src/main/java/com/ffii/fpsms/modules/logistic/web/models/SaveLogisticRequest.kt new file mode 100644 index 0000000..e82763b --- /dev/null +++ b/src/main/java/com/ffii/fpsms/modules/logistic/web/models/SaveLogisticRequest.kt @@ -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, +) + diff --git a/src/main/java/com/ffii/fpsms/modules/logistic/web/models/SaveLogisticsBatchRequest.kt b/src/main/java/com/ffii/fpsms/modules/logistic/web/models/SaveLogisticsBatchRequest.kt new file mode 100644 index 0000000..57445dd --- /dev/null +++ b/src/main/java/com/ffii/fpsms/modules/logistic/web/models/SaveLogisticsBatchRequest.kt @@ -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, +) diff --git a/src/main/java/com/ffii/fpsms/modules/master/entity/ShopRepository.kt b/src/main/java/com/ffii/fpsms/modules/master/entity/ShopRepository.kt index 6986125..5fae1eb 100644 --- a/src/main/java/com/ffii/fpsms/modules/master/entity/ShopRepository.kt +++ b/src/main/java/com/ffii/fpsms/modules/master/entity/ShopRepository.kt @@ -6,6 +6,7 @@ import com.ffii.fpsms.modules.master.entity.projections.ShopCombo import com.ffii.fpsms.modules.master.enums.ShopType import com.ffii.fpsms.modules.pickOrder.entity.Truck import org.springframework.data.jpa.repository.Query +import org.springframework.data.repository.query.Param import org.springframework.stereotype.Repository @Repository @@ -30,6 +31,15 @@ interface ShopRepository : AbstractRepository { 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): List + @Query( nativeQuery = true, value = """ diff --git a/src/main/java/com/ffii/fpsms/modules/pickOrder/entity/Truck.kt b/src/main/java/com/ffii/fpsms/modules/pickOrder/entity/Truck.kt index 976ac97..14a5417 100644 --- a/src/main/java/com/ffii/fpsms/modules/pickOrder/entity/Truck.kt +++ b/src/main/java/com/ffii/fpsms/modules/pickOrder/entity/Truck.kt @@ -1,6 +1,7 @@ package com.ffii.fpsms.modules.pickOrder.entity import com.ffii.core.entity.BaseEntity +import com.ffii.fpsms.modules.logistic.entity.Logistic import com.ffii.fpsms.modules.master.entity.Shop import jakarta.persistence.* import jakarta.validation.constraints.NotNull @@ -42,4 +43,8 @@ open class Truck : BaseEntity() { @Column(name = "remark") open var remark: String? = null -} \ No newline at end of file + @ManyToOne(fetch = FetchType.LAZY) + @JoinColumn(name = "logisticId") + open var logistic: Logistic? = null + +} diff --git a/src/main/java/com/ffii/fpsms/modules/pickOrder/entity/TruckLaneVersion.kt b/src/main/java/com/ffii/fpsms/modules/pickOrder/entity/TruckLaneVersion.kt new file mode 100644 index 0000000..bf3e5f6 --- /dev/null +++ b/src/main/java/com/ffii/fpsms/modules/pickOrder/entity/TruckLaneVersion.kt @@ -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() { + + @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 +} + diff --git a/src/main/java/com/ffii/fpsms/modules/pickOrder/entity/TruckLaneVersionLine.kt b/src/main/java/com/ffii/fpsms/modules/pickOrder/entity/TruckLaneVersionLine.kt new file mode 100644 index 0000000..bf83c01 --- /dev/null +++ b/src/main/java/com/ffii/fpsms/modules/pickOrder/entity/TruckLaneVersionLine.kt @@ -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() { + + @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 +} + diff --git a/src/main/java/com/ffii/fpsms/modules/pickOrder/entity/TruckLaneVersionLineRepository.kt b/src/main/java/com/ffii/fpsms/modules/pickOrder/entity/TruckLaneVersionLineRepository.kt new file mode 100644 index 0000000..7963ae8 --- /dev/null +++ b/src/main/java/com/ffii/fpsms/modules/pickOrder/entity/TruckLaneVersionLineRepository.kt @@ -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 { + fun findAllByTruckLaneVersion_IdAndDeletedFalseOrderByIdAsc(truckLaneVersionId: Long): List +} + diff --git a/src/main/java/com/ffii/fpsms/modules/pickOrder/entity/TruckLaneVersionRepository.kt b/src/main/java/com/ffii/fpsms/modules/pickOrder/entity/TruckLaneVersionRepository.kt new file mode 100644 index 0000000..f69ecc6 --- /dev/null +++ b/src/main/java/com/ffii/fpsms/modules/pickOrder/entity/TruckLaneVersionRepository.kt @@ -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 { + fun findAllByTruckLanceCodeAndDeletedFalseOrderByCreatedDesc(truckLanceCode: String): List + fun findAllByDeletedFalseOrderByCreatedDesc(): List + fun findByIdAndDeletedFalse(id: Long): TruckLaneVersion? +} + diff --git a/src/main/java/com/ffii/fpsms/modules/pickOrder/entity/TruckRepository.kt b/src/main/java/com/ffii/fpsms/modules/pickOrder/entity/TruckRepository.kt index c6d4277..116724e 100644 --- a/src/main/java/com/ffii/fpsms/modules/pickOrder/entity/TruckRepository.kt +++ b/src/main/java/com/ffii/fpsms/modules/pickOrder/entity/TruckRepository.kt @@ -1,6 +1,8 @@ package com.ffii.fpsms.modules.pickOrder.entity import com.ffii.core.support.AbstractRepository +import com.ffii.fpsms.modules.logistic.entity.Logistic +import org.springframework.data.jpa.repository.Modifying import org.springframework.data.jpa.repository.Query import org.springframework.data.repository.query.Param import org.springframework.stereotype.Repository @@ -32,8 +34,81 @@ interface TruckRepository : AbstractRepository { fun findByTruckLanceCode(truckLanceCode: String): Truck? @Query("SELECT t FROM Truck t WHERE t.truckLanceCode = :truckLanceCode AND t.deleted = false") fun findAllByTruckLanceCodeAndDeletedFalse(@Param("truckLanceCode") truckLanceCode: String): List + + /** + * 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 + + /** + * 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 + + /** + * 單一 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 fun findByShopNameAndStoreIdAndTruckLanceCode(shopName: String, storeId: String, truckLanceCode: String): Truck? - fun findByShopCodeAndStoreId(shopCode: String, storeId: String): Truck? + /** 同店同樓層重複列時取 id 最小一筆,避免 NonUniqueResultException */ + fun findFirstByShopCodeAndStoreIdAndDeletedFalseOrderByIdAsc( + shopCode: String, + storeId: String, + ): Truck? fun findByShopIdAndStoreId(shopId: Long, storeId: String): Truck? @@ -60,15 +135,17 @@ fun findByShopIdAndStoreIdAndDayOfWeek( SELECT t.* FROM truck t INNER JOIN ( - SELECT TruckLanceCode, remark, MIN(id) as min_id + SELECT TruckLanceCode, + COALESCE(NULLIF(TRIM(remark), ''), '') AS remark_norm, + MIN(id) AS min_id FROM truck WHERE deleted = false AND TruckLanceCode IS NOT NULL - GROUP BY TruckLanceCode, remark + GROUP BY TruckLanceCode, COALESCE(NULLIF(TRIM(remark), ''), '') ) AS unique_combos ON t.id = unique_combos.min_id WHERE t.deleted = false - ORDER BY t.TruckLanceCode, t.remark + ORDER BY t.TruckLanceCode, COALESCE(NULLIF(TRIM(t.remark), ''), '') """ ) fun findAllUniqueTruckLanceCodeAndRemarkCombinations(): List diff --git a/src/main/java/com/ffii/fpsms/modules/pickOrder/service/RouteLaneExcelSupport.kt b/src/main/java/com/ffii/fpsms/modules/pickOrder/service/RouteLaneExcelSupport.kt new file mode 100644 index 0000000..2537a54 --- /dev/null +++ b/src/main/java/com/ffii/fpsms/modules/pickOrder/service/RouteLaneExcelSupport.kt @@ -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? { + 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), + ) + } +} diff --git a/src/main/java/com/ffii/fpsms/modules/pickOrder/service/RouteReportExcelSupport.kt b/src/main/java/com/ffii/fpsms/modules/pickOrder/service/RouteReportExcelSupport.kt new file mode 100644 index 0000000..f22a40a --- /dev/null +++ b/src/main/java/com/ffii/fpsms/modules/pickOrder/service/RouteReportExcelSupport.kt @@ -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, + 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, + ) + + data class LaneGroup( + val laneCode: String, + val districts: List, + ) + + data class DistrictGroup( + val districtLabel: String, + val shops: List, + ) +} + diff --git a/src/main/java/com/ffii/fpsms/modules/pickOrder/service/TruckLaneVersionReportExcelSupport.kt b/src/main/java/com/ffii/fpsms/modules/pickOrder/service/TruckLaneVersionReportExcelSupport.kt new file mode 100644 index 0000000..6122846 --- /dev/null +++ b/src/main/java/com/ffii/fpsms/modules/pickOrder/service/TruckLaneVersionReportExcelSupport.kt @@ -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 = emptySet(), + ) + + fun writeSummarySheet(wb: XSSFWorkbook, meta: SummaryMeta, rows: List) { + 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) + } +} + diff --git a/src/main/java/com/ffii/fpsms/modules/pickOrder/service/TruckLaneVersionRouteReportExcelSupport.kt b/src/main/java/com/ffii/fpsms/modules/pickOrder/service/TruckLaneVersionRouteReportExcelSupport.kt new file mode 100644 index 0000000..ebd98b7 --- /dev/null +++ b/src/main/java/com/ffii/fpsms/modules/pickOrder/service/TruckLaneVersionRouteReportExcelSupport.kt @@ -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, + ) + + data class LaneGroup( + val laneLabel: String, + val districts: List, + ) + + data class TimeGroup( + val timeLabel: String, + val lanes: List, + ) + + 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, + 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 + } +} + diff --git a/src/main/java/com/ffii/fpsms/modules/pickOrder/service/TruckLaneVersionService.kt b/src/main/java/com/ffii/fpsms/modules/pickOrder/service/TruckLaneVersionService.kt new file mode 100644 index 0000000..bd50d7f --- /dev/null +++ b/src/main/java/com/ffii/fpsms/modules/pickOrder/service/TruckLaneVersionService.kt @@ -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, + ): 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 { + val lane = truckLanceCode.trim() + return truckLaneVersionRepository + .findAllByTruckLanceCodeAndDeletedFalseOrderByCreatedDesc(lane) + .map(::toResponse) + } + + open fun listAllVersions(): List { + return truckLaneVersionRepository + .findAllByDeletedFalseOrderByCreatedDesc() + .map(::toResponse) + } + + open fun getVersionLines(versionId: Long): List { + 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() + + fun s(v: Any?): String? = v?.toString() + + allKeys.forEach { key -> + val a = fromByRow[key] + val b = toByRow[key] + val changes = mutableListOf() + + 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 { + 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() + 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" + } +} diff --git a/src/main/java/com/ffii/fpsms/modules/pickOrder/service/TruckService.kt b/src/main/java/com/ffii/fpsms/modules/pickOrder/service/TruckService.kt index a473ae3..0175ef4 100644 --- a/src/main/java/com/ffii/fpsms/modules/pickOrder/service/TruckService.kt +++ b/src/main/java/com/ffii/fpsms/modules/pickOrder/service/TruckService.kt @@ -5,11 +5,17 @@ import com.ffii.core.support.JdbcDao import com.ffii.core.utils.ExcelUtils import org.apache.poi.ss.usermodel.Sheet import org.apache.poi.ss.usermodel.Workbook +import org.apache.poi.xssf.usermodel.XSSFWorkbook import org.springframework.stereotype.Service +import com.ffii.fpsms.modules.logistic.entity.Logistic +import com.ffii.fpsms.modules.logistic.entity.LogisticRepository import com.ffii.fpsms.modules.pickOrder.entity.Truck +import com.ffii.fpsms.modules.pickOrder.entity.TruckLaneVersionLineRepository +import com.ffii.fpsms.modules.pickOrder.entity.TruckLaneVersionRepository import com.ffii.fpsms.modules.pickOrder.entity.TruckRepository import com.ffii.fpsms.modules.pickOrder.web.models.SaveTruckRequest import com.ffii.fpsms.modules.pickOrder.web.models.CreateTruckWithoutShopRequest +import com.ffii.fpsms.modules.pickOrder.web.models.UpdateLaneLogisticRequest import com.ffii.fpsms.modules.pickOrder.web.models.UpdateTruckShopDetailsRequest import java.time.LocalTime import java.time.format.DateTimeFormatter @@ -17,6 +23,14 @@ import com.ffii.fpsms.modules.master.entity.ShopRepository import com.ffii.fpsms.modules.master.entity.projections.ShopAndTruck import com.ffii.fpsms.modules.pickOrder.web.models.SaveTruckLane import jakarta.transaction.Transactional +import java.io.ByteArrayOutputStream +import java.text.Collator +import java.time.LocalDate +import java.util.Locale +import com.ffii.fpsms.modules.pickOrder.web.models.DiffFieldChange +import com.ffii.fpsms.modules.pickOrder.web.models.ParseRouteLanesExcelResponse +import com.ffii.fpsms.modules.pickOrder.web.models.RouteLaneImportPreviewRow +import com.ffii.fpsms.modules.pickOrder.web.models.TruckLaneVersionDiffLine @Service @@ -24,7 +38,17 @@ open class TruckService( private val jdbcDao: JdbcDao, private val truckRepository: TruckRepository, private val shopRepository: ShopRepository, + private val logisticRepository: LogisticRepository, + private val truckLaneVersionRepository: TruckLaneVersionRepository, + private val truckLaneVersionLineRepository: TruckLaneVersionLineRepository, ) : AbstractBaseEntityService(jdbcDao, truckRepository) { + + private fun logisticRefOrNull(id: Long?): Logistic? { + if (id == null) return null + return logisticRepository.findById(id).orElseThrow { + IllegalArgumentException("Logistic not found with id: $id") + } + } open fun saveTruck(request: SaveTruckRequest): Truck { val truck = request.id?.let { truckRepository.findById(it).orElse(null) @@ -39,41 +63,98 @@ open class TruckService( this.truckLanceCode = request.truckLanceCode this.departureTime = request.departureTime this.shop = shop - this.shopName = request.shopName + this.shopName = normalizeTruckShopDisplayName(request.shopName) this.shopCode = request.shopCode this.loadingSequence = request.loadingSequence this.remark = request.remark + this.districtReference = request.districtReference + this.logistic = logisticRefOrNull(request.logisticId) } return truckRepository.save(truck); } + /** + * 同 (truckLanceCode, remark) 桶內僅用來佔位的列:`shop` 為 null 且店名/代碼為空或舊版 Unassign。 + * 新增店鋪時應 **先 UPDATE 此列**,避免再 INSERT 一筆造成多條「空列」。 + */ + private fun isLanePlaceholderTruck(truck: Truck): Boolean { + if (truck.shop != null) return false + val nm = truck.shopName?.trim().orEmpty() + val cd = truck.shopCode?.trim().orEmpty() + if (nm.isEmpty() && cd.isEmpty()) return true + if (nm.equals("unassign", ignoreCase = true) || cd.equals("unassign", ignoreCase = true)) return true + if (nm.equals("unassigned", ignoreCase = true) || cd.equals("unassigned", ignoreCase = true)) return true + return false + } + + /** 與 [findAllByTruckLanceCodeAndRemarkAndDeletedFalse] 相同的 remark 桶規則 */ + private fun trucksInSameLaneBucket( + truckLanceCode: String, + storeId: String, + remark: String?, + ): List { + val bucketRemark = if (storeId == "4F") remark?.trim()?.takeIf { it.isNotEmpty() } else null + val trimmed = bucketRemark?.trim().orEmpty() + val blankRemark = trimmed.isEmpty() + return truckRepository.findAllByTruckLanceCodeAndRemarkAndDeletedFalse( + truckLanceCode.trim(), + blankRemark, + if (blankRemark) null else trimmed, + ) + } + + private fun softDeleteTruckRow(t: Truck) { + t.deleted = true + truckRepository.save(t) + } + private fun parseDepartureTime(timeStr: String?): LocalTime? { if (timeStr.isNullOrBlank()) return null return try { val cleaned = timeStr.trim().uppercase().replace(" ", "") - // 处理 3:00AM / 5:30PM 这类 12 小时制 + // 12 小時制:3:00AM、5:30:15PM(含秒) if (cleaned.contains("AM") || cleaned.contains("PM")) { val isPM = cleaned.contains("PM") val timePart = cleaned.replace("AM", "").replace("PM", "") val parts = timePart.split(":") - if (parts.size == 2) { - var hour = parts[0].toInt() - val minute = parts[1].toIntOrNull() ?: 0 - - if (isPM && hour != 12) hour += 12 - if (!isPM && hour == 12) hour = 0 - - LocalTime.of(hour, minute) - } else null - } else { - // 处理 17:30 / 3:00 这类 24 小时制 + when (parts.size) { + 2 -> { + var hour = parts[0].toInt() + val minute = parts[1].toIntOrNull() ?: 0 + if (isPM && hour != 12) hour += 12 + if (!isPM && hour == 12) hour = 0 + return LocalTime.of(hour, minute) + } + 3 -> { + var hour = parts[0].toInt() + val minute = parts[1].toIntOrNull() ?: 0 + val second = parts[2].toIntOrNull() ?: 0 + if (isPM && hour != 12) hour += 12 + if (!isPM && hour == 12) hour = 0 + return LocalTime.of(hour, minute, second) + } + } + } + // 24 小時制:須接受匯出欄位 formatDepartureForExcel 的 HH:mm:ss(如 17:30:00) + val t = timeStr.trim() + try { + LocalTime.parse(t) + } catch (_: Exception) { try { - LocalTime.parse(timeStr.trim(), DateTimeFormatter.ofPattern("H:mm")) + LocalTime.parse(t, DateTimeFormatter.ofPattern("H:mm:ss")) } catch (_: Exception) { - LocalTime.parse(timeStr.trim(), DateTimeFormatter.ofPattern("HH:mm")) + try { + LocalTime.parse(t, DateTimeFormatter.ofPattern("HH:mm:ss")) + } catch (_: Exception) { + try { + LocalTime.parse(t, DateTimeFormatter.ofPattern("H:mm")) + } catch (_: Exception) { + LocalTime.parse(t, DateTimeFormatter.ofPattern("HH:mm")) + } + } } } } catch (e: Exception) { @@ -99,6 +180,118 @@ open class TruckService( return letterPart + normalizedNumber } + /** MTMS 車線匯入:判斷 truck 列是否與 Excel 指向同一 shop(shopId 或 shopCode 多寫法)。 */ + private fun truckRowMatchesImportShop( + truck: Truck, + shopId: Long, + shopCodeRaw: String, + normalizedShopCode: String, + ): Boolean { + if (truck.shop?.id != null && truck.shop?.id == shopId) return true + val tc = truck.shopCode?.trim().orEmpty() + if (tc.isEmpty()) return false + val rawTrim = shopCodeRaw.trim() + return tc == rawTrim || + tc == normalizedShopCode || + normalizeShopCode(tc) == normalizedShopCode + } + + /** + * 同 (truckLanceCode, remark) 桶內同店至多一筆:保留 id 最小,其餘 soft delete。 + * 桶內無則 fallback 全域 findFirst(搬移他線列進當前桶)。 + */ + private fun resolveExistingTruckForRouteLaneImport( + truckLanceCode: String, + storeId: String, + laneRemark: String?, + shopId: Long, + shopCodeRaw: String, + normalizedShopCode: String, + ): Truck? { + val bucket = trucksInSameLaneBucket(truckLanceCode, storeId, laneRemark) + val matched = + bucket + .filter { truckRowMatchesImportShop(it, shopId, shopCodeRaw, normalizedShopCode) } + .sortedBy { it.id ?: Long.MAX_VALUE } + if (matched.isEmpty()) { + return truckRepository.findFirstByShopCodeAndStoreIdAndDeletedFalseOrderByIdAsc( + shopCodeRaw, + storeId, + ) + ?: truckRepository.findFirstByShopCodeAndStoreIdAndDeletedFalseOrderByIdAsc( + normalizedShopCode, + storeId, + ) + } + val primary = matched.first() + for (dup in matched.drop(1)) { + logger.warn( + "Route lane import: soft-delete duplicate truck id=${dup.id} shopCode=${dup.shopCode} " + + "storeId=$storeId lane=$truckLanceCode; keep id=${primary.id}", + ) + softDeleteTruckRow(dup) + } + val pid = primary.id ?: return primary + return truckRepository.findById(pid).orElse(primary) + } + + /** + * MTMS 車線匯入:Excel「板」欄 → `districtReference`。 + * 空白、`未分類`、舊版合成「板一…」皆視同未分類(存 null);其餘 trim 後原樣寫入。 + */ + private fun normalizeDistrictReferenceForRouteLaneImport(plateColumn: String): String? { + val t = plateColumn.trim() + if (t.isEmpty() || t == "未分類") return null + for (i in 0 until 64) { + if (t == RouteLaneExcelSupport.plateLabel(i)) return null + } + return t + } + + /** + * M18 / combo 店名可能是 `CF001 - 雞檔-健威坊店`;`truck.ShopName` 應存分店短名(如 `健威`)。 + * 規則:若為 SKU 前綴開頭或分段數≥3,取最後一段並去掉尾綴「坊店」。 + */ + private fun normalizeTruckShopDisplayName(raw: String?): String { + if (raw.isNullOrBlank()) return "" + val s = raw.trim() + val parts = s.split(Regex("\\s*-\\s*")).map { it.trim() }.filter { it.isNotEmpty() } + if (parts.size < 2) return s + val first = parts[0] + val codeSkuLike = first.matches(Regex("^[A-Za-z]{1,6}\\d+$")) + val takeLast = parts.size >= 3 || codeSkuLike + if (!takeLast) return s + var last = parts.last() + if (last.endsWith("坊店")) { + last = last.removeSuffix("坊店").trim() + } + return last.ifEmpty { s } + } + + /** + * MTMS「品牌」欄:M18 全名多為 `店鋪編 - 品牌 - 分店短名/…` 或 `編號 - 品牌-分店名`; + * 與 [normalizeTruckShopDisplayName] 用同一套 ` - ` 分段,取品牌段(非首段之 SKU 前綴、非最末段之顯示名)。 + */ + private fun deriveBrandFromShopFullName(raw: String?): String { + if (raw.isNullOrBlank()) return "" + val s = raw.trim() + val parts = s.split(Regex("\\s*-\\s*")).map { it.trim() }.filter { it.isNotEmpty() } + if (parts.size >= 3) { + return parts[1] + } + if (parts.size == 2) { + val first = parts[0] + val codeSkuLike = first.matches(Regex("^[A-Za-z]{1,6}\\d+$")) + if (codeSkuLike) { + val sub = parts[1].split('-').map { it.trim() }.filter { it.isNotEmpty() } + if (sub.size >= 2) { + return sub[0] + } + } + } + return "" + } + open fun importExcel(workbook: Workbook?): String { logger.info("--------- Start - Import Warehouse Excel -------"); @@ -121,7 +314,7 @@ open class TruckService( val START_ROW_INDEX = 3; logger.info("Total rows in sheet: ${sheet.lastRowNum + 1}, Processing from row ${START_ROW_INDEX + 1} to ${sheet.lastRowNum + 1}"); // Start Import - for (i in START_ROW_INDEX.. { @@ -269,6 +489,20 @@ open class TruckService( return truckRepository.findAllByTruckLanceCodeAndDeletedFalse(truckLanceCode) } + open fun findAllByTruckLanceCodeAndRemarkAndDeletedFalse(truckLanceCode: String, remark: String?): List { + val trimmed = remark?.trim() ?: "" + val blankRemark = trimmed.isEmpty() + return truckRepository.findAllByTruckLanceCodeAndRemarkAndDeletedFalse( + truckLanceCode, + blankRemark, + if (blankRemark) null else trimmed, + ) + } + + open fun findAllForRouteBoard(): List { + return truckRepository.findAllForRouteBoard() + } + open fun findAllUniqueShopNamesAndCodesFromTrucks(): List> { return truckRepository.findAllUniqueShopNamesAndCodesFromTrucks() } @@ -304,7 +538,7 @@ open class TruckService( // Always use shopName and shopCode from request (from truck table), not from shop entity // This allows truck table to have different shop names/codes than shop table if (request.shopName != null) { - truck.shopName = request.shopName + truck.shopName = normalizeTruckShopDisplayName(request.shopName) } if (request.shopCode != null) { @@ -325,25 +559,1069 @@ open class TruckService( return truckRepository.save(truck) } + @Transactional + open fun updateLogisticForEntireLane(request: UpdateLaneLogisticRequest): Int { + val trimmedCode = request.truckLanceCode.trim() + val trimmedRemark = request.remark?.trim() ?: "" + val blankRemark = trimmedRemark.isEmpty() + val logistic = logisticRefOrNull(request.logisticId) + val updated = truckRepository.bulkUpdateLogisticForLaneGroup( + logistic, + trimmedCode, + blankRemark, + if (blankRemark) null else trimmedRemark, + ) + if (updated == 0) { + throw IllegalArgumentException("No truck rows for lane: $trimmedCode") + } + return updated + } + @Transactional open fun createTruckWithoutShop(request: CreateTruckWithoutShopRequest): Truck { - // Create a new truck without a shop - val truck = Truck() - + val laneRows = trucksInSameLaneBucket( + request.truckLanceCode, + request.store_id, + request.remark, + ) + val placeholders = laneRows.filter { isLanePlaceholderTruck(it) } + .sortedBy { it.id ?: Long.MAX_VALUE } + val primary = placeholders.firstOrNull() + val truck = primary ?: Truck() + truck.apply { this.storeId = request.store_id this.truckLanceCode = request.truckLanceCode this.departureTime = request.departureTime this.shop = null - this.shopName = "Unassign" - this.shopCode = "Unassign" + /** 僅佔位列:不寫假店名(避免 DB / 報表出現 Unassign) */ + this.shopName = null + this.shopCode = null this.loadingSequence = request.loadingSequence this.districtReference = request.districtReference // Only set remark if store_id is "4F", otherwise set to null this.remark = if (request.store_id == "4F") request.remark else null + this.logistic = logisticRefOrNull(request.logisticId) } - - return truckRepository.save(truck) + + val saved = truckRepository.save(truck) + if (primary != null) { + placeholders.drop(1).forEach { softDeleteTruckRow(it) } + } + return saved + } + + private fun formatDepartureForExcel(t: LocalTime?): String { + if (t == null) return "" + return DateTimeFormatter.ofPattern("HH:mm:ss").format(t) + } + + /** 依 loading 順序切出連續同 district 的段(每段日後對應一個「板」標題列)。 */ + private fun splitDistrictSegments(trucks: List): List> { + if (trucks.isEmpty()) return emptyList() + val out = mutableListOf>() + var cur = mutableListOf() + var prev: String? = null + for (t in trucks) { + val d = t.districtReference?.trim().orEmpty() + if (prev != null && d != prev) { + out.add(cur) + cur = mutableListOf() + } + cur.add(t) + prev = d + } + out.add(cur) + return out + } + + /** + * 匯出用:每段給定「板」欄顯示文字後,依中文排序段順序;段內維持原 loading 順序。 + * 無區域代碼(未分類)的段固定輸出「未分類」(與前端 RouteBoard 一致);多段未分類時同字串需靠列順序區分。 + */ + private fun sortDistrictSegmentsForPlateColumnExport( + trucksInLoadingOrder: List, + ): List>> { + val segments = splitDistrictSegments(trucksInLoadingOrder) + val labeled = segments.map { seg -> + val d = seg.first().districtReference?.trim().orEmpty() + val label = if (d.isNotEmpty()) d else "未分類" + label to seg + } + val coll = Collator.getInstance(Locale.forLanguageTag("zh-HK")) + coll.strength = Collator.TERTIARY + return labeled.sortedWith { a, b -> coll.compare(a.first, b.first) } + } + + /** + * PDF 圖1:一個 workbook 內每個車線一個 sheet(MTMS_ROUTE_V1)。 + */ + open fun exportRouteLanesExcelBytes(laneIds: List): ByteArray { + val wb = XSSFWorkbook() + return ByteArrayOutputStream().use { bos -> + try { + val distinctIds = laneIds.distinct().filter { it.isNotBlank() } + if (distinctIds.isEmpty()) { + throw IllegalArgumentException("laneIds is empty") + } + for (laneId in distinctIds) { + val key = RouteLaneExcelSupport.decodeLaneId(laneId) + ?: throw IllegalArgumentException("Invalid lane id: $laneId") + val (code, remark) = key + val trucks = findAllByTruckLanceCodeAndRemarkAndDeletedFalse(code, remark) + .sortedWith(compareBy({ it.loadingSequence ?: 0 }, { it.id ?: 0L })) + if (trucks.isEmpty()) continue + + val sheet = wb.createSheet( + RouteLaneExcelSupport.uniqueSheetName(wb, code, remark), + ) + + var rr = sheet.createRow(RouteLaneExcelSupport.ROW_MARKER) + rr.createCell(RouteLaneExcelSupport.COL_META_A) + .setCellValue(RouteLaneExcelSupport.FORMAT_MARKER) + rr.createCell(RouteLaneExcelSupport.COL_META_B).setCellValue(code) + rr.createCell(RouteLaneExcelSupport.COL_META_C).setCellValue(remark ?: "") + + rr = sheet.createRow(RouteLaneExcelSupport.ROW_STORE) + rr.createCell(RouteLaneExcelSupport.COL_META_A).setCellValue("樓層") + rr.createCell(RouteLaneExcelSupport.COL_META_B) + .setCellValue(trucks.first().storeId ?: "") + + rr = sheet.createRow(RouteLaneExcelSupport.ROW_DEPARTURE_DEFAULT) + rr.createCell(RouteLaneExcelSupport.COL_META_A).setCellValue("出車時間") + rr.createCell(RouteLaneExcelSupport.COL_META_B).setCellValue( + formatDepartureForExcel(trucks.first().departureTime), + ) + + rr = sheet.createRow(RouteLaneExcelSupport.ROW_HEADER) + rr.createCell(RouteLaneExcelSupport.COL_AREA_PLATE).setCellValue("板") + rr.createCell(RouteLaneExcelSupport.COL_SHOP_NAME).setCellValue("店鋪名稱") + rr.createCell(RouteLaneExcelSupport.COL_BRAND).setCellValue("品牌") + rr.createCell(RouteLaneExcelSupport.COL_SHOP_CODE).setCellValue("店鋪編號") + rr.createCell(RouteLaneExcelSupport.COL_SCHEDULE).setCellValue("此店車期") + rr.createCell(RouteLaneExcelSupport.COL_DEPARTURE_ROW).setCellValue("出車時間") + + val segmentsForRows = sortDistrictSegmentsForPlateColumnExport(trucks) + var rowNum = RouteLaneExcelSupport.ROW_FIRST_DATA + for ((label, seg) in segmentsForRows) { + for ((idx, t) in seg.withIndex()) { + val colA = if (idx == 0) label else "" + val dataRow = sheet.createRow(rowNum++) + dataRow.createCell(RouteLaneExcelSupport.COL_AREA_PLATE).setCellValue(colA) + dataRow.createCell(RouteLaneExcelSupport.COL_SHOP_NAME).setCellValue(t.shopName ?: "") + dataRow.createCell(RouteLaneExcelSupport.COL_BRAND) + .setCellValue(deriveBrandFromShopFullName(t.shop?.name)) + dataRow.createCell(RouteLaneExcelSupport.COL_SHOP_CODE).setCellValue(t.shopCode ?: "") + dataRow.createCell(RouteLaneExcelSupport.COL_SCHEDULE).setCellValue(t.remark ?: "") + dataRow.createCell(RouteLaneExcelSupport.COL_DEPARTURE_ROW).setCellValue( + formatDepartureForExcel(t.departureTime), + ) + } + } + RouteLaneExcelSupport.applyRouteLaneExportFinishing( + sheet, + wb, + RouteLaneExcelSupport.ROW_FIRST_DATA, + rowNum - 1, + ) + } + if (wb.numberOfSheets == 0) { + throw IllegalArgumentException("No lane data to export (check lane ids / empty lanes)") + } + wb.write(bos) + bos.toByteArray() + } finally { + wb.close() + } + } + } + + fun buildRouteReportFilename(): String { + val d = LocalDate.now().format(DateTimeFormatter.ofPattern("yyyy-MM-dd")) + return "車線Report_${d}.xlsx" + } + + private fun normalizeRemarkForLaneGroup(raw: String?): String { + val s = raw?.trim().orEmpty() + return s + } + + private fun laneKey(truckLanceCode: String?, remark: String?): Pair { + val code = truckLanceCode?.trim().orEmpty() + val rem = normalizeRemarkForLaneGroup(remark) + return code to rem + } + + /** + * 圖2:車線 Report(單一 sheet,每間物流公司一個水平區塊)。 + * laneIds 若不空,會限制只匯出指定 lane group(與前端 encodeLaneId 對應)。 + */ + open fun exportRouteReportExcelBytes( + laneIds: List, + preparedBy: String = "—", + ): ByteArray { + val wb = XSSFWorkbook() + return ByteArrayOutputStream().use { bos -> + try { + val trucksAll = truckRepository.findAllForRouteBoard() + val distinctLaneIds = laneIds.distinct().filter { it.isNotBlank() } + val decoded = + distinctLaneIds.mapNotNull { RouteLaneExcelSupport.decodeLaneId(it) } + if (distinctLaneIds.isNotEmpty() && decoded.isEmpty()) { + throw IllegalArgumentException("Invalid laneIds") + } + val filterKeys: Set> = + decoded + .map { (code, remark) -> + code.trim() to normalizeRemarkForLaneGroup(remark) + } + .toSet() + + val trucks = + if (filterKeys.isEmpty()) { + trucksAll + } else { + trucksAll.filter { t -> + val (c, r) = laneKey(t.truckLanceCode, t.remark) + filterKeys.contains(c to r) + } + } + + val coll = Collator.getInstance(Locale.forLanguageTag("zh-HK")).apply { + strength = Collator.TERTIARY + } + data class CompanyKey(val id: Long?, val name: String) + + val byCompanyId = trucks.groupBy { t -> + val id = t.logistic?.id + val nameRaw = t.logistic?.logisticName?.trim().orEmpty() + val name = if (nameRaw.isNotEmpty()) nameRaw else "未命名物流" + CompanyKey(id, name) + } + + val companies = byCompanyId.entries + .filter { it.value.isNotEmpty() } + .sortedWith { a, b -> + val c = coll.compare(a.key.name, b.key.name) + if (c != 0) c else (a.key.id ?: Long.MIN_VALUE).compareTo(b.key.id ?: Long.MIN_VALUE) + } + if (companies.isEmpty()) { + throw IllegalArgumentException("No lane data to export") + } + + val sheet = wb.createSheet(RouteReportExcelSupport.SHEET_NAME) + val st = RouteReportExcelSupport.buildStyles(wb) + + val today = LocalDate.now().format(DateTimeFormatter.ofPattern("yyyy-MM-dd")) + RouteReportExcelSupport.writeTitle( + sheet, + st, + "新車綫($today)更改車線", + "製表: $preparedBy", + companies.size, + ) + + val timeFmt = DateTimeFormatter.ofPattern("HH:mm") + + var blockIndex = 0 + for ((ck, list) in companies) { + val sorted = list.sortedWith( + compareBy( + { it.truckLanceCode?.trim().orEmpty() }, + { it.districtReference?.trim().orEmpty() }, + { it.loadingSequence ?: 0 }, + { it.id ?: 0L }, + ), + ) + + val metaTruck = sorted.first() + val logistic = metaTruck.logistic + val plate = logistic?.carPlate?.trim().orEmpty() + val driverName = logistic?.driverName?.trim().orEmpty() + val driverNumber = logistic?.driverNumber?.toString().orEmpty() + + // 先以 lane group(truckLanceCode + normalized remark)聚合,避免同 lane 因為某些 row 的 departureTime 為 null/不一致而被拆散 + data class LaneKey(val code: String, val remark: String) + data class LaneBucket( + val key: LaneKey, + val time: LocalTime, + val trucks: List, + ) + + val laneBuckets = sorted + .groupBy { t -> + val (c, r) = laneKey(t.truckLanceCode, t.remark) + LaneKey(c, r) + } + .entries + .map { (k, laneTrucks) -> + val laneTime = laneTrucks.firstNotNullOfOrNull { it.departureTime } ?: LocalTime.MIDNIGHT + LaneBucket(k, laneTime, laneTrucks) + } + + val timeGroups = laneBuckets + .groupBy { it.time } + .toSortedMap() + .map { (time, bucketsAtTime) -> + val lanes = bucketsAtTime + .sortedWith { a, b -> + val c1 = coll.compare(a.key.code, b.key.code) + if (c1 != 0) c1 else coll.compare(a.key.remark, b.key.remark) + } + .map { bucket -> + val laneLabel = + if (bucket.key.remark.isNotEmpty()) { + "${bucket.key.code}-${bucket.key.remark}" + } else { + bucket.key.code + }.ifEmpty { "—" } + + val districts = bucket.trucks + .groupBy { it.districtReference?.trim().orEmpty() } + .mapKeys { (k) -> if (k.isNotEmpty()) k else "未分類" } + .toList() + .sortedWith { a, b -> + if (a.first == "未分類") -1 + else if (b.first == "未分類") 1 + else coll.compare(a.first, b.first) + } + .map { (district, dTrucks) -> + val shops = dTrucks + .sortedWith(compareBy({ it.loadingSequence ?: 0 }, { it.id ?: 0L })) + .map { t -> + val name = t.shopName?.trim().takeUnless { it.isNullOrBlank() } + ?: t.shopCode?.trim().takeUnless { it.isNullOrBlank() } + ?: "—" + val brand = deriveBrandFromShopFullName(t.shop?.name).trim() + val code = t.shopCode?.trim().orEmpty() + val lines = ArrayList(3) + lines.add(name) + if (brand.isNotEmpty()) lines.add(brand) + if (code.isNotEmpty() && code != name) lines.add(code) + lines.joinToString("\n") + } + RouteReportExcelSupport.DistrictGroup(district, shops) + } + RouteReportExcelSupport.LaneGroup(laneLabel, districts) + } + RouteReportExcelSupport.TimeGroup(time.format(timeFmt), lanes) + } + + val distinctShopCount = sorted + .map { + val code = it.shopCode?.trim().orEmpty() + if (code.isNotEmpty()) "code:$code" else "id:${it.id}" + } + .toSet() + .size + + RouteReportExcelSupport.writeCompanyBlock( + sheet, + st, + blockIndex, + 1, + RouteReportExcelSupport.BlockMeta( + companyName = ck.name, + plate = plate, + driverName = driverName, + driverNumber = driverNumber, + ), + timeGroups, + distinctShopCount, + ) + blockIndex++ + } + + wb.write(bos) + bos.toByteArray() + } finally { + wb.close() + } + } + } + + data class ExportTruckLaneVersionReportInput( + val fromVersionId: Long, + val toVersionId: Long, + val preparedBy: String, + ) + + /** + * 匯出「版本 Log 車線報告」: + * - Sheet1:版本異動報告(高亮 + 文字說明) + * - 其餘 sheets:每車線一個 worksheet(MTMS_ROUTE_V1)— 內容來自 toVersion 快照 lines + */ + open fun exportTruckLaneVersionReportExcelBytes(input: ExportTruckLaneVersionReportInput): ByteArray { + val wb = XSSFWorkbook() + return ByteArrayOutputStream().use { bos -> + try { + if (input.fromVersionId == input.toVersionId) { + throw IllegalArgumentException("fromVersionId and toVersionId must be different") + } + val fromV = truckLaneVersionRepository.findByIdAndDeletedFalse(input.fromVersionId) + ?: throw IllegalArgumentException("Version not found: ${input.fromVersionId}") + val toV = truckLaneVersionRepository.findByIdAndDeletedFalse(input.toVersionId) + ?: throw IllegalArgumentException("Version not found: ${input.toVersionId}") + + // 避免 from/to 顛倒導致新增/刪除/移動語意反轉 + if (fromV.created != null && toV.created != null && fromV.created!!.isAfter(toV.created)) { + throw IllegalArgumentException("fromVersionId must be older than toVersionId") + } + + val fromLines = truckLaneVersionLineRepository + .findAllByTruckLaneVersion_IdAndDeletedFalseOrderByIdAsc(input.fromVersionId) + val toLines = truckLaneVersionLineRepository + .findAllByTruckLaneVersion_IdAndDeletedFalseOrderByIdAsc(input.toVersionId) + + // 大檔保護:避免 OOM / timeout + val maxLines = 80_000 + val maxLaneSheets = 300 + if (fromLines.size + toLines.size > maxLines) { + throw IllegalArgumentException("Snapshot too large to export (lines>${maxLines})") + } + + val logisticIds = (fromLines.mapNotNull { it.logisticId } + toLines.mapNotNull { it.logisticId }).toSet() + val logisticNameById = + if (logisticIds.isEmpty()) { + emptyMap() + } else { + logisticRepository.findAllById(logisticIds).associate { (it.id ?: -1L) to (it.logisticName ?: "") } + } + + val diffLines = buildVersionDiffLines(fromLines, toLines) + val summaryRows = buildSummaryRows(fromLines, toLines, diffLines, logisticNameById) + + val createdDate = (toV.created?.toString() ?: "").take(10) + val title = "車線報告(${createdDate.ifBlank { "—" }})更改車線" + val editor = (toV.modifiedBy ?: input.preparedBy).trim().ifBlank { input.preparedBy } + val created = toV.created?.toString() ?: "—" + + val stats = summarizeSummaryRows(summaryRows) + val statsText = + "新增 ${stats.added} · 移動 ${stats.moved} · 刪除 ${stats.deleted} · 欄位變更 ${stats.fieldChanged}" + + TruckLaneVersionReportExcelSupport.writeSummarySheet( + wb, + TruckLaneVersionReportExcelSupport.SummaryMeta( + title = title, + editor = editor, + created = created, + fromVersionId = input.fromVersionId, + toVersionId = input.toVersionId, + note = toV.note, + statsText = statsText, + ), + summaryRows, + ) + + // Sheet2:同正常車線報告版面,但把異動 row 高亮(資料來自 toVersion 快照) + val logisticsByIdForVersionReport = if (logisticIds.isEmpty()) emptyMap() else + logisticRepository.findAllById(logisticIds).associateBy { it.id ?: -1L } + appendVersionRouteReportSheet( + wb, + createdDate.ifBlank { "—" }, + editor, + toLines, + diffLines, + logisticNameById, + logisticsByIdForVersionReport, + ) + + // lane sheets (MTMS_ROUTE_V1) based on toLines snapshot + appendLaneSheetsFromSnapshotLines(wb, toLines, maxLaneSheets) + + wb.write(bos) + bos.toByteArray() + } finally { + wb.close() + } + } + } + + private data class SummaryStats( + val added: Int, + val moved: Int, + val deleted: Int, + val fieldChanged: Int, + ) + + private fun summarizeSummaryRows(rows: List): SummaryStats { + var added = 0 + var moved = 0 + var deleted = 0 + var fieldChanged = 0 + for (r in rows) { + when (r.type) { + TruckLaneVersionReportExcelSupport.RowType.ADDED -> added++ + TruckLaneVersionReportExcelSupport.RowType.MOVED -> moved++ + TruckLaneVersionReportExcelSupport.RowType.DELETED -> deleted++ + TruckLaneVersionReportExcelSupport.RowType.EDITED -> {} + } + if (r.changedFields.isNotEmpty()) fieldChanged++ + } + return SummaryStats(added, moved, deleted, fieldChanged) + } + + private fun laneLabel(code: String?, remark: String?): String { + val c = code?.trim().orEmpty() + val r = remark?.trim().orEmpty() + if (c.isEmpty() && r.isEmpty()) return "—" + if (r.isEmpty()) return c + return "$c-$r" + } + + private fun buildVersionDiffLines( + fromLines: List, + toLines: List, + ): List { + 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 = ArrayList() + + fun s(v: Any?): String? = v?.toString() + + for (key in allKeys) { + val a = fromByRow[key] + val b = toByRow[key] + val changes = ArrayList() + + 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, + ), + ) + } + } + return changed + } + + private val FIELD_LABEL = mapOf( + "departureTime" to "發車時段", + "loadingSequence" to "裝載順序", + "branchName" to "分店名稱", + "districtReference" to "區域", + "shopCode" to "店鋪代碼", + "storeId" to "樓層/店別", + "remark" to "備註", + "truckLanceCode" to "車線代碼", + "logisticId" to "物流公司", + ) + + private fun buildSummaryRows( + fromLines: List, + toLines: List, + diffs: List, + logisticNameById: Map, + ): List { + val fromByRow = fromLines.associateBy { it.truckRowId ?: -1 } + val toByRow = toLines.associateBy { it.truckRowId ?: -1 } + + fun shopName(line: com.ffii.fpsms.modules.pickOrder.entity.TruckLaneVersionLine?): String { + val n = line?.branchName?.trim().orEmpty() + return if (n.isNotEmpty()) n else (line?.shopCode?.trim().orEmpty().ifEmpty { "—" }) + } + + val out = ArrayList() + for (d in diffs) { + val a = fromByRow[d.truckRowId] + val b = toByRow[d.truckRowId] + val fromLane = laneLabel(a?.truckLanceCode, a?.remark) + val toLane = laneLabel(b?.truckLanceCode, b?.remark) + val fromEmpty = fromLane == "—" + val toEmpty = toLane == "—" + val type = + if (fromEmpty && !toEmpty) TruckLaneVersionReportExcelSupport.RowType.ADDED + else if (!fromEmpty && toEmpty) TruckLaneVersionReportExcelSupport.RowType.DELETED + else if (fromLane != toLane) TruckLaneVersionReportExcelSupport.RowType.MOVED + else TruckLaneVersionReportExcelSupport.RowType.EDITED + + val changedFields = d.changes + .map { it.field } + .filterNot { it == "truckLanceCode" || it == "remark" } + .toSet() + val textBits = ArrayList() + if (type == TruckLaneVersionReportExcelSupport.RowType.ADDED) { + textBits.add("新增到 $toLane") + } else if (type == TruckLaneVersionReportExcelSupport.RowType.DELETED) { + textBits.add("自 $fromLane 移除") + } else if (type == TruckLaneVersionReportExcelSupport.RowType.MOVED) { + textBits.add("由 $fromLane → $toLane") + } + for (c in d.changes) { + if (c.field == "truckLanceCode" || c.field == "remark") continue + val label = FIELD_LABEL[c.field] ?: c.field + val from = + if (c.field == "logisticId") { + val id = c.from?.trim()?.toLongOrNull() + val name = id?.let { logisticNameById[it] }?.trim().orEmpty() + name.ifEmpty { c.from?.trim().takeUnless { it.isNullOrBlank() } ?: "—" } + } else { + c.from?.trim().takeUnless { it.isNullOrBlank() } ?: "—" + } + val to = + if (c.field == "logisticId") { + val id = c.to?.trim()?.toLongOrNull() + val name = id?.let { logisticNameById[it] }?.trim().orEmpty() + name.ifEmpty { c.to?.trim().takeUnless { it.isNullOrBlank() } ?: "—" } + } else { + c.to?.trim().takeUnless { it.isNullOrBlank() } ?: "—" + } + if (from != to) textBits.add("$label:$from → $to") + } + + out.add( + TruckLaneVersionReportExcelSupport.SummaryRow( + type = type, + shopName = shopName(b ?: a), + shopCode = (b?.shopCode ?: a?.shopCode ?: "").trim(), + fromLane = fromLane, + toLane = toLane, + changeText = textBits.joinToString(";").ifEmpty { "欄位變更" }, + changedFields = changedFields, + ), + ) + } + return out + } + + private fun appendLaneSheetsFromSnapshotLines( + wb: XSSFWorkbook, + toLines: List, + maxLaneSheets: Int, + ) { + data class LaneKey(val code: String, val remark: String) + fun keyOf(l: com.ffii.fpsms.modules.pickOrder.entity.TruckLaneVersionLine): LaneKey { + val c = l.truckLanceCode?.trim().orEmpty() + val r = normalizeRemarkForLaneGroup(l.remark) + return LaneKey(c, r) + } + + val codes = toLines.mapNotNull { it.shopCode?.trim()?.takeIf { s -> s.isNotEmpty() } }.toSet() + val shopNameByCode = if (codes.isEmpty()) emptyMap() else + shopRepository.findAllByCodeInAndDeletedIsFalse(codes).associateBy({ it.code ?: "" }, { it.name ?: "" }) + + fun deriveBrandByShopCode(code: String?): String { + val c = code?.trim().orEmpty() + if (c.isEmpty()) return "" + val full = shopNameByCode[c].orEmpty() + return deriveBrandFromShopFullName(full) + } + + fun parseLocalTimeOrNull(raw: String?): LocalTime? = parseDepartureTime(raw) + + // group by lane + val byLane = toLines + .filter { !it.truckLanceCode.isNullOrBlank() } + .groupBy(::keyOf) + .entries + .sortedWith(compareBy({ it.key.code }, { it.key.remark })) + + if (byLane.size > maxLaneSheets) { + throw IllegalArgumentException("Too many lane sheets to export (>${maxLaneSheets})") + } + + for ((k, linesRaw) in byLane) { + val lines = linesRaw.sortedWith(compareBy({ it.loadingSequence ?: 0 }, { it.truckRowId ?: 0L }, { it.id ?: 0L })) + if (lines.isEmpty()) continue + val sheetName = RouteLaneExcelSupport.uniqueSheetName(wb, k.code, k.remark.ifEmpty { null }) + val sheet = wb.createSheet(sheetName) + + // marker/meta + var rr = sheet.createRow(RouteLaneExcelSupport.ROW_MARKER) + rr.createCell(RouteLaneExcelSupport.COL_META_A).setCellValue(RouteLaneExcelSupport.FORMAT_MARKER) + rr.createCell(RouteLaneExcelSupport.COL_META_B).setCellValue(k.code) + rr.createCell(RouteLaneExcelSupport.COL_META_C).setCellValue(k.remark) + + rr = sheet.createRow(RouteLaneExcelSupport.ROW_STORE) + rr.createCell(RouteLaneExcelSupport.COL_META_A).setCellValue("樓層") + rr.createCell(RouteLaneExcelSupport.COL_META_B).setCellValue(lines.first().storeId ?: "") + + rr = sheet.createRow(RouteLaneExcelSupport.ROW_DEPARTURE_DEFAULT) + rr.createCell(RouteLaneExcelSupport.COL_META_A).setCellValue("出車時間") + val deptDefault = parseLocalTimeOrNull(lines.firstNotNullOfOrNull { it.departureTime }) + rr.createCell(RouteLaneExcelSupport.COL_META_B).setCellValue(formatDepartureForExcel(deptDefault)) + + rr = sheet.createRow(RouteLaneExcelSupport.ROW_HEADER) + rr.createCell(RouteLaneExcelSupport.COL_AREA_PLATE).setCellValue("板") + rr.createCell(RouteLaneExcelSupport.COL_SHOP_NAME).setCellValue("店鋪名稱") + rr.createCell(RouteLaneExcelSupport.COL_BRAND).setCellValue("品牌") + rr.createCell(RouteLaneExcelSupport.COL_SHOP_CODE).setCellValue("店鋪編號") + rr.createCell(RouteLaneExcelSupport.COL_SCHEDULE).setCellValue("此店車期") + rr.createCell(RouteLaneExcelSupport.COL_DEPARTURE_ROW).setCellValue("出車時間") + + // segments by district changes in loading order + val segments = ArrayList>() + var cur = ArrayList() + var prev = "" + for (l in lines) { + val d = l.districtReference?.trim().orEmpty() + if (cur.isNotEmpty() && d != prev) { + segments.add(cur) + cur = ArrayList() + } + cur.add(l) + prev = d + } + if (cur.isNotEmpty()) segments.add(cur) + + val coll = Collator.getInstance(Locale.forLanguageTag("zh-HK")).apply { strength = Collator.TERTIARY } + val labeled = segments.map { seg -> + val d = seg.first().districtReference?.trim().orEmpty() + val label = if (d.isNotEmpty()) d else "未分類" + label to seg + }.sortedWith { a, b -> coll.compare(a.first, b.first) } + + var rowNum = RouteLaneExcelSupport.ROW_FIRST_DATA + for ((label, seg) in labeled) { + for ((idx, l) in seg.withIndex()) { + val colA = if (idx == 0) label else "" + val dataRow = sheet.createRow(rowNum++) + dataRow.createCell(RouteLaneExcelSupport.COL_AREA_PLATE).setCellValue(colA) + dataRow.createCell(RouteLaneExcelSupport.COL_SHOP_NAME).setCellValue(l.branchName ?: "") + dataRow.createCell(RouteLaneExcelSupport.COL_BRAND).setCellValue(deriveBrandByShopCode(l.shopCode)) + dataRow.createCell(RouteLaneExcelSupport.COL_SHOP_CODE).setCellValue(l.shopCode ?: "") + dataRow.createCell(RouteLaneExcelSupport.COL_SCHEDULE).setCellValue(l.remark ?: "") + val dept = parseLocalTimeOrNull(l.departureTime) ?: deptDefault + dataRow.createCell(RouteLaneExcelSupport.COL_DEPARTURE_ROW).setCellValue(formatDepartureForExcel(dept)) + } + } + + RouteLaneExcelSupport.applyRouteLaneExportFinishing( + sheet, + wb, + RouteLaneExcelSupport.ROW_FIRST_DATA, + rowNum - 1, + ) + } + } + + private fun appendVersionRouteReportSheet( + wb: XSSFWorkbook, + createdDate: String, + editor: String, + toLines: List, + diffLines: List, + logisticNameById: Map, + logisticsById: Map, + ) { + val diffByRowId = diffLines.associateBy { it.truckRowId } + val logisticChangeByRowId = diffLines.mapNotNull { d -> + d.changes.firstOrNull { it.field == "logisticId" }?.let { d.truckRowId to it } + }.toMap() + + val changedFieldsByRowId: Map> = diffLines.associate { d -> + d.truckRowId to d.changes.map { it.field }.toSet() + } + val changedRowIds = changedFieldsByRowId.keys.toSet() + + val shopCodes = toLines.mapNotNull { it.shopCode?.trim()?.takeIf { s -> s.isNotEmpty() } }.toSet() + val shopNameByCode = if (shopCodes.isEmpty()) emptyMap() else + shopRepository.findAllByCodeInAndDeletedIsFalse(shopCodes).associateBy({ it.code ?: "" }, { it.name ?: "" }) + + fun brandByShopCode(code: String?): String { + val c = code?.trim().orEmpty() + if (c.isEmpty()) return "" + return deriveBrandFromShopFullName(shopNameByCode[c].orEmpty()).trim() + } + + fun laneLabel(code: String?, remark: String?): String { + val c = code?.trim().orEmpty() + val r = remark?.trim().orEmpty() + if (c.isEmpty() && r.isEmpty()) return "—" + return if (r.isEmpty()) c else "$c-$r" + } + + fun timeLabel(raw: String?): String { + val t = parseDepartureTime(raw) ?: return "00:00" + return DateTimeFormatter.ofPattern("HH:mm").format(t) + } + + // group by logisticId + val linesWithLane = toLines.filter { !it.truckLanceCode.isNullOrBlank() } + data class CompanyKey(val id: Long?, val name: String) + val byCompany = linesWithLane.groupBy { l -> + val id = l.logisticId + val logi = if (id != null) logisticsById[id] else null + val name = logi?.logisticName?.trim().orEmpty().ifEmpty { "未命名物流" } + CompanyKey(id, name) + }.entries.sortedBy { it.key.name } + + val st = TruckLaneVersionRouteReportExcelSupport.buildStyles(wb) + val sheet = wb.createSheet(TruckLaneVersionRouteReportExcelSupport.SHEET_NAME) + TruckLaneVersionRouteReportExcelSupport.writeTitle( + sheet, + st, + "車線報告($createdDate)更改車線", + "製表: $editor", + byCompany.size.coerceAtLeast(1), + ) + + var blockIndex = 0 + for ((ck, list) in byCompany) { + val metaLogi = ck.id?.let { logisticsById[it] } + val plate = metaLogi?.carPlate?.trim().orEmpty() + val driverName = metaLogi?.driverName?.trim().orEmpty() + val driverNumber = metaLogi?.driverNumber?.toString().orEmpty() + + // lane buckets by (code+remark) to avoid split by inconsistent times + data class LaneKey(val code: String, val remark: String) + data class LaneBucket(val key: LaneKey, val time: String, val lines: List) + val laneBuckets = list.groupBy { l -> + LaneKey(l.truckLanceCode?.trim().orEmpty(), normalizeRemarkForLaneGroup(l.remark)) + }.map { (k, ls) -> + val t = ls.firstNotNullOfOrNull { it.departureTime } ?: "" + LaneBucket(k, timeLabel(t), ls) + } + + val timeGroups = laneBuckets.groupBy { it.time }.toSortedMap().map { (time, bucketsAtTime) -> + val lanes = bucketsAtTime + .sortedWith(compareBy({ it.key.code }, { it.key.remark })) + .map { bucket -> + val lane = laneLabel(bucket.key.code, bucket.key.remark) + val districts = bucket.lines + .groupBy { it.districtReference?.trim().orEmpty().ifEmpty { "未分類" } } + .toSortedMap { a, b -> + if (a == "未分類") -1 + else if (b == "未分類") 1 + else a.compareTo(b) + } + .map { (district, dLines) -> + val shops = dLines + .sortedWith(compareBy({ it.loadingSequence ?: 0 }, { it.truckRowId ?: 0L }, { it.id ?: 0L })) + .map { l -> + val id = l.truckRowId ?: -1L + val changed = changedRowIds.contains(id) + val name = l.branchName?.trim().orEmpty().ifEmpty { l.shopCode?.trim().orEmpty().ifEmpty { "—" } } + val brand = brandByShopCode(l.shopCode) + val code = l.shopCode?.trim().orEmpty() + val extra = changedFieldsByRowId[id]?.let { fields -> + val shown = fields + .filterNot { it == "truckLanceCode" || it == "remark" || it == "logisticId" } + .map { FIELD_LABEL[it] ?: it } + if (shown.isNotEmpty()) "變更: ${shown.joinToString(",")}" else null + } + val logisticChange = logisticChangeByRowId[id]?.let { ch -> + val fromName = ch.from?.trim()?.toLongOrNull() + ?.let { logisticNameById[it] } + ?.trim() + .orEmpty() + .ifEmpty { ch.from?.trim().orEmpty() } + .ifEmpty { "—" } + val toName = ch.to?.trim()?.toLongOrNull() + ?.let { logisticNameById[it] } + ?.trim() + .orEmpty() + .ifEmpty { ch.to?.trim().orEmpty() } + .ifEmpty { "—" } + if (fromName != toName) "物流公司:$fromName → $toName" else null + } + val linesText = ArrayList(4) + linesText.add(name) + if (brand.isNotEmpty()) linesText.add(brand) + if (code.isNotEmpty() && code != name) linesText.add(code) + if (!extra.isNullOrBlank()) linesText.add(extra) + if (!logisticChange.isNullOrBlank()) linesText.add(logisticChange) + TruckLaneVersionRouteReportExcelSupport.ShopRow( + truckRowId = id, + text = linesText.joinToString("\n"), + changed = changed, + ) + } + TruckLaneVersionRouteReportExcelSupport.DistrictGroup(district, shops) + } + TruckLaneVersionRouteReportExcelSupport.LaneGroup(lane, districts) + } + TruckLaneVersionRouteReportExcelSupport.TimeGroup(time, lanes) + } + + val distinctShopCount = list.mapNotNull { it.shopCode?.trim()?.takeIf { s -> s.isNotEmpty() } }.toSet().size + + TruckLaneVersionRouteReportExcelSupport.writeCompanyBlock( + sheet, + st, + blockIndex, + 1, + TruckLaneVersionRouteReportExcelSupport.BlockMeta( + companyName = ck.name, + plate = plate, + driverName = driverName, + driverNumber = driverNumber, + ), + timeGroups, + distinctShopCount, + ) + blockIndex++ + } + } + + /** Parse MTMS_ROUTE_V1 workbook without writing to DB (for staged import preview). */ + open fun parseRouteLanesExcel(workbook: Workbook?): ParseRouteLanesExcelResponse { + if (workbook == null) { + return ParseRouteLanesExcelResponse(0, 0, emptyList()) + } + var sheetsProcessed = 0 + val previewRows = mutableListOf() + for (si in 0 until workbook.numberOfSheets) { + val sheet = workbook.getSheetAt(si) + val row0 = sheet.getRow(RouteLaneExcelSupport.ROW_MARKER) ?: continue + val marker = ExcelUtils.getStringValue(row0.getCell(RouteLaneExcelSupport.COL_META_A)).trim() + if (marker != RouteLaneExcelSupport.FORMAT_MARKER) { + logger.warn("Skip sheet ${sheet.sheetName}: not ${RouteLaneExcelSupport.FORMAT_MARKER}") + continue + } + val truckLanceCode = + ExcelUtils.getStringValue(row0.getCell(RouteLaneExcelSupport.COL_META_B)).trim() + if (truckLanceCode.isEmpty()) { + logger.warn("Skip sheet ${sheet.sheetName}: empty truckLanceCode") + continue + } + val remarkCell = + ExcelUtils.getStringValue(row0.getCell(RouteLaneExcelSupport.COL_META_C)).trim() + val laneRemark = remarkCell.ifEmpty { null } + + val rowStore = sheet.getRow(RouteLaneExcelSupport.ROW_STORE) + val storeId = + ExcelUtils.getStringValue(rowStore?.getCell(RouteLaneExcelSupport.COL_META_B)).trim() + if (storeId.isEmpty()) { + logger.warn("Skip sheet ${sheet.sheetName}: empty store id") + continue + } + + val rowDeptRow = sheet.getRow(RouteLaneExcelSupport.ROW_DEPARTURE_DEFAULT) + val defaultDeptStr = + ExcelUtils.getStringValue(rowDeptRow?.getCell(RouteLaneExcelSupport.COL_META_B)).trim() + val defaultDept = parseDepartureTime(defaultDeptStr) + + var currentDistrict = "" + var seq = 1 + for (i in RouteLaneExcelSupport.ROW_FIRST_DATA..sheet.lastRowNum) { + val row = sheet.getRow(i) ?: continue + val c0 = + ExcelUtils.getStringValue(row.getCell(RouteLaneExcelSupport.COL_AREA_PLATE)).trim() + if (c0.isNotEmpty()) { + currentDistrict = c0 + } + val shopName = + ExcelUtils.getStringValue(row.getCell(RouteLaneExcelSupport.COL_SHOP_NAME)).trim() + val shopCodeRaw = + ExcelUtils.getStringValue(row.getCell(RouteLaneExcelSupport.COL_SHOP_CODE)).trim() + if (shopCodeRaw.isEmpty()) { + continue + } + + val scheduleRemark = + ExcelUtils.getStringValue(row.getCell(RouteLaneExcelSupport.COL_SCHEDULE)).trim() + val rowDeptStr = + ExcelUtils.getStringValue(row.getCell(RouteLaneExcelSupport.COL_DEPARTURE_ROW)).trim() + val departure = parseDepartureTime(rowDeptStr) ?: defaultDept + if (departure == null) { + logger.warn("Sheet ${sheet.sheetName} row ${i + 1}: skipped — invalid departure") + continue + } + + val normalizedShopCode = normalizeShopCode(shopCodeRaw) + val allShops = shopRepository.findAllByDeletedIsFalse() + val shop = allShops.firstOrNull { it.code == shopCodeRaw } + ?: allShops.firstOrNull { it.code == normalizedShopCode } + if (shop == null) { + logger.warn( + "Sheet ${sheet.sheetName} row ${i + 1}: no shop for code '$shopCodeRaw' (normalized '$normalizedShopCode')", + ) + continue + } + + val effectiveRemark = + if (storeId == "4F") { + when { + scheduleRemark.isNotEmpty() -> scheduleRemark + laneRemark != null && laneRemark.isNotEmpty() -> laneRemark + else -> null + } + } else { + null + } + + val existingTruck = + resolveExistingTruckForRouteLaneImport( + truckLanceCode.trim(), + storeId, + laneRemark, + shop.id!!, + shopCodeRaw, + normalizedShopCode, + ) + val logisticId = existingTruck?.logistic?.id + + previewRows.add( + RouteLaneImportPreviewRow( + truckRowId = existingTruck?.id, + truckLanceCode = truckLanceCode, + remark = effectiveRemark, + storeId = storeId, + departureTime = departure.toString(), + shopId = shop.id!!, + shopName = shopName.ifEmpty { shop.name ?: "" }, + shopCode = normalizedShopCode, + loadingSequence = seq, + districtReference = normalizeDistrictReferenceForRouteLaneImport(currentDistrict), + logisticId = logisticId, + ), + ) + seq++ + } + sheetsProcessed++ + } + return ParseRouteLanesExcelResponse( + sheetCount = sheetsProcessed, + rowCount = previewRows.size, + rows = previewRows, + ) + } + + private fun saveTruckFromImportPreview(row: RouteLaneImportPreviewRow) { + val departure = parseDepartureTime(row.departureTime) + ?: throw IllegalArgumentException("Invalid departureTime: ${row.departureTime}") + saveTruck( + SaveTruckRequest( + id = row.truckRowId, + store_id = row.storeId, + truckLanceCode = row.truckLanceCode, + departureTime = departure, + shopId = row.shopId, + shopName = row.shopName, + shopCode = row.shopCode, + loadingSequence = row.loadingSequence, + remark = row.remark, + districtReference = row.districtReference, + logisticId = row.logisticId, + ), + ) + } + + /** 不做單一大 transaction:逐列 [saveTruck] 各自提交,部分列失敗時前面仍保留 */ + open fun importRouteLanesExcel(workbook: Workbook?): String { + if (workbook == null) { + return "Import Excel failure" + } + val parsed = parseRouteLanesExcel(workbook) + for (row in parsed.rows) { + saveTruckFromImportPreview(row) + } + return "Import Excel success: ${parsed.sheetCount} sheet(s), ${parsed.rowCount} row(s)" } } \ No newline at end of file diff --git a/src/main/java/com/ffii/fpsms/modules/pickOrder/web/TruckController.kt b/src/main/java/com/ffii/fpsms/modules/pickOrder/web/TruckController.kt index 65e0694..9df44fa 100644 --- a/src/main/java/com/ffii/fpsms/modules/pickOrder/web/TruckController.kt +++ b/src/main/java/com/ffii/fpsms/modules/pickOrder/web/TruckController.kt @@ -7,7 +7,11 @@ import org.springframework.web.bind.ServletRequestBindingException import jakarta.servlet.http.HttpServletRequest import org.apache.poi.ss.usermodel.Workbook import org.apache.poi.xssf.usermodel.XSSFWorkbook +import org.springframework.http.ContentDisposition +import org.springframework.http.HttpHeaders +import org.springframework.http.MediaType import org.springframework.http.ResponseEntity +import java.nio.charset.StandardCharsets import org.springframework.web.bind.annotation.* import org.springframework.web.multipart.MultipartHttpServletRequest @@ -17,12 +21,19 @@ import com.ffii.fpsms.modules.pickOrder.web.models.UpdateTruckShopDetailsRequest import com.ffii.fpsms.modules.pickOrder.service.TruckService import com.ffii.fpsms.modules.pickOrder.entity.TruckRepository import com.ffii.fpsms.modules.pickOrder.web.models.SaveTruckLane +import com.ffii.fpsms.modules.pickOrder.web.models.TruckLaneCombinationResponse +import com.ffii.fpsms.modules.pickOrder.web.models.ExportRouteLanesRequest +import com.ffii.fpsms.modules.pickOrder.web.models.ExportRouteReportRequest +import com.ffii.fpsms.modules.pickOrder.web.models.ExportTruckLaneVersionReportExcelRequest +import com.ffii.fpsms.modules.pickOrder.web.models.UpdateLaneLogisticRequest +import com.ffii.fpsms.modules.pickOrder.web.models.ParseRouteLanesExcelResponse import com.ffii.fpsms.modules.pickOrder.web.models.deleteTruckLane +import com.ffii.fpsms.modules.pickOrder.web.models.toLaneCombinationResponse import jakarta.validation.Valid @RestController @RequestMapping("/truck") -class TruckController( +open class TruckController( private val truckService: TruckService, private val truckRepository: TruckRepository, ) { @@ -80,6 +91,142 @@ class TruckController( } } + /** + * PDF 圖1:多車線匯出;每個 laneId(encodeLaneId)一個 worksheet,格式 MTMS_ROUTE_V1。 + */ + @PostMapping( + "/exportRouteLanesExcel", + consumes = [MediaType.APPLICATION_JSON_VALUE], + produces = ["application/vnd.openxmlformats-officedocument.spreadsheetml.sheet"], + ) + fun exportRouteLanesExcel(@RequestBody req: ExportRouteLanesRequest): ResponseEntity { + 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 { + 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 { + 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 { + var workbook: Workbook? = null + try { + val multipartFile = (request as MultipartHttpServletRequest).getFile("multipartFileList") + workbook = XSSFWorkbook(multipartFile?.inputStream) + return ResponseEntity.ok(truckService.parseRouteLanesExcel(workbook)) + } catch (e: Exception) { + println("Error reading Excel file: ${e.message}") + return ResponseEntity.badRequest().body( + ParseRouteLanesExcelResponse(0, 0, emptyList()), + ) + } finally { + try { + workbook?.close() + } catch (_: Exception) { + } + } + } + + /** 與 [exportRouteLanesExcel] 同一格式;一個檔案內多 sheet,每 sheet 一條車線。 */ + @PostMapping("/importRouteLanesExcel") + @Throws(ServletRequestBindingException::class) + fun importRouteLanesExcel(request: HttpServletRequest): ResponseEntity<*> { + var workbook: Workbook? = null + try { + val multipartFile = (request as MultipartHttpServletRequest).getFile("multipartFileList") + workbook = XSSFWorkbook(multipartFile?.inputStream) + } catch (e: Exception) { + println("Error reading Excel file: ${e.message}") + return ResponseEntity.badRequest().body( + MessageResponse( + id = null, + name = null, + code = null, + type = "truck", + message = "Error reading Excel file: ${e.message}", + errorPosition = null, + entity = null, + ), + ) + } + try { + val result = truckService.importRouteLanesExcel(workbook) + return ResponseEntity.ok( + MessageResponse( + id = null, + name = null, + code = null, + type = "truck", + message = result, + errorPosition = null, + entity = null, + ), + ) + } finally { + try { + workbook?.close() + } catch (_: Exception) { + } + } + } + @PostMapping("/importExcel") @Throws(ServletRequestBindingException::class) fun importExcel(request: HttpServletRequest): ResponseEntity<*> { @@ -103,18 +250,25 @@ class TruckController( ) } - val result = truckService.importExcel(workbook) - return ResponseEntity.ok( - MessageResponse( - id = null, - name = null, - code = null, - type = "truck", - message = result, - errorPosition = null, - entity = null + try { + val result = truckService.importExcel(workbook) + return ResponseEntity.ok( + MessageResponse( + id = null, + name = null, + code = null, + type = "truck", + message = result, + errorPosition = null, + entity = null + ) ) - ) + } finally { + try { + workbook?.close() + } catch (_: Exception) { + } + } } @GetMapping("/findTruckLane/{shopId}") @@ -136,7 +290,7 @@ class TruckController( type = "truck", message = if (truck != null) "Truck lane updated successfully" else "Truck lane not found", errorPosition = null, - entity = truck + entity = null ) } catch (e: Exception) { return MessageResponse( @@ -151,6 +305,32 @@ class TruckController( } } + @PostMapping("/updateLaneLogistic") + fun updateLaneLogistic(@Valid @RequestBody request: UpdateLaneLogisticRequest): MessageResponse { + try { + val n = truckService.updateLogisticForEntireLane(request) + return MessageResponse( + id = null, + name = null, + code = request.truckLanceCode, + type = "truck", + message = "Updated logistic for $n truck row(s)", + errorPosition = null, + entity = null, + ) + } catch (e: Exception) { + return MessageResponse( + id = null, + name = null, + code = null, + type = "truck", + message = "Error: ${e.message}", + errorPosition = null, + entity = null, + ) + } + } + @PostMapping("/deleteTruckLane") fun deleteTruckLane(@Valid @RequestBody request: deleteTruckLane): MessageResponse { try { @@ -178,8 +358,10 @@ class TruckController( } @GetMapping("/findAllUniqueTruckLanceCodeAndRemarkCombinations") - fun findAllUniqueTruckLanceCodeAndRemarkCombinations(): List { - return truckService.findAllUniqueTruckLanceCodeAndRemarkCombinations() + fun findAllUniqueTruckLanceCodeAndRemarkCombinations(): List { + return truckService + .findAllUniqueTruckLanceCodeAndRemarkCombinations() + .map { it.toLaneCombinationResponse() } } @@ -193,6 +375,27 @@ class TruckController( return truckService.findAllByTruckLanceCodeAndDeletedFalse(truckLanceCode) } + /** + * Filter trucks by the same (truckLanceCode, remark) group as the unique-combinations query. + * Omit `remark` or pass empty for rows with NULL/empty remark. + */ + @GetMapping("/findAllByTruckLanceCodeAndRemarkAndDeletedFalse") + fun findAllByTruckLanceCodeAndRemarkAndDeletedFalse( + @RequestParam truckLanceCode: String, + @RequestParam(required = false) remark: String?, + ): List { + 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 { + return truckService.findAllForRouteBoard() + } + @GetMapping("/findAllUniqueShopNamesAndCodesFromTrucks") fun findAllUniqueShopNamesAndCodesFromTrucks(): List> { return truckService.findAllUniqueShopNamesAndCodesFromTrucks() diff --git a/src/main/java/com/ffii/fpsms/modules/pickOrder/web/TruckLaneVersionController.kt b/src/main/java/com/ffii/fpsms/modules/pickOrder/web/TruckLaneVersionController.kt new file mode 100644 index 0000000..90eba64 --- /dev/null +++ b/src/main/java/com/ffii/fpsms/modules/pickOrder/web/TruckLaneVersionController.kt @@ -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 { + 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 { + 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 { + val msg = truckLaneVersionService.restore(versionId) + return ResponseEntity.ok( + MessageResponse( + id = null, + name = null, + code = null, + type = "OK", + message = msg, + errorPosition = null, + entity = null, + ) + ) + } +} + diff --git a/src/main/java/com/ffii/fpsms/modules/pickOrder/web/models/ExportRouteLanesRequest.kt b/src/main/java/com/ffii/fpsms/modules/pickOrder/web/models/ExportRouteLanesRequest.kt new file mode 100644 index 0000000..9ce0111 --- /dev/null +++ b/src/main/java/com/ffii/fpsms/modules/pickOrder/web/models/ExportRouteLanesRequest.kt @@ -0,0 +1,5 @@ +package com.ffii.fpsms.modules.pickOrder.web.models + +data class ExportRouteLanesRequest( + val laneIds: List, +) diff --git a/src/main/java/com/ffii/fpsms/modules/pickOrder/web/models/ExportRouteReportRequest.kt b/src/main/java/com/ffii/fpsms/modules/pickOrder/web/models/ExportRouteReportRequest.kt new file mode 100644 index 0000000..cb70349 --- /dev/null +++ b/src/main/java/com/ffii/fpsms/modules/pickOrder/web/models/ExportRouteReportRequest.kt @@ -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 = emptyList(), +) + diff --git a/src/main/java/com/ffii/fpsms/modules/pickOrder/web/models/ExportTruckLaneVersionReportExcelRequest.kt b/src/main/java/com/ffii/fpsms/modules/pickOrder/web/models/ExportTruckLaneVersionReportExcelRequest.kt new file mode 100644 index 0000000..40949d1 --- /dev/null +++ b/src/main/java/com/ffii/fpsms/modules/pickOrder/web/models/ExportTruckLaneVersionReportExcelRequest.kt @@ -0,0 +1,7 @@ +package com.ffii.fpsms.modules.pickOrder.web.models + +data class ExportTruckLaneVersionReportExcelRequest( + val fromVersionId: Long, + val toVersionId: Long, +) + diff --git a/src/main/java/com/ffii/fpsms/modules/pickOrder/web/models/ParseRouteLanesExcelModels.kt b/src/main/java/com/ffii/fpsms/modules/pickOrder/web/models/ParseRouteLanesExcelModels.kt new file mode 100644 index 0000000..e830810 --- /dev/null +++ b/src/main/java/com/ffii/fpsms/modules/pickOrder/web/models/ParseRouteLanesExcelModels.kt @@ -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, +) diff --git a/src/main/java/com/ffii/fpsms/modules/pickOrder/web/models/SaveTruckRequest.kt b/src/main/java/com/ffii/fpsms/modules/pickOrder/web/models/SaveTruckRequest.kt index b96e33c..7e59339 100644 --- a/src/main/java/com/ffii/fpsms/modules/pickOrder/web/models/SaveTruckRequest.kt +++ b/src/main/java/com/ffii/fpsms/modules/pickOrder/web/models/SaveTruckRequest.kt @@ -1,4 +1,5 @@ package com.ffii.fpsms.modules.pickOrder.web.models +import jakarta.validation.constraints.NotBlank import java.time.LocalTime data class SaveTruckRequest( val id: Long? = null, @@ -11,6 +12,7 @@ data class SaveTruckRequest( val loadingSequence: Int, val remark: String? = null, val districtReference: String? = null, + val logisticId: Long? = null, ) data class SaveTruckLane( val id: Long, @@ -19,7 +21,10 @@ data class SaveTruckLane( val loadingSequence: Long, val districtReference: String?, val storeId: String, - val remark: String? = null + val remark: String? = null, + val logisticId: Long? = null, + /** When true, apply [logisticId] (including null to clear); when false, leave truck.logistic unchanged. */ + val updateLogistic: Boolean = false, ) data class deleteTruckLane( val id: Long @@ -39,4 +44,13 @@ data class CreateTruckWithoutShopRequest( val loadingSequence: Int = 0, val districtReference: String? = null, val remark: String? = null, + val logisticId: Long? = null, +) + +/** 單一 transaction 更新同 (truckLanceCode, remark) 桶內所有 truck 的 logistic。 */ +data class UpdateLaneLogisticRequest( + @field:NotBlank + val truckLanceCode: String, + val remark: String? = null, + val logisticId: Long? = null, ) diff --git a/src/main/java/com/ffii/fpsms/modules/pickOrder/web/models/TruckLaneCombinationResponse.kt b/src/main/java/com/ffii/fpsms/modules/pickOrder/web/models/TruckLaneCombinationResponse.kt new file mode 100644 index 0000000..35e4e09 --- /dev/null +++ b/src/main/java/com/ffii/fpsms/modules/pickOrder/web/models/TruckLaneCombinationResponse.kt @@ -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, + ) +} diff --git a/src/main/java/com/ffii/fpsms/modules/pickOrder/web/models/TruckLaneVersionModels.kt b/src/main/java/com/ffii/fpsms/modules/pickOrder/web/models/TruckLaneVersionModels.kt new file mode 100644 index 0000000..7dfe507 --- /dev/null +++ b/src/main/java/com/ffii/fpsms/modules/pickOrder/web/models/TruckLaneVersionModels.kt @@ -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, +) + +/** 物流主檔異動(版本區間內新增/修改;不依賴 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, + val logisticMasterChanges: List = emptyList(), +) + diff --git a/src/main/java/com/ffii/fpsms/modules/pickOrder/web/models/UpdateTruckLaneVersionNoteRequest.kt b/src/main/java/com/ffii/fpsms/modules/pickOrder/web/models/UpdateTruckLaneVersionNoteRequest.kt new file mode 100644 index 0000000..219c085 --- /dev/null +++ b/src/main/java/com/ffii/fpsms/modules/pickOrder/web/models/UpdateTruckLaneVersionNoteRequest.kt @@ -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, +) diff --git a/src/main/resources/db/changelog/changes/20260430_02_2fi/01_truck_lane_version_snapshot.sql b/src/main/resources/db/changelog/changes/20260430_02_2fi/01_truck_lane_version_snapshot.sql new file mode 100644 index 0000000..4571ba9 --- /dev/null +++ b/src/main/resources/db/changelog/changes/20260430_02_2fi/01_truck_lane_version_snapshot.sql @@ -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; diff --git a/src/main/resources/db/changelog/changes/20260430_02_2fi/02_truck_lane_version_snapshot_patch.sql b/src/main/resources/db/changelog/changes/20260430_02_2fi/02_truck_lane_version_snapshot_patch.sql new file mode 100644 index 0000000..b79fb6c --- /dev/null +++ b/src/main/resources/db/changelog/changes/20260430_02_2fi/02_truck_lane_version_snapshot_patch.sql @@ -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; diff --git a/src/main/resources/db/changelog/changes/20260504_01_2fi/01_truck_add_logistic_id.sql b/src/main/resources/db/changelog/changes/20260504_01_2fi/01_truck_add_logistic_id.sql new file mode 100644 index 0000000..5b5e68f --- /dev/null +++ b/src/main/resources/db/changelog/changes/20260504_01_2fi/01_truck_add_logistic_id.sql @@ -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`); diff --git a/src/main/resources/db/changelog/changes/20260505_01_2fi/01_truck_lane_version_drop_store_id.sql b/src/main/resources/db/changelog/changes/20260505_01_2fi/01_truck_lane_version_drop_store_id.sql new file mode 100644 index 0000000..aa20c43 --- /dev/null +++ b/src/main/resources/db/changelog/changes/20260505_01_2fi/01_truck_lane_version_drop_store_id.sql @@ -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; diff --git a/src/main/resources/db/changelog/changes/20260507_01_2fi/01_truck_lane_version_line_add_logistic_id.sql b/src/main/resources/db/changelog/changes/20260507_01_2fi/01_truck_lane_version_line_add_logistic_id.sql new file mode 100644 index 0000000..072423e --- /dev/null +++ b/src/main/resources/db/changelog/changes/20260507_01_2fi/01_truck_lane_version_line_add_logistic_id.sql @@ -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; +