From 02a58538e697ef146f724d8d44ed8c57df119e6b Mon Sep 17 00:00:00 2001 From: "CANCERYS\\kw093" Date: Tue, 1 Sep 2026 11:28:17 +0800 Subject: [PATCH 01/10] update --- .../modules/master/service/ItemUomService.kt | 50 +++++++- .../stock/service/StockInLineService.kt | 27 +++++ .../stock/service/StockQtyRoundSupport.kt | 109 ++++++++++++++++++ .../stock/web/model/SaveStockInRequest.kt | 8 +- .../stock/service/StockQtyRoundSupportTest.kt | 82 +++++++++++++ 5 files changed, 269 insertions(+), 7 deletions(-) create mode 100644 src/main/java/com/ffii/fpsms/modules/stock/service/StockQtyRoundSupport.kt create mode 100644 src/test/kotlin/com/ffii/fpsms/modules/stock/service/StockQtyRoundSupportTest.kt diff --git a/src/main/java/com/ffii/fpsms/modules/master/service/ItemUomService.kt b/src/main/java/com/ffii/fpsms/modules/master/service/ItemUomService.kt index 275b80c5..b005670b 100644 --- a/src/main/java/com/ffii/fpsms/modules/master/service/ItemUomService.kt +++ b/src/main/java/com/ffii/fpsms/modules/master/service/ItemUomService.kt @@ -104,6 +104,9 @@ open class ItemUomService( open fun convertPurchaseQtyToStockQty(itemId: Long, purchaseQty: BigDecimal): BigDecimal { val purchaseUnit = findPurchaseUnitByItemId(itemId) ?: return purchaseQty; val stockUnit = findStockUnitByItemId(itemId) ?: return purchaseQty; + if (sameUomId(purchaseUnit, stockUnit)) { + return purchaseQty.setScale(0, RoundingMode.UP) + } val one = BigDecimal.ONE; // IMPORTANT: @@ -129,6 +132,9 @@ open class ItemUomService( open fun convertPurchaseQtyToStockQtyRoundDown(itemId: Long, purchaseQty: BigDecimal): BigDecimal { val purchaseUnit = findPurchaseUnitByItemId(itemId) ?: return purchaseQty val stockUnit = findStockUnitByItemId(itemId) ?: return purchaseQty + if (sameUomId(purchaseUnit, stockUnit)) { + return purchaseQty.setScale(0, RoundingMode.DOWN) + } val one = BigDecimal.ONE // Use high precision for intermediate steps, and round down only at the end. @@ -149,6 +155,9 @@ open class ItemUomService( open fun convertStockQtyToPurchaseQty(itemId: Long, stockQty: BigDecimal): BigDecimal { val purchaseUnit = findPurchaseUnitByItemId(itemId) ?: return stockQty val stockUnit = findStockUnitByItemId(itemId) ?: return stockQty + if (sameUomId(purchaseUnit, stockUnit)) { + return stockQty.setScale(0, RoundingMode.UP) + } val one = BigDecimal.ONE val calcScale = 10 @@ -168,8 +177,11 @@ open class ItemUomService( * Returns source qty when source/purchase unit mapping is not found. */ open fun convertQtyToPurchaseQty(itemId: Long, uomId: Long, sourceQty: BigDecimal): BigDecimal { - val sourceItemUom = findFirstByItemIdAndUomId(itemId, uomId) ?: return sourceQty val purchaseUnit = findPurchaseUnitByItemId(itemId) ?: return sourceQty + if (sameUomId(uomId, purchaseUnit)) { + return sourceQty.setScale(0, RoundingMode.UP) + } + val sourceItemUom = findFirstByItemIdAndUomId(itemId, uomId) ?: return sourceQty val one = BigDecimal.ONE val calcScale = 10 @@ -185,9 +197,12 @@ open class ItemUomService( } open fun convertQtyToStockQty(itemId: Long, uomId: Long, sourceQty: BigDecimal): BigDecimal { - val itemUom = findFirstByItemIdAndUomId(itemId, uomId) ?: return sourceQty; - - val stockUnit = findStockUnitByItemId(itemId) ?: return BigDecimal.ZERO; + val stockUnit = findStockUnitByItemId(itemId) + if (stockUnit != null && sameUomId(uomId, stockUnit)) { + return sourceQty.setScale(0, RoundingMode.UP) + } + val itemUom = findFirstByItemIdAndUomId(itemId, uomId) ?: return sourceQty + if (stockUnit == null) return BigDecimal.ZERO val one = BigDecimal.ONE; val calcScale = 10 @@ -208,8 +223,12 @@ open class ItemUomService( * Same as convertQtyToStockQty but rounds down the final stock qty to integer. */ open fun convertQtyToStockQtyRoundDown(itemId: Long, uomId: Long, sourceQty: BigDecimal): BigDecimal { + val stockUnit = findStockUnitByItemId(itemId) + if (stockUnit != null && sameUomId(uomId, stockUnit)) { + return sourceQty.setScale(0, RoundingMode.DOWN) + } val itemUom = findFirstByItemIdAndUomId(itemId, uomId) ?: return sourceQty - val stockUnit = findStockUnitByItemId(itemId) ?: return BigDecimal.ZERO + if (stockUnit == null) return BigDecimal.ZERO val one = BigDecimal.ONE val calcScale = 10 @@ -224,6 +243,18 @@ open class ItemUomService( return stockQty.setScale(0, RoundingMode.DOWN) } + /** True when both item UOMs point at the same `uom_conversion` row. */ + private fun sameUomId(left: ItemUom?, right: ItemUom?): Boolean { + val leftId = left?.uom?.id ?: return false + val rightId = right?.uom?.id ?: return false + return leftId == rightId + } + + private fun sameUomId(uomId: Long, unit: ItemUom?): Boolean { + val unitUomId = unit?.uom?.id ?: return false + return unitUomId == uomId + } + /** * Stock UOM code [PCS/CTN]* denotes pieces-per-carton; displayed stock qty should be whole cartons (integer). */ @@ -247,6 +278,9 @@ open class ItemUomService( open fun convertPurchaseQtyToStockQtyPrecise(itemId: Long, purchaseQty: BigDecimal): BigDecimal { val purchaseUnit = findPurchaseUnitByItemId(itemId) ?: return purchaseQty val stockUnit = findStockUnitByItemId(itemId) ?: return purchaseQty + if (sameUomId(purchaseUnit, stockUnit)) { + return finalizePreciseStockQty(stockUnit, purchaseQty) + } val one = BigDecimal.ONE val calcScale = 10 @@ -266,8 +300,12 @@ open class ItemUomService( * Used for PO (M18 UOM) conversion where round-down is not allowed. */ open fun convertQtyToStockQtyPrecise(itemId: Long, uomId: Long, sourceQty: BigDecimal): BigDecimal { + val stockUnit = findStockUnitByItemId(itemId) + if (stockUnit != null && sameUomId(uomId, stockUnit)) { + return finalizePreciseStockQty(stockUnit, sourceQty) + } val itemUom = findFirstByItemIdAndUomId(itemId, uomId) ?: return sourceQty - val stockUnit = findStockUnitByItemId(itemId) ?: return BigDecimal.ZERO + if (stockUnit == null) return BigDecimal.ZERO val one = BigDecimal.ONE val calcScale = 10 diff --git a/src/main/java/com/ffii/fpsms/modules/stock/service/StockInLineService.kt b/src/main/java/com/ffii/fpsms/modules/stock/service/StockInLineService.kt index fa4c1abc..8101446b 100644 --- a/src/main/java/com/ffii/fpsms/modules/stock/service/StockInLineService.kt +++ b/src/main/java/com/ffii/fpsms/modules/stock/service/StockInLineService.kt @@ -331,6 +331,31 @@ open class StockInLineService( latestCode = latestCode, ) } + + private fun applyRequestedStockQtyRound( + stockInLine: StockInLine, + request: SaveStockInLineRequest, + isCreate: Boolean, + ) { + if (request.stockQtyRoundMode.isNullOrBlank()) return + if (stockInLine.purchaseOrderLine == null) return + if (!isCreate) { + val status = stockInLine.status?.lowercase()?.trim() + if (status != StockInLineStatus.PENDING.status && status != StockInLineStatus.ESCALATED.status) { + return + } + } + val currentQty = stockInLine.acceptedQty ?: return + val (roundedQty, remarks) = StockQtyRoundSupport.applyIfRequested( + currentQty = currentQty, + currentRemarks = stockInLine.remarks ?: request.remarks, + request = request, + defaultSource = if (isCreate) "CREATE" else "QC", + ) + stockInLine.acceptedQty = roundedQty + stockInLine.remarks = remarks + } + @Throws(IOException::class) @Transactional open fun create(request: SaveStockInLineRequest): MessageResponse { @@ -449,6 +474,7 @@ open class StockInLineService( this.type = "NOR" status = StockInLineStatus.PENDING.status } + applyRequestedStockQtyRound(stockInLine, request, isCreate = true) if (jo != null) { val planStartDate = jo.planStart?.toLocalDate() stockInLine.lotNo = assignLotNoForJo(planStartDate) @@ -1030,6 +1056,7 @@ open class StockInLineService( this.expiryDate = stockInLine.expiryDate ?: request.expiryDate this.remarks = stockInLine.remarks ?: request.remarks } + applyRequestedStockQtyRound(stockInLine, request, isCreate = false) _logStep("apply_fields", _tApplyFields) if (request.status == StockInLineStatus.RECEIVED.status) { val _tPutAwayTotal = System.nanoTime() diff --git a/src/main/java/com/ffii/fpsms/modules/stock/service/StockQtyRoundSupport.kt b/src/main/java/com/ffii/fpsms/modules/stock/service/StockQtyRoundSupport.kt new file mode 100644 index 00000000..33093520 --- /dev/null +++ b/src/main/java/com/ffii/fpsms/modules/stock/service/StockQtyRoundSupport.kt @@ -0,0 +1,109 @@ +package com.ffii.fpsms.modules.stock.service + +import com.ffii.core.exception.BadRequestException +import com.ffii.fpsms.modules.common.SecurityUtils +import com.ffii.fpsms.modules.stock.web.model.SaveStockInLineRequest +import java.math.BigDecimal +import java.math.RoundingMode +import java.time.LocalDateTime +import java.time.format.DateTimeFormatter + +object StockQtyRoundSupport { + private val TAG_REGEX = Regex("""\[QTY_ROUND[^\]]*\]""") + private val AT_FORMAT = DateTimeFormatter.ofPattern("yyyy-MM-dd'T'HH:mm") + private val REMARKS_MAX = 500 + + enum class Mode { + HALF_UP, + CEILING, + FLOOR, + CUSTOM, + } + + fun isNotInteger(qty: BigDecimal): Boolean { + return qty.stripTrailingZeros().scale() > 0 + } + + fun applyIfRequested( + currentQty: BigDecimal, + currentRemarks: String?, + request: SaveStockInLineRequest, + defaultSource: String, + ): Pair { + val mode = parseMode(request.stockQtyRoundMode) ?: return currentQty to currentRemarks + if (!isNotInteger(currentQty)) { + return currentQty to currentRemarks + } + val after = roundQty(currentQty, mode, request.stockQtyCustomQty) + val source = request.stockQtyRoundSource?.trim()?.ifBlank { null } ?: defaultSource + val tag = buildTag( + mode = mode, + before = currentQty, + after = after, + source = source, + ) + val withReason = if (mode == Mode.CUSTOM) { + val reason = request.stockQtyCustomReason?.trim().orEmpty() + if (reason.isEmpty()) tag else "$tag 原因:$reason" + } else { + tag + } + return after to mergeRemark(currentRemarks, withReason) + } + + fun parseMode(raw: String?): Mode? { + val value = raw?.trim()?.uppercase() ?: return null + if (value.isEmpty()) return null + return try { + Mode.valueOf(value) + } catch (_: IllegalArgumentException) { + throw BadRequestException("Invalid stockQtyRoundMode: $raw") + } + } + + fun roundQty(before: BigDecimal, mode: Mode, customQty: BigDecimal?): BigDecimal { + return when (mode) { + Mode.HALF_UP -> before.setScale(0, RoundingMode.HALF_UP) + Mode.CEILING -> before.setScale(0, RoundingMode.CEILING) + Mode.FLOOR -> before.setScale(0, RoundingMode.FLOOR) + Mode.CUSTOM -> { + val custom = customQty ?: throw BadRequestException("stockQtyCustomQty is required for CUSTOM") + if (custom <= BigDecimal.ZERO) { + throw BadRequestException("stockQtyCustomQty must be greater than 0") + } + if (isNotInteger(custom)) { + throw BadRequestException("stockQtyCustomQty must be an integer") + } + custom.setScale(0, RoundingMode.DOWN) + } + } + } + + fun buildTag( + mode: Mode, + before: BigDecimal, + after: BigDecimal, + source: String, + at: LocalDateTime = LocalDateTime.now(), + by: String = currentUsername(), + ): String { + return "[QTY_ROUND mode=${mode.name} before=${plain(before)} after=${plain(after)} source=$source by=${sanitize(by)} at=${at.format(AT_FORMAT)}]" + } + + fun mergeRemark(existing: String?, tag: String): String { + val stripped = existing?.replace(TAG_REGEX, "")?.trim().orEmpty() + val merged = if (stripped.isEmpty()) tag else "$tag $stripped" + return if (merged.length <= REMARKS_MAX) merged else merged.take(REMARKS_MAX) + } + + private fun currentUsername(): String { + val user = SecurityUtils.getUser().orElse(null) + return user?.username ?: user?.name ?: "unknown" + } + + private fun plain(qty: BigDecimal): String = qty.stripTrailingZeros().toPlainString() + + private fun sanitize(value: String): String { + return value.replace(Regex("""[\s\[\]]+"""), "_").ifBlank { "unknown" } + } +} diff --git a/src/main/java/com/ffii/fpsms/modules/stock/web/model/SaveStockInRequest.kt b/src/main/java/com/ffii/fpsms/modules/stock/web/model/SaveStockInRequest.kt index ff261531..e605552d 100644 --- a/src/main/java/com/ffii/fpsms/modules/stock/web/model/SaveStockInRequest.kt +++ b/src/main/java/com/ffii/fpsms/modules/stock/web/model/SaveStockInRequest.kt @@ -63,7 +63,13 @@ data class SaveStockInLineRequest( var escalationLog: SaveEscalationLogRequest? = null, var warehouseId: Long? = null, var rejectQty: BigDecimal? = null, - var inventoryLotLines: List? = null + var inventoryLotLines: List? = null, + /** HALF_UP / CEILING / FLOOR / CUSTOM. Null keeps current decimal stock qty. */ + var stockQtyRoundMode: String? = null, + var stockQtyCustomQty: BigDecimal? = null, + /** CREATE (PO submit) or QC (old pending line). */ + var stockQtyRoundSource: String? = null, + var stockQtyCustomReason: String? = null, ) data class SaveInventoryLotLineForSil ( diff --git a/src/test/kotlin/com/ffii/fpsms/modules/stock/service/StockQtyRoundSupportTest.kt b/src/test/kotlin/com/ffii/fpsms/modules/stock/service/StockQtyRoundSupportTest.kt new file mode 100644 index 00000000..944affc2 --- /dev/null +++ b/src/test/kotlin/com/ffii/fpsms/modules/stock/service/StockQtyRoundSupportTest.kt @@ -0,0 +1,82 @@ +package com.ffii.fpsms.modules.stock.service + +import com.ffii.fpsms.modules.stock.web.model.SaveStockInLineRequest +import org.junit.jupiter.api.Assertions.assertEquals +import org.junit.jupiter.api.Assertions.assertFalse +import org.junit.jupiter.api.Assertions.assertTrue +import org.junit.jupiter.api.Test +import java.math.BigDecimal +import java.time.LocalDateTime + +class StockQtyRoundSupportTest { + @Test + fun isNotInteger_detects_decimals() { + assertTrue(StockQtyRoundSupport.isNotInteger(BigDecimal("10.50"))) + assertFalse(StockQtyRoundSupport.isNotInteger(BigDecimal("10.00"))) + assertFalse(StockQtyRoundSupport.isNotInteger(BigDecimal("10"))) + } + + @Test + fun roundQty_halfUp_ceiling_floor() { + val before = BigDecimal("10.50") + assertEquals(0, StockQtyRoundSupport.roundQty(before, StockQtyRoundSupport.Mode.HALF_UP, null).compareTo(BigDecimal("11"))) + assertEquals(0, StockQtyRoundSupport.roundQty(before, StockQtyRoundSupport.Mode.CEILING, null).compareTo(BigDecimal("11"))) + assertEquals(0, StockQtyRoundSupport.roundQty(before, StockQtyRoundSupport.Mode.FLOOR, null).compareTo(BigDecimal("10"))) + assertEquals( + 0, + StockQtyRoundSupport.roundQty(before, StockQtyRoundSupport.Mode.CUSTOM, BigDecimal("12")).compareTo(BigDecimal("12")), + ) + } + + @Test + fun mergeRemark_replaces_previous_tag() { + val first = StockQtyRoundSupport.buildTag( + mode = StockQtyRoundSupport.Mode.FLOOR, + before = BigDecimal("10.50"), + after = BigDecimal("10"), + source = "CREATE", + at = LocalDateTime.of(2026, 8, 20, 17, 40), + by = "kw093", + ) + val mergedOnce = StockQtyRoundSupport.mergeRemark("舊備註", first) + val second = StockQtyRoundSupport.buildTag( + mode = StockQtyRoundSupport.Mode.CUSTOM, + before = BigDecimal("10.50"), + after = BigDecimal("12"), + source = "QC", + at = LocalDateTime.of(2026, 8, 20, 17, 45), + by = "kw093", + ) + val mergedTwice = StockQtyRoundSupport.mergeRemark(mergedOnce, second) + assertTrue(mergedTwice.startsWith("[QTY_ROUND mode=CUSTOM")) + assertTrue(mergedTwice.contains("舊備註")) + assertFalse(mergedTwice.contains("mode=FLOOR")) + } + + @Test + fun applyIfRequested_writes_custom_tag() { + val request = SaveStockInLineRequest( + itemId = 1, + acceptedQty = BigDecimal("10.50"), + acceptQty = null, + status = null, + expiryDate = null, + stockQtyRoundMode = "CUSTOM", + stockQtyCustomQty = BigDecimal("12"), + stockQtyRoundSource = "QC", + stockQtyCustomReason = "實收12箱", + ) + val (qty, remarks) = StockQtyRoundSupport.applyIfRequested( + currentQty = BigDecimal("10.50"), + currentRemarks = null, + request = request, + defaultSource = "QC", + ) + assertEquals(0, qty.compareTo(BigDecimal("12"))) + assertTrue(remarks!!.contains("mode=CUSTOM")) + assertTrue(remarks.contains("before=10.5")) + assertTrue(remarks.contains("after=12")) + assertTrue(remarks.contains("source=QC")) + assertTrue(remarks.contains("原因:實收12箱")) + } +} From a3710e2b1ec175f0bb68c4904e82382844aa00e5 Mon Sep 17 00:00:00 2001 From: Fai Luk Date: Sat, 10 Oct 2026 16:45:50 +0800 Subject: [PATCH 02/10] change the job order combo unit, from sales unit to stock unit --- .../ffii/fpsms/modules/master/entity/projections/BomCombo.kt | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/main/java/com/ffii/fpsms/modules/master/entity/projections/BomCombo.kt b/src/main/java/com/ffii/fpsms/modules/master/entity/projections/BomCombo.kt index 8f4bb249..efd9964d 100644 --- a/src/main/java/com/ffii/fpsms/modules/master/entity/projections/BomCombo.kt +++ b/src/main/java/com/ffii/fpsms/modules/master/entity/projections/BomCombo.kt @@ -7,7 +7,7 @@ interface BomCombo { val id: Long; @get:Value("#{target.id}") val value: Long; - @get:Value("#{target.code ?: ''} - #{target.name ?: ''} - #{target.item?.itemUoms?.^[salesUnit == true && deleted == false]?.uom?.udfudesc ?: ''}") + @get:Value("#{target.code ?: ''} - #{target.name ?: ''} - #{target.item?.itemUoms?.^[stockUnit == true && deleted == false]?.uom?.udfudesc ?: ''}") val label: String; val outputQty: BigDecimal; val outputQtyUom: String?; From f00a77980ae3461fe9d918794dda6aeca8e1bbb3 Mon Sep 17 00:00:00 2001 From: "CANCERYS\\kw093" Date: Wed, 14 Oct 2026 17:52:26 +0800 Subject: [PATCH 03/10] remark of merge round up/down --- .../ffii/fpsms/modules/stock/service/StockInLineService.kt | 3 +++ .../ffii/fpsms/modules/stock/service/StockQtyRoundSupport.kt | 5 +++++ .../ffii/fpsms/modules/stock/web/model/SaveStockInRequest.kt | 1 + 3 files changed, 9 insertions(+) diff --git a/src/main/java/com/ffii/fpsms/modules/stock/service/StockInLineService.kt b/src/main/java/com/ffii/fpsms/modules/stock/service/StockInLineService.kt index c8f72b7f..c3e0704e 100644 --- a/src/main/java/com/ffii/fpsms/modules/stock/service/StockInLineService.kt +++ b/src/main/java/com/ffii/fpsms/modules/stock/service/StockInLineService.kt @@ -333,6 +333,7 @@ open class StockInLineService( ) } + /** FP-MTMS Version Checklist | Functions Ref. No. 56 | v1.0.6 | 2026-10-14 */ private fun applyRequestedStockQtyRound( stockInLine: StockInLine, request: SaveStockInLineRequest, @@ -357,6 +358,7 @@ open class StockInLineService( stockInLine.remarks = remarks } + /** FP-MTMS Version Checklist | Functions Ref. No. 56 | v1.0.6 | 2026-10-14 */ @Throws(IOException::class) @Transactional open fun create(request: SaveStockInLineRequest): MessageResponse { @@ -975,6 +977,7 @@ open class StockInLineService( } } + /** FP-MTMS Version Checklist | Functions Ref. No. 56 | v1.0.6 | 2026-10-14 */ @Throws(IOException::class) @Transactional open fun update(request: SaveStockInLineRequest): MessageResponse { diff --git a/src/main/java/com/ffii/fpsms/modules/stock/service/StockQtyRoundSupport.kt b/src/main/java/com/ffii/fpsms/modules/stock/service/StockQtyRoundSupport.kt index 33093520..d7955c7e 100644 --- a/src/main/java/com/ffii/fpsms/modules/stock/service/StockQtyRoundSupport.kt +++ b/src/main/java/com/ffii/fpsms/modules/stock/service/StockQtyRoundSupport.kt @@ -24,6 +24,7 @@ object StockQtyRoundSupport { return qty.stripTrailingZeros().scale() > 0 } + /** FP-MTMS Version Checklist | Functions Ref. No. 56 | v1.0.6 | 2026-10-14 */ fun applyIfRequested( currentQty: BigDecimal, currentRemarks: String?, @@ -51,6 +52,7 @@ object StockQtyRoundSupport { return after to mergeRemark(currentRemarks, withReason) } + /** FP-MTMS Version Checklist | Functions Ref. No. 56 | v1.0.6 | 2026-10-14 */ fun parseMode(raw: String?): Mode? { val value = raw?.trim()?.uppercase() ?: return null if (value.isEmpty()) return null @@ -61,6 +63,7 @@ object StockQtyRoundSupport { } } + /** FP-MTMS Version Checklist | Functions Ref. No. 56 | v1.0.6 | 2026-10-14 */ fun roundQty(before: BigDecimal, mode: Mode, customQty: BigDecimal?): BigDecimal { return when (mode) { Mode.HALF_UP -> before.setScale(0, RoundingMode.HALF_UP) @@ -79,6 +82,7 @@ object StockQtyRoundSupport { } } + /** FP-MTMS Version Checklist | Functions Ref. No. 56 | v1.0.6 | 2026-10-14 */ fun buildTag( mode: Mode, before: BigDecimal, @@ -90,6 +94,7 @@ object StockQtyRoundSupport { return "[QTY_ROUND mode=${mode.name} before=${plain(before)} after=${plain(after)} source=$source by=${sanitize(by)} at=${at.format(AT_FORMAT)}]" } + /** FP-MTMS Version Checklist | Functions Ref. No. 56 | v1.0.6 | 2026-10-14 */ fun mergeRemark(existing: String?, tag: String): String { val stripped = existing?.replace(TAG_REGEX, "")?.trim().orEmpty() val merged = if (stripped.isEmpty()) tag else "$tag $stripped" diff --git a/src/main/java/com/ffii/fpsms/modules/stock/web/model/SaveStockInRequest.kt b/src/main/java/com/ffii/fpsms/modules/stock/web/model/SaveStockInRequest.kt index e605552d..d853ee5e 100644 --- a/src/main/java/com/ffii/fpsms/modules/stock/web/model/SaveStockInRequest.kt +++ b/src/main/java/com/ffii/fpsms/modules/stock/web/model/SaveStockInRequest.kt @@ -37,6 +37,7 @@ data class SaveStockInRequest( // val m18 ) +/** FP-MTMS Version Checklist | Functions Ref. No. 56 | v1.0.6 | 2026-10-14 */ data class SaveStockInLineRequest( var id: Long? = null, var stockInId: Long? = null, From 40dea3406fa9e61c0a53488544d5a489422e9848 Mon Sep 17 00:00:00 2001 From: tommy Date: Mon, 26 Oct 2026 17:24:32 +0800 Subject: [PATCH 04/10] trucklanecode rename --- .../pickOrder/entity/TruckLaneRenameLog.kt | 32 ++ .../entity/TruckLaneRenameLogRepository.kt | 9 + .../pickOrder/service/TruckLaneRenameRules.kt | 61 ++++ .../service/TruckLaneRenameService.kt | 336 ++++++++++++++++++ .../service/TruckLaneVersionService.kt | 26 ++ .../modules/pickOrder/web/TruckController.kt | 15 + .../web/models/TruckLaneRenameModels.kt | 43 +++ .../01_create_truck_lane_rename_log.sql | 23 ++ 8 files changed, 545 insertions(+) create mode 100644 src/main/java/com/ffii/fpsms/modules/pickOrder/entity/TruckLaneRenameLog.kt create mode 100644 src/main/java/com/ffii/fpsms/modules/pickOrder/entity/TruckLaneRenameLogRepository.kt create mode 100644 src/main/java/com/ffii/fpsms/modules/pickOrder/service/TruckLaneRenameRules.kt create mode 100644 src/main/java/com/ffii/fpsms/modules/pickOrder/service/TruckLaneRenameService.kt create mode 100644 src/main/java/com/ffii/fpsms/modules/pickOrder/web/models/TruckLaneRenameModels.kt create mode 100644 src/main/resources/db/changelog/changes/20261026_truck_lane_rename/01_create_truck_lane_rename_log.sql diff --git a/src/main/java/com/ffii/fpsms/modules/pickOrder/entity/TruckLaneRenameLog.kt b/src/main/java/com/ffii/fpsms/modules/pickOrder/entity/TruckLaneRenameLog.kt new file mode 100644 index 00000000..40acce88 --- /dev/null +++ b/src/main/java/com/ffii/fpsms/modules/pickOrder/entity/TruckLaneRenameLog.kt @@ -0,0 +1,32 @@ +package com.ffii.fpsms.modules.pickOrder.entity + +import com.ffii.core.entity.BaseEntity +import jakarta.persistence.Column +import jakarta.persistence.Entity +import jakarta.persistence.Table + +@Entity +@Table(name = "truck_lane_rename_log") +open class TruckLaneRenameLog : BaseEntity() { + @Column(name = "oldCode", nullable = false, length = 50) + open var oldCode: String = "" + + @Column(name = "newCode", nullable = false, length = 50) + open var newCode: String = "" + + @Column(name = "storeId", nullable = false, length = 10) + open var storeId: String = "" + + @Column(name = "remark", length = 255) + open var remark: String? = null + + /** A clock time, or `mixed` when rows in the bucket did not share one time. */ + @Column(name = "oldDepartureTime", length = 20) + open var oldDepartureTime: String? = null + + @Column(name = "newDepartureTime", length = 20) + open var newDepartureTime: String? = null + + @Column(name = "ticketCount", nullable = false) + open var ticketCount: Int = 0 +} diff --git a/src/main/java/com/ffii/fpsms/modules/pickOrder/entity/TruckLaneRenameLogRepository.kt b/src/main/java/com/ffii/fpsms/modules/pickOrder/entity/TruckLaneRenameLogRepository.kt new file mode 100644 index 00000000..773915ea --- /dev/null +++ b/src/main/java/com/ffii/fpsms/modules/pickOrder/entity/TruckLaneRenameLogRepository.kt @@ -0,0 +1,9 @@ +package com.ffii.fpsms.modules.pickOrder.entity + +import org.springframework.data.jpa.repository.JpaRepository +import org.springframework.stereotype.Repository + +@Repository +interface TruckLaneRenameLogRepository : JpaRepository { + fun findTop50ByDeletedFalseOrderByIdDesc(): List +} diff --git a/src/main/java/com/ffii/fpsms/modules/pickOrder/service/TruckLaneRenameRules.kt b/src/main/java/com/ffii/fpsms/modules/pickOrder/service/TruckLaneRenameRules.kt new file mode 100644 index 00000000..6db99169 --- /dev/null +++ b/src/main/java/com/ffii/fpsms/modules/pickOrder/service/TruckLaneRenameRules.kt @@ -0,0 +1,61 @@ +package com.ffii.fpsms.modules.pickOrder.service + +/** + * Bucket identity shared by the route board and the rename transaction. + * Remark is part of the key only on 4F. Comparison trims, and does not collapse inner spaces. + */ +object TruckLaneRenameRules { + const val RESERVED = "車線-X" + const val MAX_LEN = 50 + + data class BucketRef( + val code: String, + val floor: String, + val remarkKey: String, + ) + + fun floorOf(storeId: String?): String { + val compact = storeId?.trim()?.uppercase()?.replace(" ", "")?.replace("/", "") ?: "" + return if (compact == "4F") "4F" else "2F" + } + + /** Blank, null, and whitespace-only remarks share the empty key. 2F ignores remark. */ + fun remarkKey(floor: String, remark: String?): String { + if (floor != "4F") return "" + return remark?.trim().orEmpty() + } + + /** Null when the name is acceptable. Otherwise a stable error code. */ + fun validateNames(oldName: String, newName: String): String? { + val oldTrim = oldName.trim() + val newTrim = newName.trim() + if (oldTrim.isEmpty() || newTrim.isEmpty()) return "LANE_NAME_EMPTY" + if (newTrim.length > MAX_LEN) return "LANE_NAME_TOO_LONG" + if (oldTrim == RESERVED || newTrim == RESERVED) return "LANE_NAME_RESERVED" + return null + } + + /** + * Same-remark collision ignores case and excludes the source bucket. + * A different remark refuses only when the trimmed new name is exactly the same, + * because tickets have no remark and could not tell the buckets apart. + */ + fun collisionCode(source: BucketRef, newCode: String, live: List): String? { + val next = newCode.trim() + for (other in live) { + if (other.floor != source.floor) continue + val sameSource = other.remarkKey == source.remarkKey && + other.code.trim().equals(source.code.trim(), ignoreCase = true) + if (sameSource) continue + if (other.remarkKey == source.remarkKey && + other.code.trim().equals(next, ignoreCase = true) + ) { + return "LANE_NAME_TAKEN" + } + if (other.remarkKey != source.remarkKey && other.code.trim() == next) { + return "LANE_NAME_REMARK_AMBIGUOUS" + } + } + return null + } +} diff --git a/src/main/java/com/ffii/fpsms/modules/pickOrder/service/TruckLaneRenameService.kt b/src/main/java/com/ffii/fpsms/modules/pickOrder/service/TruckLaneRenameService.kt new file mode 100644 index 00000000..74e8e800 --- /dev/null +++ b/src/main/java/com/ffii/fpsms/modules/pickOrder/service/TruckLaneRenameService.kt @@ -0,0 +1,336 @@ +package com.ffii.fpsms.modules.pickOrder.service + +import com.ffii.core.support.JdbcDao +import com.ffii.fpsms.modules.pickOrder.entity.Truck +import com.ffii.fpsms.modules.pickOrder.entity.TruckLaneRenameLog +import com.ffii.fpsms.modules.pickOrder.entity.TruckLaneRenameLogRepository +import com.ffii.fpsms.modules.pickOrder.entity.TruckRepository +import com.ffii.fpsms.modules.pickOrder.web.models.PreviewTruckLaneRenameRequest +import com.ffii.fpsms.modules.pickOrder.web.models.PreviewTruckLaneRenameResponse +import com.ffii.fpsms.modules.pickOrder.web.models.RenameTruckLaneRequest +import com.ffii.fpsms.modules.pickOrder.web.models.RenameTruckLaneResponse +import com.ffii.fpsms.modules.pickOrder.web.models.TruckLaneRenameLogResponse +import org.springframework.http.HttpStatus +import org.springframework.stereotype.Service +import org.springframework.transaction.annotation.Transactional +import org.springframework.web.server.ResponseStatusException +import java.time.LocalTime +import java.time.format.DateTimeParseException + +@Service +open class TruckLaneRenameService( + private val jdbcDao: JdbcDao, + private val truckRepository: TruckRepository, + private val renameLogRepository: TruckLaneRenameLogRepository, +) { + @Transactional(readOnly = true) + open fun preview(req: PreviewTruckLaneRenameRequest): PreviewTruckLaneRenameResponse { + val code = req.truckLanceCode.trim() + if (code.isEmpty()) throw badRequest("LANE_NAME_EMPTY") + val floor = TruckLaneRenameRules.floorOf(req.storeId) + val remarkKey = TruckLaneRenameRules.remarkKey(floor, req.remark) + val params = ticketParams(code, floor, remarkKey) + return PreviewTruckLaneRenameResponse( + unfinishedCount = count(UNFINISHED_COUNT_SQL, params), + completedCount = count(COMPLETED_COUNT_SQL, params), + handlerNames = jdbcDao.queryForStrings(HANDLER_SQL, params) + .map { it.trim() } + .filter { it.isNotEmpty() } + .distinct(), + openScheduleCount = count(OPEN_SCHEDULE_COUNT_SQL, params), + ) + } + + @Transactional + open fun rename(req: RenameTruckLaneRequest): RenameTruckLaneResponse { + val oldCode = req.truckLanceCode.trim() + val newCode = req.replacementTruckLanceCode.trim() + val nameError = TruckLaneRenameRules.validateNames(oldCode, newCode) + if (nameError != null) throw badRequest(nameError) + val floor = TruckLaneRenameRules.floorOf(req.storeId) + val remarkKey = TruckLaneRenameRules.remarkKey(floor, req.remark) + val newTime = if (req.departureTimeChanged) parseTime(req.newDepartureTime) else null + if (req.departureTimeChanged && newTime == null) throw badRequest("LANE_TIME_INVALID") + + jdbcDao.queryForList( + "SELECT id FROM truck WHERE deleted = 0 FOR UPDATE", + emptyMap(), + ) + + val live = truckRepository.findAllForRouteBoard().filter { it.deleted != true } + val sourceRows = live.filter { inBucket(it, oldCode, floor, remarkKey) } + if (sourceRows.isEmpty()) throw badRequest("LANE_NOT_FOUND") + + val refs = liveBuckets(live) + val collision = TruckLaneRenameRules.collisionCode( + TruckLaneRenameRules.BucketRef(oldCode, floor, remarkKey), + newCode, + refs, + ) + if (collision != null) throw conflict(collision) + + val oldTimeLabel = describeTimes(sourceRows.map { it.departureTime }) + val casings = sourceRows.map { it.truckLanceCode?.trim().orEmpty() }.filter { it.isNotEmpty() }.distinct() + var scheduleCount = 0 + var ticketCount = 0 + for (casing in casings) { + scheduleCount += applyOpenScheduleRename(casing, newCode, floor, remarkKey) + val params = ticketParams(casing, floor, remarkKey).toMutableMap() + params["newCode"] = newCode + ticketCount += jdbcDao.executeUpdate( + updateTicketSql(DOPO_UPDATE_SQL, req.departureTimeChanged), + withTime(params, newTime), + ) + jdbcDao.executeUpdate( + updateTicketSql(PICK_UPDATE_SQL, req.departureTimeChanged), + withTime(params, newTime), + ) + } + for (row in sourceRows) { + row.truckLanceCode = newCode + if (req.departureTimeChanged) row.departureTime = newTime + } + truckRepository.saveAll(sourceRows) + + val log = TruckLaneRenameLog() + log.oldCode = oldCode + log.newCode = newCode + log.storeId = floor + log.remark = remarkKey.ifEmpty { null } + log.oldDepartureTime = oldTimeLabel + log.newDepartureTime = if (req.departureTimeChanged) newTime.toString() else oldTimeLabel + log.ticketCount = ticketCount + renameLogRepository.save(log) + + return RenameTruckLaneResponse( + truckLanceCode = newCode, + ticketCount = ticketCount, + scheduleLineCount = scheduleCount, + ) + } + + /** + * Restore writes master rows back to the snapshot name and does not move unfinished tickets. + * Open schedule from/to that still point at the current name are moved back here. + */ + @Transactional + open fun rewriteOpenScheduleNames( + oldCode: String, + newCode: String, + storeId: String?, + remark: String?, + ): Int { + val floor = TruckLaneRenameRules.floorOf(storeId) + val remarkKey = TruckLaneRenameRules.remarkKey(floor, remark) + return applyOpenScheduleRename(oldCode.trim(), newCode.trim(), floor, remarkKey) + } + + @Transactional(readOnly = true) + open fun recentLogs(): List = + renameLogRepository.findTop50ByDeletedFalseOrderByIdDesc().map { row -> + TruckLaneRenameLogResponse( + id = row.id ?: 0, + created = row.created?.toString(), + createdBy = row.createdBy, + oldCode = row.oldCode, + newCode = row.newCode, + storeId = row.storeId, + remark = row.remark, + oldDepartureTime = row.oldDepartureTime, + newDepartureTime = row.newDepartureTime, + ticketCount = row.ticketCount, + ) + } + + private fun applyOpenScheduleRename( + oldCode: String, + newCode: String, + floor: String, + remarkKey: String, + ): Int { + if (oldCode.isEmpty() || newCode.isEmpty() || oldCode == newCode) return 0 + val params = ticketParams(oldCode, floor, remarkKey).toMutableMap() + params["newCode"] = newCode + val matched = count(OPEN_SCHEDULE_COUNT_SQL, params) + jdbcDao.executeUpdate(SCHEDULE_FROM_SQL, params) + jdbcDao.executeUpdate(SCHEDULE_TO_SQL, params) + return matched + } + + private fun liveBuckets(live: List): List { + val grouped = linkedMapOf, String>() + for (row in live) { + val code = row.truckLanceCode?.trim().orEmpty() + if (code.isEmpty()) continue + val floor = TruckLaneRenameRules.floorOf(row.storeId) + val remarkKey = TruckLaneRenameRules.remarkKey(floor, row.remark) + val key = Triple(code.lowercase(), floor, remarkKey) + grouped.putIfAbsent(key, code) + } + return grouped.map { (key, code) -> + TruckLaneRenameRules.BucketRef(code, key.second, key.third) + } + } + + private fun inBucket(row: Truck, code: String, floor: String, remarkKey: String): Boolean { + val rowFloor = TruckLaneRenameRules.floorOf(row.storeId) + val rowRemark = TruckLaneRenameRules.remarkKey(rowFloor, row.remark) + return rowFloor == floor && + rowRemark == remarkKey && + row.truckLanceCode?.trim().orEmpty().equals(code, ignoreCase = true) + } + + private fun describeTimes(times: List): String { + val labels = times.map { it?.withNano(0)?.toString() ?: "" }.distinct() + return if (labels.size <= 1) labels.firstOrNull().orEmpty() else "mixed" + } + + private fun parseTime(raw: String?): LocalTime? { + val text = raw?.trim().orEmpty() + if (text.isEmpty()) return null + val normalized = if (text.length == 5) "$text:00" else text + return try { + LocalTime.parse(normalized) + } catch (_: DateTimeParseException) { + null + } + } + + private fun withTime(params: MutableMap, time: LocalTime?): Map { + if (time != null) params["newTime"] = time.toString() + return params + } + + private fun updateTicketSql(base: String, withTime: Boolean): String { + if (!withTime) return base + return base.replace("SET colCode = :newCode", "SET colCode = :newCode, colTime = :newTime") + .replace("SET TruckLanceCode = :newCode", "SET TruckLanceCode = :newCode, truck_departure_time = :newTime") + .replace("SET truckLanceCode = :newCode", "SET truckLanceCode = :newCode, truckDepartureTime = :newTime") + } + + private fun ticketParams(code: String, floor: String, remarkKey: String): Map = mapOf( + "oldCode" to code, + "floor" to floor, + "remark" to remarkKey, + "blankRemark" to if (remarkKey.isEmpty()) 1 else 0, + ) + + private fun count(sql: String, params: Map): Int { + val row = jdbcDao.queryForList(sql, params).firstOrNull() ?: return 0 + val value = row.values.firstOrNull() + return (value as? Number)?.toInt() ?: 0 + } + + private fun badRequest(code: String) = ResponseStatusException(HttpStatus.BAD_REQUEST, code) + + private fun conflict(code: String) = ResponseStatusException(HttpStatus.CONFLICT, code) + + companion object { + private const val FLOOR_MATCH = """ + ( + (:floor = '4F' AND UPPER(REPLACE(TRIM(storeCol), ' ', '')) IN ('4F', '4/F')) + OR (:floor <> '4F' AND UPPER(REPLACE(TRIM(storeCol), ' ', '')) IN ('2F', '2/F')) + ) + """ + + private const val REMARK_MATCH_4F = """ + ( + :floor <> '4F' + OR ( + (:blankRemark = 1 AND (remarkCol IS NULL OR TRIM(remarkCol) = '')) + OR (:blankRemark = 0 AND TRIM(remarkCol) = :remark) + ) + ) + """ + + private val UNFINISHED_COUNT_SQL = """ + SELECT COUNT(*) AS cnt + FROM delivery_order_pick_order + WHERE deleted = 0 + AND ticketStatus IN ('pending', 'released') + AND TRIM(truckLanceCode) = :oldCode + AND ${FLOOR_MATCH.replace("storeCol", "storeId")} + """.trimIndent() + + private val COMPLETED_COUNT_SQL = """ + SELECT COUNT(*) AS cnt + FROM delivery_order_pick_order + WHERE deleted = 0 + AND ticketStatus = 'completed' + AND TRIM(truckLanceCode) = :oldCode + AND ${FLOOR_MATCH.replace("storeCol", "storeId")} + """.trimIndent() + + private val HANDLER_SQL = """ + SELECT DISTINCT TRIM(handlerName) + FROM delivery_order_pick_order + WHERE deleted = 0 + AND ticketStatus IN ('pending', 'released') + AND handledBy IS NOT NULL + AND handlerName IS NOT NULL + AND TRIM(handlerName) <> '' + AND TRIM(truckLanceCode) = :oldCode + AND ${FLOOR_MATCH.replace("storeCol", "storeId")} + """.trimIndent() + + private val DOPO_UPDATE_SQL = """ + UPDATE delivery_order_pick_order + SET truckLanceCode = :newCode, version = version + 1 + WHERE deleted = 0 + AND ticketStatus IN ('pending', 'released') + AND TRIM(truckLanceCode) = :oldCode + AND ${FLOOR_MATCH.replace("storeCol", "storeId")} + """.trimIndent() + + private val PICK_UPDATE_SQL = """ + UPDATE do_pick_order + SET TruckLanceCode = :newCode, version = version + 1 + WHERE deleted = 0 + AND ticket_status IN ('pending', 'released') + AND TRIM(TruckLanceCode) = :oldCode + AND ${FLOOR_MATCH.replace("storeCol", "store_id")} + """.trimIndent() + + private val OPEN_SCHEDULE_COUNT_SQL = """ + SELECT COUNT(*) AS cnt + FROM truck_lane_schedule_line l + JOIN truck_lane_schedule s ON s.id = l.scheduleId + WHERE l.deleted = 0 AND s.deleted = 0 + AND s.status NOT IN ('APPLIED', 'CANCELLED') + AND ( + ( + TRIM(l.fromTruckLanceCode) = :oldCode + AND ${FLOOR_MATCH.replace("storeCol", "l.fromStoreId")} + AND ${REMARK_MATCH_4F.replace("remarkCol", "l.fromRemark")} + ) + OR ( + TRIM(l.toTruckLanceCode) = :oldCode + AND ${FLOOR_MATCH.replace("storeCol", "l.toStoreId")} + AND ${REMARK_MATCH_4F.replace("remarkCol", "l.toRemark")} + ) + ) + """.trimIndent() + + private val SCHEDULE_FROM_SQL = """ + UPDATE truck_lane_schedule_line l + JOIN truck_lane_schedule s ON s.id = l.scheduleId + SET l.fromTruckLanceCode = :newCode, l.version = l.version + 1 + WHERE l.deleted = 0 AND s.deleted = 0 + AND s.status NOT IN ('APPLIED', 'CANCELLED') + AND TRIM(l.fromTruckLanceCode) = :oldCode + AND ${FLOOR_MATCH.replace("storeCol", "l.fromStoreId")} + AND ${REMARK_MATCH_4F.replace("remarkCol", "l.fromRemark")} + """.trimIndent() + + private val SCHEDULE_TO_SQL = """ + UPDATE truck_lane_schedule_line l + JOIN truck_lane_schedule s ON s.id = l.scheduleId + SET l.toTruckLanceCode = :newCode, l.version = l.version + 1 + WHERE l.deleted = 0 AND s.deleted = 0 + AND s.status NOT IN ('APPLIED', 'CANCELLED') + AND TRIM(l.toTruckLanceCode) = :oldCode + AND ${FLOOR_MATCH.replace("storeCol", "l.toStoreId")} + AND ${REMARK_MATCH_4F.replace("remarkCol", "l.toRemark")} + """.trimIndent() + } +} 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 index c5623d2a..f120a522 100644 --- a/src/main/java/com/ffii/fpsms/modules/pickOrder/service/TruckLaneVersionService.kt +++ b/src/main/java/com/ffii/fpsms/modules/pickOrder/service/TruckLaneVersionService.kt @@ -16,6 +16,7 @@ open class TruckLaneVersionService( private val truckLaneVersionRepository: TruckLaneVersionRepository, private val truckLaneVersionLineRepository: TruckLaneVersionLineRepository, private val logisticRepository: LogisticRepository, + private val truckLaneRenameService: TruckLaneRenameService, ) { private fun toResponse(v: TruckLaneVersion): TruckLaneVersionResponse = TruckLaneVersionResponse( @@ -271,6 +272,7 @@ open class TruckLaneVersionService( } } + rewriteOpenSchedulesBackToSnapshot(version, lines) restoreRowsFromLines(version, lines) if (!skipPostSnapshot) { @@ -316,6 +318,7 @@ open class TruckLaneVersionService( return "No snapshot lines for affected lanes (versionId=$versionId)" } + rewriteOpenSchedulesBackToSnapshot(version, scopedLines) val fullSnapshotIds = allLines.mapNotNull { it.truckRowId }.filter { it > 0 }.toSet() for (lane in codes) { val currentLane = truckRepository.findAllByTruckLanceCodeAndDeletedFalse(lane) @@ -340,6 +343,29 @@ open class TruckLaneVersionService( return "Restored ${scopedLines.size} rows in ${codes.size} lane(s) from versionId=$versionId" } + /** + * Open schedule from/to that still use the live name are pointed back at the snapshot name. + * Unfinished delivery tickets are left on the current name. + */ + private fun rewriteOpenSchedulesBackToSnapshot( + version: TruckLaneVersion, + lines: List, + ) { + val ids = lines.mapNotNull { it.truckRowId }.filter { it > 0 }.distinct() + if (ids.isEmpty()) return + val trucks = truckRepository.findAllById(ids).associateBy { it.id } + val seen = HashSet() + for (line in lines) { + val truck = trucks[line.truckRowId] ?: continue + val before = truck.truckLanceCode?.trim().orEmpty() + val after = (line.truckLanceCode ?: version.truckLanceCode)?.trim().orEmpty() + if (before.isEmpty() || after.isEmpty() || before == after) continue + val key = "$before|$after|${truck.storeId}|${truck.remark}" + if (!seen.add(key)) continue + truckLaneRenameService.rewriteOpenScheduleNames(before, after, truck.storeId, truck.remark) + } + } + private fun restoreRowsFromLines( version: TruckLaneVersion, lines: List, 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 c4b4edb2..59022d4a 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 @@ -18,8 +18,11 @@ import org.springframework.web.multipart.MultipartHttpServletRequest 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.UpdateTruckShopDetailsRequest +import com.ffii.fpsms.modules.pickOrder.service.TruckLaneRenameService import com.ffii.fpsms.modules.pickOrder.service.TruckService import com.ffii.fpsms.modules.pickOrder.entity.TruckRepository +import com.ffii.fpsms.modules.pickOrder.web.models.PreviewTruckLaneRenameRequest +import com.ffii.fpsms.modules.pickOrder.web.models.RenameTruckLaneRequest 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 @@ -37,6 +40,7 @@ import jakarta.validation.Valid open class TruckController( private val truckService: TruckService, private val truckRepository: TruckRepository, + private val truckLaneRenameService: TruckLaneRenameService, ) { @@ -469,4 +473,15 @@ open class TruckController( } } + @PostMapping("/previewTruckLaneRename") + open fun previewTruckLaneRename(@RequestBody req: PreviewTruckLaneRenameRequest) = + truckLaneRenameService.preview(req) + + @PostMapping("/renameTruckLane") + open fun renameTruckLane(@RequestBody req: RenameTruckLaneRequest) = + truckLaneRenameService.rename(req) + + @GetMapping("/truckLaneRenameLog") + open fun truckLaneRenameLog() = truckLaneRenameService.recentLogs() + } diff --git a/src/main/java/com/ffii/fpsms/modules/pickOrder/web/models/TruckLaneRenameModels.kt b/src/main/java/com/ffii/fpsms/modules/pickOrder/web/models/TruckLaneRenameModels.kt new file mode 100644 index 00000000..b4e9449d --- /dev/null +++ b/src/main/java/com/ffii/fpsms/modules/pickOrder/web/models/TruckLaneRenameModels.kt @@ -0,0 +1,43 @@ +package com.ffii.fpsms.modules.pickOrder.web.models + +data class PreviewTruckLaneRenameRequest( + val truckLanceCode: String = "", + val storeId: String = "2F", + val remark: String? = null, +) + +data class PreviewTruckLaneRenameResponse( + val unfinishedCount: Int = 0, + val completedCount: Int = 0, + val handlerNames: List = emptyList(), + val openScheduleCount: Int = 0, +) + +data class RenameTruckLaneRequest( + val truckLanceCode: String = "", + val storeId: String = "2F", + val remark: String? = null, + @param:com.fasterxml.jackson.annotation.JsonProperty("newTruckLanceCode") + val replacementTruckLanceCode: String = "", + val departureTimeChanged: Boolean = false, + val newDepartureTime: String? = null, +) + +data class RenameTruckLaneResponse( + val truckLanceCode: String = "", + val ticketCount: Int = 0, + val scheduleLineCount: Int = 0, +) + +data class TruckLaneRenameLogResponse( + val id: Long = 0, + val created: String? = null, + val createdBy: String? = null, + val oldCode: String = "", + val newCode: String = "", + val storeId: String = "", + val remark: String? = null, + val oldDepartureTime: String? = null, + val newDepartureTime: String? = null, + val ticketCount: Int = 0, +) diff --git a/src/main/resources/db/changelog/changes/20261026_truck_lane_rename/01_create_truck_lane_rename_log.sql b/src/main/resources/db/changelog/changes/20261026_truck_lane_rename/01_create_truck_lane_rename_log.sql new file mode 100644 index 00000000..f7f36449 --- /dev/null +++ b/src/main/resources/db/changelog/changes/20261026_truck_lane_rename/01_create_truck_lane_rename_log.sql @@ -0,0 +1,23 @@ +-- liquibase formatted sql +-- changeset fpsms:20261026_01_create_truck_lane_rename_log +-- preconditions onFail:MARK_RAN +-- precondition-sql-check expectedResult:0 SELECT COUNT(*) FROM information_schema.tables WHERE table_schema = DATABASE() AND table_name = 'truck_lane_rename_log' + +CREATE TABLE IF NOT EXISTS `truck_lane_rename_log` +( + `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', + `oldCode` VARCHAR(50) NOT NULL, + `newCode` VARCHAR(50) NOT NULL, + `storeId` VARCHAR(10) NOT NULL, + `remark` VARCHAR(255) NULL DEFAULT NULL, + `oldDepartureTime` VARCHAR(20) NULL DEFAULT NULL, + `newDepartureTime` VARCHAR(20) NULL DEFAULT NULL, + `ticketCount` INT NOT NULL DEFAULT '0', + CONSTRAINT pk_truck_lane_rename_log PRIMARY KEY (`id`) +); From e36db529380ef7d0167ba2dc8f0a7bed081ca686 Mon Sep 17 00:00:00 2001 From: tommy Date: Tue, 27 Oct 2026 16:25:21 +0800 Subject: [PATCH 05/10] rename trucklancecode --- .../service/TruckLaneRenameService.kt | 47 ++----------------- 1 file changed, 4 insertions(+), 43 deletions(-) diff --git a/src/main/java/com/ffii/fpsms/modules/pickOrder/service/TruckLaneRenameService.kt b/src/main/java/com/ffii/fpsms/modules/pickOrder/service/TruckLaneRenameService.kt index 74e8e800..7c217a2e 100644 --- a/src/main/java/com/ffii/fpsms/modules/pickOrder/service/TruckLaneRenameService.kt +++ b/src/main/java/com/ffii/fpsms/modules/pickOrder/service/TruckLaneRenameService.kt @@ -71,20 +71,11 @@ open class TruckLaneRenameService( val oldTimeLabel = describeTimes(sourceRows.map { it.departureTime }) val casings = sourceRows.map { it.truckLanceCode?.trim().orEmpty() }.filter { it.isNotEmpty() }.distinct() + // Existing delivery tickets (pending / released / assigned / completed) keep the old name. + // Later releases copy the new master name at insert time. var scheduleCount = 0 - var ticketCount = 0 for (casing in casings) { scheduleCount += applyOpenScheduleRename(casing, newCode, floor, remarkKey) - val params = ticketParams(casing, floor, remarkKey).toMutableMap() - params["newCode"] = newCode - ticketCount += jdbcDao.executeUpdate( - updateTicketSql(DOPO_UPDATE_SQL, req.departureTimeChanged), - withTime(params, newTime), - ) - jdbcDao.executeUpdate( - updateTicketSql(PICK_UPDATE_SQL, req.departureTimeChanged), - withTime(params, newTime), - ) } for (row in sourceRows) { row.truckLanceCode = newCode @@ -99,12 +90,12 @@ open class TruckLaneRenameService( log.remark = remarkKey.ifEmpty { null } log.oldDepartureTime = oldTimeLabel log.newDepartureTime = if (req.departureTimeChanged) newTime.toString() else oldTimeLabel - log.ticketCount = ticketCount + log.ticketCount = 0 renameLogRepository.save(log) return RenameTruckLaneResponse( truckLanceCode = newCode, - ticketCount = ticketCount, + ticketCount = 0, scheduleLineCount = scheduleCount, ) } @@ -196,18 +187,6 @@ open class TruckLaneRenameService( } } - private fun withTime(params: MutableMap, time: LocalTime?): Map { - if (time != null) params["newTime"] = time.toString() - return params - } - - private fun updateTicketSql(base: String, withTime: Boolean): String { - if (!withTime) return base - return base.replace("SET colCode = :newCode", "SET colCode = :newCode, colTime = :newTime") - .replace("SET TruckLanceCode = :newCode", "SET TruckLanceCode = :newCode, truck_departure_time = :newTime") - .replace("SET truckLanceCode = :newCode", "SET truckLanceCode = :newCode, truckDepartureTime = :newTime") - } - private fun ticketParams(code: String, floor: String, remarkKey: String): Map = mapOf( "oldCode" to code, "floor" to floor, @@ -273,24 +252,6 @@ open class TruckLaneRenameService( AND ${FLOOR_MATCH.replace("storeCol", "storeId")} """.trimIndent() - private val DOPO_UPDATE_SQL = """ - UPDATE delivery_order_pick_order - SET truckLanceCode = :newCode, version = version + 1 - WHERE deleted = 0 - AND ticketStatus IN ('pending', 'released') - AND TRIM(truckLanceCode) = :oldCode - AND ${FLOOR_MATCH.replace("storeCol", "storeId")} - """.trimIndent() - - private val PICK_UPDATE_SQL = """ - UPDATE do_pick_order - SET TruckLanceCode = :newCode, version = version + 1 - WHERE deleted = 0 - AND ticket_status IN ('pending', 'released') - AND TRIM(TruckLanceCode) = :oldCode - AND ${FLOOR_MATCH.replace("storeCol", "store_id")} - """.trimIndent() - private val OPEN_SCHEDULE_COUNT_SQL = """ SELECT COUNT(*) AS cnt FROM truck_lane_schedule_line l From b200778250c0a223624ab53f9c63dff2d3274643 Mon Sep 17 00:00:00 2001 From: "CANCERYS\\kw093" Date: Wed, 28 Oct 2026 16:00:49 +0800 Subject: [PATCH 06/10] =?UTF-8?q?=E5=B7=B2=E6=94=BE=E5=96=AE=E7=9A=84?= =?UTF-8?q?=E9=80=81=E8=B2=A8=E5=96=AE=E6=90=9C=E5=B0=8B=E6=94=B9=E8=AE=80?= =?UTF-8?q?=E7=A5=A8=E6=93=9A=E8=BB=8A=E7=B7=9A=EF=BC=8C=E9=81=BF=E5=85=8D?= =?UTF-8?q?=E6=94=BE=E5=96=AE=E5=BE=8C=E5=86=8D=E4=BE=9D=E4=BE=9B=E6=87=89?= =?UTF-8?q?=E5=95=86=E9=87=8D=E6=9F=A5=E8=BB=8A=E8=BC=9B=E3=80=82?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-authored-by: Cursor --- .../service/DeliveryOrderService.kt | 248 +++++++++++++----- 1 file changed, 189 insertions(+), 59 deletions(-) diff --git a/src/main/java/com/ffii/fpsms/modules/deliveryOrder/service/DeliveryOrderService.kt b/src/main/java/com/ffii/fpsms/modules/deliveryOrder/service/DeliveryOrderService.kt index cee583ca..73a0eb9d 100644 --- a/src/main/java/com/ffii/fpsms/modules/deliveryOrder/service/DeliveryOrderService.kt +++ b/src/main/java/com/ffii/fpsms/modules/deliveryOrder/service/DeliveryOrderService.kt @@ -159,6 +159,7 @@ open class DeliveryOrderService( } } + /** FP-MTMS Version Checklist | Functions Ref. No. 88 | v1.0.1 | 2026-10-28 */ open fun searchDoLiteByPage( code: String?, shopName: String?, @@ -203,10 +204,14 @@ open class DeliveryOrderService( val preFilteredContent = allResult.content val (floorSuppliers2F, floorSuppliers4F) = loadDoFloorSupplierLists() + val ticketLaneByDoId = loadTicketTruckLanesByDoId( + deliveryOrdersMap.values.filter { isReleasedDeliveryOrder(it) }.mapNotNull { it.id }, + ) - // ✅ 优化3: 收集所有需要查询的 shopId 和日期组合(只处理预过滤后的记录) + // 未放單才依供應商樓層查 truck;已放單用票據車線 val shopIdAndDatePairs = preFilteredContent.mapNotNull { info -> val deliveryOrder = deliveryOrdersMap[info.id] + if (isReleasedDeliveryOrder(deliveryOrder)) return@mapNotNull null val shopId = deliveryOrder?.shop?.id val estimatedArrivalDate = info.estimatedArrivalDate if (shopId != null && estimatedArrivalDate != null) { @@ -234,23 +239,16 @@ open class DeliveryOrderService( // 处理预过滤后的记录:计算 truckLanceCode 并过滤 val processedRecords = preFilteredContent.map { info -> val deliveryOrder = deliveryOrdersMap[info.id] - val supplierCode = deliveryOrder?.supplier?.code - val preferredFloor = preferredStoreFloorForSupplier(supplierCode, floorSuppliers2F, floorSuppliers4F) - val shop = deliveryOrder?.shop - val shopId = shop?.id val estimatedArrivalDate = info.estimatedArrivalDate - val calculatedTruckLanceCode = - if (deliveryOrder != null && shopId != null && estimatedArrivalDate != null) { - val targetDate = estimatedArrivalDate.toLocalDate() - val dayAbbr = getDayOfWeekAbbr(targetDate) - - // 从缓存中获取 Truck - val matchedTruck = truckCache[Triple(shopId, preferredFloor, dayAbbr)] - matchedTruck?.truckLanceCode - } else { - null - } + val calculatedTruckLanceCode = displayedTruckLanceCode( + deliveryOrder = deliveryOrder, + estimatedArrivalDate = estimatedArrivalDate, + floorSuppliers2F = floorSuppliers2F, + floorSuppliers4F = floorSuppliers4F, + truckCache = truckCache, + ticketLaneByDoId = ticketLaneByDoId, + ) DeliveryOrderInfoLiteDto( id = info.id, @@ -299,22 +297,22 @@ open class DeliveryOrderService( ) val (floorSuppliers2F, floorSuppliers4F) = loadDoFloorSupplierLists() + val loadedOrders = result.content.associate { info -> + info.id to deliveryOrderRepository.findByIdAndDeletedIsFalse(info.id) + } + val ticketLaneByDoId = loadTicketTruckLanesByDoId( + loadedOrders.values.filter { isReleasedDeliveryOrder(it) }.mapNotNull { it?.id }, + ) val records = result.content.map { info -> - val deliveryOrder = deliveryOrderRepository.findByIdAndDeletedIsFalse(info.id) - val supplierCode = deliveryOrder?.supplier?.code - val preferredFloor = preferredStoreFloorForSupplier(supplierCode, floorSuppliers2F, floorSuppliers4F) - val shop = deliveryOrder?.shop - val shopId = shop?.id - val estimatedArrivalDate = info.estimatedArrivalDate - - val calculatedTruckLanceCode = - if (deliveryOrder != null && shopId != null && estimatedArrivalDate != null) { - val targetDate = estimatedArrivalDate.toLocalDate() - val dayAbbr = getDayOfWeekAbbr(targetDate) - resolveTruckForShopFloorAndDay(shopId, preferredFloor, dayAbbr)?.truckLanceCode - } else { - null - } + val deliveryOrder = loadedOrders[info.id] + val calculatedTruckLanceCode = displayedTruckLanceCode( + deliveryOrder = deliveryOrder, + estimatedArrivalDate = info.estimatedArrivalDate, + floorSuppliers2F = floorSuppliers2F, + floorSuppliers4F = floorSuppliers4F, + truckCache = null, + ticketLaneByDoId = ticketLaneByDoId, + ) DeliveryOrderInfoLiteDto( id = info.id, @@ -391,8 +389,9 @@ open class DeliveryOrderService( } /** - * 僅回傳依店鋪/ETA 從 truck 排程**推算後** `truckLanceCode` 為 null 或空白的送貨單(畫面上對應「車線-X」)。 - * 與 [searchDoLiteByPage] 帶一般車線關鍵字分開,避免 `車線-X` 在 truck 表無 shopId 時走舊邏輯漏單。 + * FP-MTMS Version Checklist | Functions Ref. No. 88 | v1.0.1 | 2026-10-28 + * 僅回傳顯示車線為 null 或空白的送貨單(畫面上對應「車線-X」)。 + * 已放單(receiving/completed)用票據車線;未放單才依店鋪/ETA 從 truck 排程推算。 */ open fun searchDoLiteUnassignedTruckByPage( code: String?, @@ -425,23 +424,20 @@ open class DeliveryOrderService( val deliveryOrderIds = allResult.content.mapNotNull { it.id } val deliveryOrdersMap = deliveryOrderRepository.findAllById(deliveryOrderIds).associateBy { it.id } val (floorSuppliers2F, floorSuppliers4F) = loadDoFloorSupplierLists() + val ticketLaneByDoId = loadTicketTruckLanesByDoId( + deliveryOrdersMap.values.filter { isReleasedDeliveryOrder(it) }.mapNotNull { it.id }, + ) val processedRecords = allResult.content.map { info -> val deliveryOrder = deliveryOrdersMap[info.id] - val supplierCode = deliveryOrder?.supplier?.code - val preferredFloor = preferredStoreFloorForSupplier(supplierCode, floorSuppliers2F, floorSuppliers4F) - val shop = deliveryOrder?.shop - val shopId = shop?.id - val infoEta = info.estimatedArrivalDate - - val calculatedTruckLanceCode = - if (deliveryOrder != null && shopId != null && infoEta != null) { - val targetDate = infoEta.toLocalDate() - val dayAbbr = getDayOfWeekAbbr(targetDate) - resolveTruckForShopFloorAndDay(shopId, preferredFloor, dayAbbr)?.truckLanceCode - } else { - null - } + val calculatedTruckLanceCode = displayedTruckLanceCode( + deliveryOrder = deliveryOrder, + estimatedArrivalDate = info.estimatedArrivalDate, + floorSuppliers2F = floorSuppliers2F, + floorSuppliers4F = floorSuppliers4F, + truckCache = null, + ticketLaneByDoId = ticketLaneByDoId, + ) DeliveryOrderInfoLiteDto( id = info.id, @@ -2502,6 +2498,7 @@ val inventoryLotLine = illId?.let { inventoryLotLineMap[it] } return out } + /** FP-MTMS Version Checklist | Functions Ref. No. 88 | v1.0.1 | 2026-10-28 */ private fun buildResolvedTruckDtosForLiteRows( rows: List, ): List { @@ -2509,9 +2506,13 @@ val inventoryLotLine = illId?.let { inventoryLotLineMap[it] } if (ids.isEmpty()) return emptyList() val deliveryOrdersMap = deliveryOrderRepository.findAllById(ids).associateBy { it.id } val (floorSuppliers2F, floorSuppliers4F) = loadDoFloorSupplierLists() + val ticketLaneByDoId = loadTicketTruckLanesByDoId( + deliveryOrdersMap.values.filter { isReleasedDeliveryOrder(it) }.mapNotNull { it.id }, + ) val shopIdAndDatePairs = rows.mapNotNull { info -> val d = deliveryOrdersMap[info.id] + if (isReleasedDeliveryOrder(d)) return@mapNotNull null val shopId = d?.shop?.id val eta = info.estimatedArrivalDate if (shopId != null && eta != null) { @@ -2533,18 +2534,14 @@ val inventoryLotLine = illId?.let { inventoryLotLineMap[it] } return rows.map { info -> val deliveryOrder = deliveryOrdersMap[info.id] - val supplierCode = deliveryOrder?.supplier?.code - val preferredFloor = preferredStoreFloorForSupplier(supplierCode, floorSuppliers2F, floorSuppliers4F) - val shopId = deliveryOrder?.shop?.id - val infoEta = info.estimatedArrivalDate - val calculatedTruckLanceCode = - if (deliveryOrder != null && shopId != null && infoEta != null) { - val targetDate = infoEta.toLocalDate() - val dayAbbr = getDayOfWeekAbbr(targetDate) - truckCache[Triple(shopId, preferredFloor, dayAbbr)]?.truckLanceCode - } else { - null - } + val calculatedTruckLanceCode = displayedTruckLanceCode( + deliveryOrder = deliveryOrder, + estimatedArrivalDate = info.estimatedArrivalDate, + floorSuppliers2F = floorSuppliers2F, + floorSuppliers4F = floorSuppliers4F, + truckCache = truckCache, + ticketLaneByDoId = ticketLaneByDoId, + ) DeliveryOrderInfoLiteDto( id = info.id, code = info.code, @@ -2560,6 +2557,139 @@ val inventoryLotLine = illId?.let { inventoryLotLineMap[it] } } } + private class TicketLaneHit( + val lane: String?, + val ticketId: Long, + val srcRank: Int, + ) + + /** + * FP-MTMS Version Checklist | Functions Ref. No. 88 | v1.0.1 | 2026-10-28 + * 放單後狀態:receiving 表示已發布,completed 表示已完成。 + */ + private fun isReleasedDeliveryOrder(deliveryOrder: DeliveryOrder?): Boolean = + deliveryOrder?.status == DeliveryOrderStatus.RECEIVING || + deliveryOrder?.status == DeliveryOrderStatus.COMPLETED + + /** + * FP-MTMS Version Checklist | Functions Ref. No. 88 | v1.0.1 | 2026-10-28 + * 已放單讀票據車線(工作台 `delivery_order_pick_order`,否則 `do_pick_order`/record)。 + * 未放單才依供應商樓層 + 店鋪 + 星期從 truck 推算。 + * [truckCache] 為 null 時,未放單即時查 truck。 + */ + private fun displayedTruckLanceCode( + deliveryOrder: DeliveryOrder?, + estimatedArrivalDate: LocalDateTime?, + floorSuppliers2F: List, + floorSuppliers4F: List, + truckCache: Map, Truck?>?, + ticketLaneByDoId: Map, + ): String? { + if (isReleasedDeliveryOrder(deliveryOrder)) { + return deliveryOrder?.id?.let { ticketLaneByDoId[it] } + } + val shopId = deliveryOrder?.shop?.id ?: return null + val eta = estimatedArrivalDate ?: return null + val preferredFloor = preferredStoreFloorForSupplier( + deliveryOrder.supplier?.code, + floorSuppliers2F, + floorSuppliers4F, + ) + val dayAbbr = getDayOfWeekAbbr(eta.toLocalDate()) + val key = Triple(shopId, preferredFloor, dayAbbr) + if (truckCache != null) { + return truckCache[key]?.truckLanceCode + } + return resolveTruckForShopFloorAndDay(shopId, preferredFloor, dayAbbr)?.truckLanceCode + } + + /** + * FP-MTMS Version Checklist | Functions Ref. No. 88 | v1.0.1 | 2026-10-28 + * 一批送貨單對應的票據車線。同一張單有多張票時,工作台票優先,同來源取 id 最大的一張。 + * 票上的車線為空白時仍回傳 null,呼叫端不再改查 truck。 + */ + private fun loadTicketTruckLanesByDoId(doIds: Collection): Map { + val ids = doIds.distinct() + if (ids.isEmpty()) return emptyMap() + val best = HashMap() + val sql = """ + SELECT do_id, truck_lance_code, ticket_id, src_rank + FROM ( + SELECT po.doId AS do_id, + NULLIF(TRIM(dop.truckLanceCode), '') AS truck_lance_code, + dop.id AS ticket_id, + 1 AS src_rank + FROM fpsmsdb.delivery_order_pick_order dop + INNER JOIN fpsmsdb.pick_order po + ON po.deliveryOrderPickOrderId = dop.id AND po.deleted = 0 + WHERE dop.deleted = 0 + AND po.doId IN (:doIds) + + UNION ALL + + SELECT dpo.do_order_id AS do_id, + NULLIF(TRIM(dpo.TruckLanceCode), '') AS truck_lance_code, + dpo.id AS ticket_id, + 2 AS src_rank + FROM fpsmsdb.do_pick_order dpo + WHERE dpo.deleted = 0 + AND dpo.do_order_id IN (:doIds) + + UNION ALL + + SELECT dpol.do_order_id AS do_id, + NULLIF(TRIM(dpo.TruckLanceCode), '') AS truck_lance_code, + dpo.id AS ticket_id, + 2 AS src_rank + FROM fpsmsdb.do_pick_order dpo + INNER JOIN fpsmsdb.do_pick_order_line dpol + ON dpol.do_pick_order_id = dpo.id AND dpol.deleted = 0 + WHERE dpo.deleted = 0 + AND dpol.do_order_id IN (:doIds) + + UNION ALL + + SELECT rec.do_order_id AS do_id, + NULLIF(TRIM(rec.TruckLanceCode), '') AS truck_lance_code, + rec.id AS ticket_id, + 3 AS src_rank + FROM fpsmsdb.do_pick_order_record rec + WHERE rec.deleted = 0 + AND rec.do_order_id IN (:doIds) + ) lanes + """.trimIndent() + + ids.chunked(400).forEach { chunk -> + val rows = jdbcDao.queryForList(sql, mapOf("doIds" to chunk)) + for (row in rows) { + val doId = jdbcNumber(row, "do_id")?.toLong() ?: continue + val ticketId = jdbcNumber(row, "ticket_id")?.toLong() ?: 0L + val srcRank = jdbcNumber(row, "src_rank")?.toInt() ?: 9 + val lane = jdbcText(row, "truck_lance_code") + val hit = TicketLaneHit(lane, ticketId, srcRank) + val current = best[doId] + if ( + current == null || + hit.srcRank < current.srcRank || + (hit.srcRank == current.srcRank && hit.ticketId > current.ticketId) + ) { + best[doId] = hit + } + } + } + return best.mapValues { it.value.lane } + } + + private fun jdbcNumber(row: Map<*, *>, name: String): Number? { + val value = row.entries.firstOrNull { it.key?.toString().equals(name, true) }?.value ?: return null + return value as? Number + } + + private fun jdbcText(row: Map<*, *>, name: String): String? { + val value = row.entries.firstOrNull { it.key?.toString().equals(name, true) }?.value ?: return null + return value.toString().trim().takeIf { it.isNotEmpty() } + } + /** * 依店鋪 + 揀貨樓層解析當日應顯示之車線。 * - **2F**(P07/P06D/P06Y):`TruckLanceCode` 多為 `車線-…` 且不含星期縮寫,不依 `LIKE '%Mon%'` 篩選;取該店該樓層未刪除車輛中出發時間最早者。 From d50642a7a11a6e44fe4977f4f65d7b403d099d62 Mon Sep 17 00:00:00 2001 From: tommy Date: Wed, 28 Oct 2026 17:53:33 +0800 Subject: [PATCH 07/10] truck rename --- .../ffii/fpsms/modules/pickOrder/entity/TruckLaneRenameLog.kt | 4 ++++ .../fpsms/modules/pickOrder/service/TruckLaneRenameService.kt | 2 ++ .../modules/pickOrder/web/models/TruckLaneRenameModels.kt | 1 + 3 files changed, 7 insertions(+) diff --git a/src/main/java/com/ffii/fpsms/modules/pickOrder/entity/TruckLaneRenameLog.kt b/src/main/java/com/ffii/fpsms/modules/pickOrder/entity/TruckLaneRenameLog.kt index 40acce88..977bfb0a 100644 --- a/src/main/java/com/ffii/fpsms/modules/pickOrder/entity/TruckLaneRenameLog.kt +++ b/src/main/java/com/ffii/fpsms/modules/pickOrder/entity/TruckLaneRenameLog.kt @@ -29,4 +29,8 @@ open class TruckLaneRenameLog : BaseEntity() { @Column(name = "ticketCount", nullable = false) open var ticketCount: Int = 0 + + /** `truck.id` of each shop row in the renamed lane, comma-separated, ascending. */ + @Column(name = "truckIds", columnDefinition = "TEXT") + open var truckIds: String? = null } diff --git a/src/main/java/com/ffii/fpsms/modules/pickOrder/service/TruckLaneRenameService.kt b/src/main/java/com/ffii/fpsms/modules/pickOrder/service/TruckLaneRenameService.kt index 7c217a2e..fa4dc2c6 100644 --- a/src/main/java/com/ffii/fpsms/modules/pickOrder/service/TruckLaneRenameService.kt +++ b/src/main/java/com/ffii/fpsms/modules/pickOrder/service/TruckLaneRenameService.kt @@ -91,6 +91,7 @@ open class TruckLaneRenameService( log.oldDepartureTime = oldTimeLabel log.newDepartureTime = if (req.departureTimeChanged) newTime.toString() else oldTimeLabel log.ticketCount = 0 + log.truckIds = sourceRows.mapNotNull { it.id }.distinct().sorted().joinToString(",") renameLogRepository.save(log) return RenameTruckLaneResponse( @@ -130,6 +131,7 @@ open class TruckLaneRenameService( oldDepartureTime = row.oldDepartureTime, newDepartureTime = row.newDepartureTime, ticketCount = row.ticketCount, + truckIds = row.truckIds, ) } diff --git a/src/main/java/com/ffii/fpsms/modules/pickOrder/web/models/TruckLaneRenameModels.kt b/src/main/java/com/ffii/fpsms/modules/pickOrder/web/models/TruckLaneRenameModels.kt index b4e9449d..78b659e4 100644 --- a/src/main/java/com/ffii/fpsms/modules/pickOrder/web/models/TruckLaneRenameModels.kt +++ b/src/main/java/com/ffii/fpsms/modules/pickOrder/web/models/TruckLaneRenameModels.kt @@ -40,4 +40,5 @@ data class TruckLaneRenameLogResponse( val oldDepartureTime: String? = null, val newDepartureTime: String? = null, val ticketCount: Int = 0, + val truckIds: String? = null, ) From fdaef672b7eb81089951df80863d247c20ef0eae Mon Sep 17 00:00:00 2001 From: tommy Date: Wed, 28 Oct 2026 17:55:23 +0800 Subject: [PATCH 08/10] no message --- .../01_add_truck_ids.sql | 9 +++++++++ 1 file changed, 9 insertions(+) create mode 100644 src/main/resources/db/changelog/changes/20261028_truck_lane_rename_log_truck_ids/01_add_truck_ids.sql diff --git a/src/main/resources/db/changelog/changes/20261028_truck_lane_rename_log_truck_ids/01_add_truck_ids.sql b/src/main/resources/db/changelog/changes/20261028_truck_lane_rename_log_truck_ids/01_add_truck_ids.sql new file mode 100644 index 00000000..f45dba7e --- /dev/null +++ b/src/main/resources/db/changelog/changes/20261028_truck_lane_rename_log_truck_ids/01_add_truck_ids.sql @@ -0,0 +1,9 @@ +-- liquibase formatted sql +-- changeset fpsms:20261028_01_truck_lane_rename_log_truck_ids +-- preconditions onFail:MARK_RAN +-- precondition-sql-check expectedResult:0 SELECT COUNT(*) FROM information_schema.columns WHERE table_schema = DATABASE() AND table_name = 'truck_lane_rename_log' AND column_name = 'truckIds' + +-- truck.id values of the shops in the renamed lane, comma-separated, ascending. +-- Nullable so rows written before this column existed stay valid. +ALTER TABLE `truck_lane_rename_log` + ADD COLUMN `truckIds` TEXT NULL DEFAULT NULL AFTER `ticketCount`; From 1bae0e38a038d1ec6a20b3883896e1cac17e2428 Mon Sep 17 00:00:00 2001 From: "CANCERYS\\kw093" Date: Wed, 28 Oct 2026 18:01:34 +0800 Subject: [PATCH 09/10] =?UTF-8?q?=E6=AD=A3=E5=BC=8F=E9=80=81=E8=B2=A8?= =?UTF-8?q?=E5=96=AE=E5=90=8C=E4=B8=80=E8=B2=A8=E5=93=81=E6=9C=89=E5=A4=9A?= =?UTF-8?q?=E7=AD=86=E6=8F=90=E6=96=99=E8=A1=8C=E6=99=82=EF=BC=8C=E5=B7=B2?= =?UTF-8?q?=E6=8F=90=E6=95=B8=E9=87=8F=E6=94=B9=E6=8C=89=E6=95=B8=E9=87=8F?= =?UTF-8?q?=E9=85=8D=E5=B0=8D=EF=BC=8C=E9=81=BF=E5=85=8D=E9=83=BD=E5=8D=B0?= =?UTF-8?q?=E6=88=90=E7=AC=AC=E4=B8=80=E7=AD=86=E3=80=82?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-authored-by: Cursor --- .../entity/models/DeliveryOrderLineInfo.kt | 1 + .../service/DeliveryOrderService.kt | 120 +++++++++++++++--- .../service/DoReplenishmentService.kt | 3 + .../service/DoWorkbenchMainService.kt | 10 +- 4 files changed, 112 insertions(+), 22 deletions(-) diff --git a/src/main/java/com/ffii/fpsms/modules/deliveryOrder/entity/models/DeliveryOrderLineInfo.kt b/src/main/java/com/ffii/fpsms/modules/deliveryOrder/entity/models/DeliveryOrderLineInfo.kt index 1fd8a3e1..98145832 100644 --- a/src/main/java/com/ffii/fpsms/modules/deliveryOrder/entity/models/DeliveryOrderLineInfo.kt +++ b/src/main/java/com/ffii/fpsms/modules/deliveryOrder/entity/models/DeliveryOrderLineInfo.kt @@ -4,6 +4,7 @@ import org.springframework.beans.factory.annotation.Value import java.math.BigDecimal interface DeliveryOrderLineInfo { + val id: Long @get:Value("#{target.item?.name}") val itemName: String? val itemNo: String diff --git a/src/main/java/com/ffii/fpsms/modules/deliveryOrder/service/DeliveryOrderService.kt b/src/main/java/com/ffii/fpsms/modules/deliveryOrder/service/DeliveryOrderService.kt index 73a0eb9d..dd4ccdf1 100644 --- a/src/main/java/com/ffii/fpsms/modules/deliveryOrder/service/DeliveryOrderService.kt +++ b/src/main/java/com/ffii/fpsms/modules/deliveryOrder/service/DeliveryOrderService.kt @@ -1383,6 +1383,7 @@ open class DeliveryOrderService( ) } + /** FP-MTMS Version Checklist | Functions Ref. No. 89 | v1.0.0 | 2026-10-28 */ private fun exportDeliveryNoteFromRecord( request: ExportDeliveryNoteRequest, deliveryNote: net.sf.jasperreports.engine.JasperReport, @@ -1476,15 +1477,19 @@ open class DeliveryOrderService( val deliveryOrderCodeById = deliveryOrders.associate { it.id!! to (it.code ?: "") } val isExtraByDoId = deliveryOrders.associate { it.id!! to it.isExtra } val replenishPdfIndex = doReplenishmentService.buildReplenishPdfIndex(deliveryOrderIds) - + val pickOrderLineIdByDeliveryLine = matchDeliveryNotePickOrderLines( + exportLines = exportLines, + pickOrderIdByDoId = pickOrderIdByDoId, + pickOrderLines = pickOrderLines, + replenishmentPickOrderLineIds = replenishPdfIndex.replenishmentPickOrderLineIds(), + ) + sortedLines.forEach { row -> fields.add( buildDeliveryNotePdfLineField( deliveryOrderId = row.deliveryOrderId, line = row.line, sequenceNumber = fields.size + 1, - pickOrderIdByDoId = pickOrderIdByDoId, - pickOrderLines = pickOrderLines, stockOutLinesByPickOrderLineId = stockOutLinesByPickOrderLineId, illById = illById, itemsById = itemsById, @@ -1493,6 +1498,7 @@ open class DeliveryOrderService( headerTicketNo = null, headerIsMerge = false, replenishPdfIndex = replenishPdfIndex, + pickOrderLineIdByDeliveryLine = pickOrderLineIdByDeliveryLine, ), ) } @@ -1553,17 +1559,47 @@ open class DeliveryOrderService( info.deliveryOrderLines.filter { it.deleted != true }.map { line -> DeliveryNoteExportLine(info.id, line) } } - fun resolvePickOrderLineIdForDeliveryNoteLine( - deliveryOrderId: Long, - itemId: Long?, + /** + * FP-MTMS Version Checklist | Functions Ref. No. 89 | v1.0.0 | 2026-10-28 + * Pair each delivery-order line with its own pick line. + * Same item on one pick order is matched by qty first (4↔4, 1↔1). + * Equal qty is paired in id order. Replenishment pick lines are left out; + * those rows still print from do_replenishment.pickOrderLineId. + */ + fun matchDeliveryNotePickOrderLines( + exportLines: List, pickOrderIdByDoId: Map, pickOrderLines: List, - ): Long? { - if (itemId == null) return null - val pickOrderId = pickOrderIdByDoId[deliveryOrderId] ?: return null - return pickOrderLines - .firstOrNull { pol -> pol.pickOrder?.id == pickOrderId && pol.item?.id == itemId } - ?.id + replenishmentPickOrderLineIds: Set = emptySet(), + ): Map, Long> { + val keys = exportLines.mapNotNull { row -> + val itemId = row.line.itemId ?: return@mapNotNull null + if (row.line.deleted == true) return@mapNotNull null + DeliveryNoteLineMatchKey( + deliveryOrderId = row.deliveryOrderId, + lineId = row.line.id, + itemId = itemId, + qty = row.line.qty, + ) + } + val candidates = pickOrderLines.mapNotNull { pol -> + val polId = pol.id ?: return@mapNotNull null + val pickOrderId = pol.pickOrder?.id ?: return@mapNotNull null + val itemId = pol.item?.id ?: return@mapNotNull null + val qty = pol.qty ?: return@mapNotNull null + DeliveryNotePickLineCandidate( + id = polId, + pickOrderId = pickOrderId, + itemId = itemId, + qty = qty, + ) + } + return DeliveryNotePickLineMatcher.match( + lines = keys, + pickOrderIdByDoId = pickOrderIdByDoId, + pickLines = candidates, + replenishmentPickOrderLineIds = replenishmentPickOrderLineIds, + ) } fun parseTicketTypeLetter(ticketNo: String?): String? { @@ -1675,12 +1711,11 @@ open class DeliveryOrderService( return field } + /** FP-MTMS Version Checklist | Functions Ref. No. 89 | v1.0.0 | 2026-10-28 */ fun buildDeliveryNotePdfLineField( deliveryOrderId: Long, line: DeliveryOrderLineInfo, sequenceNumber: Int, - pickOrderIdByDoId: Map, - pickOrderLines: List, stockOutLinesByPickOrderLineId: Map>, illById: Map, itemsById: Map, @@ -1689,6 +1724,7 @@ open class DeliveryOrderService( headerTicketNo: String? = null, headerIsMerge: Boolean = false, replenishPdfIndex: DoReplenishmentService.ReplenishPdfIndex = DoReplenishmentService.ReplenishPdfIndex.EMPTY, + pickOrderLineIdByDeliveryLine: Map, Long> = emptyMap(), ): MutableMap { val field = mutableMapOf() val isExtra = isExtraDeliveryTicket( @@ -1697,12 +1733,7 @@ open class DeliveryOrderService( headerIsMerge = headerIsMerge, ) - val polId = resolvePickOrderLineIdForDeliveryNoteLine( - deliveryOrderId = deliveryOrderId, - itemId = line.itemId, - pickOrderIdByDoId = pickOrderIdByDoId, - pickOrderLines = pickOrderLines, - ) + val polId = pickOrderLineIdByDeliveryLine[deliveryOrderId to line.id] val isReplenish = replenishPdfIndex.matches(deliveryOrderId, line.itemId, polId) field["sequenceNumber"] = formatSequenceNumber(sequenceNumber, isExtra, isReplenish) @@ -2806,3 +2837,52 @@ val inventoryLotLine = illId?.let { inventoryLotLineMap[it] } } } } + +internal data class DeliveryNoteLineMatchKey( + val deliveryOrderId: Long, + val lineId: Long, + val itemId: Long, + val qty: BigDecimal, +) + +internal data class DeliveryNotePickLineCandidate( + val id: Long, + val pickOrderId: Long, + val itemId: Long, + val qty: BigDecimal, +) + +internal object DeliveryNotePickLineMatcher { + /** FP-MTMS Version Checklist | Functions Ref. No. 89 | v1.0.0 | 2026-10-28 */ + fun match( + lines: List, + pickOrderIdByDoId: Map, + pickLines: List, + replenishmentPickOrderLineIds: Set = emptySet(), + ): Map, Long> { + val result = mutableMapOf, Long>() + val rowsByDoAndItem = lines.groupBy { it.deliveryOrderId to it.itemId } + for ((key, rows) in rowsByDoAndItem) { + val (deliveryOrderId, itemId) = key + val pickOrderId = pickOrderIdByDoId[deliveryOrderId] ?: continue + val available = pickLines + .filter { pol -> + pol.pickOrderId == pickOrderId && + pol.itemId == itemId && + pol.id !in replenishmentPickOrderLineIds + } + .sortedBy { it.id } + .toMutableList() + for (row in rows.sortedBy { it.lineId }) { + val qtyIndex = available.indexOfFirst { it.qty.compareTo(row.qty) == 0 } + val chosen = when { + qtyIndex >= 0 -> available.removeAt(qtyIndex) + available.isNotEmpty() -> available.removeAt(0) + else -> null + } ?: continue + result[row.deliveryOrderId to row.lineId] = chosen.id + } + } + return result + } +} diff --git a/src/main/java/com/ffii/fpsms/modules/deliveryOrder/service/DoReplenishmentService.kt b/src/main/java/com/ffii/fpsms/modules/deliveryOrder/service/DoReplenishmentService.kt index 81d0010a..50e8b0e2 100644 --- a/src/main/java/com/ffii/fpsms/modules/deliveryOrder/service/DoReplenishmentService.kt +++ b/src/main/java/com/ffii/fpsms/modules/deliveryOrder/service/DoReplenishmentService.kt @@ -162,6 +162,9 @@ open class DoReplenishmentService( return deliveryOrderId to resolvedItemId in targetDoItemKeys } + /** FP-MTMS Version Checklist | Functions Ref. No. 89 | v1.0.0 | 2026-10-28 */ + fun replenishmentPickOrderLineIds(): Set = pickOrderLineIds + companion object { val EMPTY = ReplenishPdfIndex(emptySet(), emptySet()) } diff --git a/src/main/java/com/ffii/fpsms/modules/deliveryOrder/service/DoWorkbenchMainService.kt b/src/main/java/com/ffii/fpsms/modules/deliveryOrder/service/DoWorkbenchMainService.kt index 50b2ec58..74d9e7f7 100644 --- a/src/main/java/com/ffii/fpsms/modules/deliveryOrder/service/DoWorkbenchMainService.kt +++ b/src/main/java/com/ffii/fpsms/modules/deliveryOrder/service/DoWorkbenchMainService.kt @@ -3057,6 +3057,7 @@ return MessageResponse( return deliveryOrderService.formatDoCodesPlain(codes) } + /** FP-MTMS Version Checklist | Functions Ref. No. 89 | v1.0.0 | 2026-10-28 */ open fun exportDeliveryNoteWorkbench(request: ExportDeliveryNoteRequest): Map { val deliveryNote = deliveryNoteJasperTemplate val fields = mutableListOf>() @@ -3108,6 +3109,12 @@ return MessageResponse( val headerIsMerge = ctx.header.ticketNo?.startsWith("TI-M-") == true val headerTicketNo = ctx.header.ticketNo val replenishPdfIndex = doReplenishmentService.buildReplenishPdfIndex(ctx.deliveryOrderIds) + val pickOrderLineIdByDeliveryLine = deliveryOrderService.matchDeliveryNotePickOrderLines( + exportLines = exportLines, + pickOrderIdByDoId = ctx.pickOrderIdByDoId, + pickOrderLines = pickOrderLines, + replenishmentPickOrderLineIds = replenishPdfIndex.replenishmentPickOrderLineIds(), + ) sortedLines.forEach { row -> fields.add( @@ -3115,8 +3122,6 @@ return MessageResponse( deliveryOrderId = row.deliveryOrderId, line = row.line, sequenceNumber = fields.size + 1, - pickOrderIdByDoId = ctx.pickOrderIdByDoId, - pickOrderLines = pickOrderLines, stockOutLinesByPickOrderLineId = stockOutLinesByPickOrderLineId, illById = illById, itemsById = itemsById, @@ -3125,6 +3130,7 @@ return MessageResponse( headerTicketNo = headerTicketNo, headerIsMerge = headerIsMerge, replenishPdfIndex = replenishPdfIndex, + pickOrderLineIdByDeliveryLine = pickOrderLineIdByDeliveryLine, ), ) } From cd9fc251a94c7f30d42ecef587564eee851d6e9c Mon Sep 17 00:00:00 2001 From: "CANCERYS\\kw093" Date: Mon, 2 Nov 2026 14:51:52 +0800 Subject: [PATCH 10/10] =?UTF-8?q?=E5=B7=A5=E5=96=AE=E5=BB=BA=E8=AD=B0?= =?UTF-8?q?=E6=8E=92=E9=99=A4=E5=85=A8=E9=83=A8=204F=EF=BC=8C=E5=90=8C?= =?UTF-8?q?=E5=88=B0=E6=9C=9F=E6=97=A5=E5=85=88=203F=20=E5=86=8D=202F?= =?UTF-8?q?=EF=BC=9B=E7=9B=A4=E9=BB=9E=E6=A0=B8=E5=87=86=E5=B7=AE=E7=95=B0?= =?UTF-8?q?=E6=94=B9=E8=B7=9F=E7=95=AB=E9=9D=A2=E9=81=B8=E6=93=87=E6=95=B8?= =?UTF-8?q?=E9=87=8F=E3=80=82?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-authored-by: Cursor --- .../service/DoWorkbenchMainService.kt | 4 +- .../service/JoWorkbenchMainService.kt | 5 +- .../service/JoWorkbenchPickConstants.kt | 29 ++------- .../ConsumableWorkbenchPickConstants.kt | 4 +- .../stock/service/StockTakeRecordService.kt | 33 +++++++++- .../SuggestedPickLotWorkbenchService.kt | 62 +++++++++++++++---- 6 files changed, 93 insertions(+), 44 deletions(-) diff --git a/src/main/java/com/ffii/fpsms/modules/deliveryOrder/service/DoWorkbenchMainService.kt b/src/main/java/com/ffii/fpsms/modules/deliveryOrder/service/DoWorkbenchMainService.kt index 74d9e7f7..cc3cabc0 100644 --- a/src/main/java/com/ffii/fpsms/modules/deliveryOrder/service/DoWorkbenchMainService.kt +++ b/src/main/java/com/ffii/fpsms/modules/deliveryOrder/service/DoWorkbenchMainService.kt @@ -259,9 +259,9 @@ open class DoWorkbenchMainService( } /** - * FP-MTMS Version Checklist | Functions Ref. No. 38 | v1.0.2 | 2026-08-13 + * FP-MTMS Version Checklist | Functions Ref. No. 38 | v1.0.3 | 2026-11-02 * Warehouse exclude list for workbench **re-suggest** after scan-pick (shortfall / lot split). - * - JO: same list as assign ([JoWorkbenchPickConstants.DEFAULT_EXCLUDE_WAREHOUSE_CODES]). + * - JO: same list as assign ([JoWorkbenchPickConstants.DEFAULT_EXCLUDE_WAREHOUSE_CODES], all `4F-` bins). * - Consumable: hardcoded user [ConsumableWorkbenchPickConstants.HARDCODED_EXCLUDE_USER_ID] → JO list; else empty (no limit). * - DO / other: pass through request (`null` → service default excludes). */ diff --git a/src/main/java/com/ffii/fpsms/modules/jobOrder/service/JoWorkbenchMainService.kt b/src/main/java/com/ffii/fpsms/modules/jobOrder/service/JoWorkbenchMainService.kt index a2ef1724..2a41fc58 100644 --- a/src/main/java/com/ffii/fpsms/modules/jobOrder/service/JoWorkbenchMainService.kt +++ b/src/main/java/com/ffii/fpsms/modules/jobOrder/service/JoWorkbenchMainService.kt @@ -23,12 +23,12 @@ import com.ffii.fpsms.modules.stock.entity.SuggestPickLotRepository import com.ffii.fpsms.modules.stock.entity.enum.InventoryLotLineStatus import com.ffii.fpsms.modules.stock.service.StockOutLineWorkbenchService import com.ffii.fpsms.modules.stock.service.SuggestedPickLotWorkbenchService +import com.ffii.fpsms.modules.stock.service.isWorkbenchWarehouseCodeExcluded import com.ffii.fpsms.modules.user.service.UserService import org.springframework.stereotype.Service import org.springframework.transaction.annotation.Transactional import java.math.BigDecimal import java.time.LocalDate -import java.util.Locale /** * Job Order Workbench–only flows (assign prime, hierarchical payload for scan-pick UI). @@ -79,8 +79,7 @@ open class JoWorkbenchMainService( val availableStatus = notDeleted.filter { it.status == InventoryLotLineStatus.AVAILABLE } val notExpired = availableStatus.filter { it.inventoryLot?.expiryDate?.isBefore(today) != true } val notExcluded = notExpired.filter { lot -> - val code = lot.warehouse?.code?.trim()?.uppercase(Locale.ROOT) - code.isNullOrEmpty() || code !in workbenchDefaultExcludeWarehouseCodes + !isWorkbenchWarehouseCodeExcluded(lot.warehouse?.code, workbenchDefaultExcludeWarehouseCodes) } val positiveQty = notExcluded.filter { lot -> val inQ = lot.inQty ?: BigDecimal.ZERO diff --git a/src/main/java/com/ffii/fpsms/modules/jobOrder/service/JoWorkbenchPickConstants.kt b/src/main/java/com/ffii/fpsms/modules/jobOrder/service/JoWorkbenchPickConstants.kt index 727c54a0..e5aaaff1 100644 --- a/src/main/java/com/ffii/fpsms/modules/jobOrder/service/JoWorkbenchPickConstants.kt +++ b/src/main/java/com/ffii/fpsms/modules/jobOrder/service/JoWorkbenchPickConstants.kt @@ -1,37 +1,18 @@ package com.ffii.fpsms.modules.jobOrder.service /** + * FP-MTMS Version Checklist | Functions Ref. No. 32 | v1.0.3 | 2026-11-02 * JO workbench pick constants. * * [DEFAULT_EXCLUDE_WAREHOUSE_CODES] applies on **assign / first prime** ([JoWorkbenchMainService]) * and on **scan-pick re-suggest** ([com.ffii.fpsms.modules.deliveryOrder.service.DoWorkbenchMainService]). * JO re-suggest keeps [storeId] null (same as assign); it does not use DO floor store resolution. + * + * Each entry matches a warehouse code exactly, or any code with that prefix plus `-`. + * `"4F"` therefore excludes every 4F bin (`4F-W402-…`, `4F-W401-…`, and the rest). */ object JoWorkbenchPickConstants { val DEFAULT_EXCLUDE_WAREHOUSE_CODES: Set = setOf( - "4F-W402-01-00", - "4F-W402-02-00", - "4F-W402-03-00", - "4F-W402-04-00", - "4F-W402-05-00", - "4F-W402-#A-00", - "4F-W402-#B-00", - "4F-W402-#C-00", - "4F-W402-#D-00", - "4F-W402-#E-00", - "4F-W402-#F-00", - "4F-W402-#G-00", - "4F-W402-#H-00", - "4F-W402-#I-00", - "4F-W402-#J-00", - "4F-W402-#K-00", - "4F-W402-#L-00", - "4F-W402-#M-00", - "4F-W402-#N-00", - "4F-W402-#O-00", - "4F-W402-#P-00", - "4F-W402-#Q-00", - "4F-W402-#R-00", - "4F-W402-#S-00", + "4F", ) } diff --git a/src/main/java/com/ffii/fpsms/modules/pickOrder/service/ConsumableWorkbenchPickConstants.kt b/src/main/java/com/ffii/fpsms/modules/pickOrder/service/ConsumableWorkbenchPickConstants.kt index caaadd87..9372a772 100644 --- a/src/main/java/com/ffii/fpsms/modules/pickOrder/service/ConsumableWorkbenchPickConstants.kt +++ b/src/main/java/com/ffii/fpsms/modules/pickOrder/service/ConsumableWorkbenchPickConstants.kt @@ -5,7 +5,7 @@ import com.ffii.fpsms.modules.jobOrder.service.JoWorkbenchPickConstants /** * Temporary consumable workbench exclude list until per-user DB config (Scheme A) lands. * - * - [HARDCODED_EXCLUDE_USER_ID] → same warehouses as JO ([JoWorkbenchPickConstants]). + * - [HARDCODED_EXCLUDE_USER_ID] → same warehouses as JO ([JoWorkbenchPickConstants], all `4F-` bins). * - All other users → empty list (no warehouse limit). Do not return null: * [SuggestedPickLotWorkbenchService] treats null as DO default 2F excludes. * Consumable re-suggest keeps storeId null (same as first prime / JO); it does not use DO floor store resolution. @@ -13,7 +13,7 @@ import com.ffii.fpsms.modules.jobOrder.service.JoWorkbenchPickConstants object ConsumableWorkbenchPickConstants { const val HARDCODED_EXCLUDE_USER_ID: Long = 246L - /** FP-MTMS Version Checklist | Functions Ref. No. 66 | v1.0.0 | 2026-08-13 */ + /** FP-MTMS Version Checklist | Functions Ref. No. 66 | v1.0.1 | 2026-11-02 */ fun resolveExcludeWarehouseCodes(userId: Long): List { if (userId != HARDCODED_EXCLUDE_USER_ID) return emptyList() return JoWorkbenchPickConstants.DEFAULT_EXCLUDE_WAREHOUSE_CODES.toList() diff --git a/src/main/java/com/ffii/fpsms/modules/stock/service/StockTakeRecordService.kt b/src/main/java/com/ffii/fpsms/modules/stock/service/StockTakeRecordService.kt index d4d3c308..a7f51df3 100644 --- a/src/main/java/com/ffii/fpsms/modules/stock/service/StockTakeRecordService.kt +++ b/src/main/java/com/ffii/fpsms/modules/stock/service/StockTakeRecordService.kt @@ -316,8 +316,31 @@ open class StockTakeRecordService( } } - /** 與 Approver 列表前端一致:優先第二次盤點數,否則第一次 */ + /** + * 與核准列表「差異百分比」同一個選擇數量。 + * lastSelect:1 第一次、2 第二次、3 審核員(盤點數 − 不良數)。 + * 沒有 lastSelect 時用 finalQty 對應;再沒有才退回第二次,否則第一次。 + */ private fun resolvePickerQtyForVarianceFilter(response: InventoryLotDetailResponse): BigDecimal { + when (response.lastSelect) { + 1 -> return response.firstStockTakeQty ?: BigDecimal.ZERO + 2 -> return response.secondStockTakeQty ?: BigDecimal.ZERO + 3 -> return approverGoodQty(response) + } + val finalQty = response.finalQty + if (finalQty != null) { + val approverGood = approverGoodQty(response) + if (response.approverQty != null && sameStockTakeQty(approverGood, finalQty)) { + return approverGood + } + val second = response.secondStockTakeQty + if (second != null && sameStockTakeQty(second, finalQty)) return second + val first = response.firstStockTakeQty + if (first != null && sameStockTakeQty(first, finalQty)) return first + if (response.approverQty != null && sameStockTakeQty(response.approverQty, finalQty)) { + return approverGood + } + } val second = response.secondStockTakeQty val first = response.firstStockTakeQty return when { @@ -327,6 +350,13 @@ open class StockTakeRecordService( } } + /** 與前端選擇數量一致:審核員盤點數減不良數。 */ + private fun approverGoodQty(response: InventoryLotDetailResponse): BigDecimal = + (response.approverQty ?: BigDecimal.ZERO).subtract(response.approverBadQty ?: BigDecimal.ZERO) + + private fun sameStockTakeQty(a: BigDecimal?, b: BigDecimal?): Boolean = + a != null && b != null && a.compareTo(b) == 0 + private fun computeInventoryLotDetailVariancePercentage(response: InventoryLotDetailResponse): BigDecimal { val selectedQty = resolvePickerQtyForVarianceFilter(response) val bookQty = response.bookQty ?: response.availableQty ?: BigDecimal.ZERO @@ -646,6 +676,7 @@ open class StockTakeRecordService( return stockTakeRepository.findByIdAndDeletedIsFalse(roundKey) } + /** FP-MTMS Version Checklist | Functions Ref. No. 90 | v1.0.0 | 2026-11-02 */ open fun getApproverInventoryLotDetailsAll( stockTakeId: Long? = null, pageNum: Int = 0, diff --git a/src/main/java/com/ffii/fpsms/modules/stock/service/SuggestedPickLotWorkbenchService.kt b/src/main/java/com/ffii/fpsms/modules/stock/service/SuggestedPickLotWorkbenchService.kt index 50f02a6c..e8dd5f0d 100644 --- a/src/main/java/com/ffii/fpsms/modules/stock/service/SuggestedPickLotWorkbenchService.kt +++ b/src/main/java/com/ffii/fpsms/modules/stock/service/SuggestedPickLotWorkbenchService.kt @@ -23,6 +23,21 @@ data class WorkbenchSuggestionSummary( val skippedExisting: Int, ) +/** + * FP-MTMS Version Checklist | Functions Ref. No. 32 | v1.0.3 | 2026-11-02 + * Case-insensitive match of [warehouseCode] against [excludeCodes]. + * An entry matches the code exactly, or any code that starts with `{entry}-`. + * `"4F"` therefore excludes every 4F bin. + */ +fun isWorkbenchWarehouseCodeExcluded(warehouseCode: String?, excludeCodes: Collection): Boolean { + val code = warehouseCode?.trim()?.uppercase() ?: return false + if (code.isEmpty() || excludeCodes.isEmpty()) return false + return excludeCodes.any { raw -> + val entry = raw.trim().uppercase() + entry.isNotEmpty() && (code == entry || code.startsWith("$entry-")) + } +} + @Service open class SuggestedPickLotWorkbenchService( private val pickOrderLineRepository: PickOrderLineRepository, @@ -127,7 +142,7 @@ open class SuggestedPickLotWorkbenchService( /** * Backend-default avoid warehouse list for workbench suggestions. - * - Case-insensitive match against `warehouse.code`. + * - Case-insensitive match against `warehouse.code` (exact, or `{entry}-` prefix). * - When request.excludeWarehouseCodes is provided, it overrides this default. * * Keep it empty if you don't need any default excludes. @@ -143,9 +158,32 @@ open class SuggestedPickLotWorkbenchService( } private fun isWarehouseExcluded(lot: InventoryLotLine, excludeWarehouseCodes: Set): Boolean { - if (excludeWarehouseCodes.isEmpty()) return false - val code = lot.warehouse?.code?.trim()?.uppercase() - return !code.isNullOrEmpty() && code in excludeWarehouseCodes + return isWorkbenchWarehouseCodeExcluded(lot.warehouse?.code, excludeWarehouseCodes) + } + + /** 3F before 2F when expiry is the same. Other floors stay after those two. */ + private fun suggestionFloorRank(lot: InventoryLotLine): Int { + val store = normalizeStoreId(lot.warehouse?.store_id) + ?: floorTokenFromWarehouseCode(lot.warehouse?.code) + return when (store) { + "3F" -> 0 + "2F" -> 1 + else -> 2 + } + } + + private fun floorTokenFromWarehouseCode(code: String?): String? { + val head = code?.trim()?.substringBefore("-") ?: return null + return normalizeStoreId(head) + } + + /** FP-MTMS Version Checklist | Functions Ref. No. 32 | v1.0.3 | 2026-11-02 + * Closest expiry first; same day prefers 3F, then 2F. + */ + private fun compareSuggestedLots(a: InventoryLotLine, b: InventoryLotLine): Int { + val byExpiry = compareValues(a.inventoryLot?.expiryDate, b.inventoryLot?.expiryDate) + if (byExpiry != 0) return byExpiry + return suggestionFloorRank(a).compareTo(suggestionFloorRank(b)) } private fun effectiveExcludeWarehouseCodes(raw: List?): Set { @@ -168,7 +206,7 @@ open class SuggestedPickLotWorkbenchService( } /** - * FP-MTMS Version Checklist | Functions Ref. No. 32 | v1.0.2 | 2026-08-03 + * FP-MTMS Version Checklist | Functions Ref. No. 32 | v1.0.3 | 2026-11-02 * Build suggestions with no-hold rule: * available = inQty - outQty (ignore holdQty). * When [desired] is a single segment, reuses one SPL row (max id) and repoints lot + qty (switch-lot without new id). @@ -204,7 +242,7 @@ open class SuggestedPickLotWorkbenchService( .filter { it.status == InventoryLotLineStatus.AVAILABLE } .filter { it.inventoryLot?.expiryDate?.isBefore(today) != true } .filter { !isWarehouseExcluded(it, excludeSet) } - .sortedBy { it.inventoryLot?.expiryDate } + .sortedWith(this::compareSuggestedLots) .groupBy { it.inventoryLot?.item?.id } var created = 0 @@ -301,7 +339,7 @@ open class SuggestedPickLotWorkbenchService( } /** - * FP-MTMS Version Checklist | Functions Ref. No. 32 | v1.0.2 | 2026-08-03 + * FP-MTMS Version Checklist | Functions Ref. No. 32 | v1.0.3 | 2026-11-02 * Same no-hold allocation as [rebuildNoHoldSuggestionsForPickOrder], but only for one [pickOrderLineId]. * Used by workbench scan-pick to avoid rebuilding suggestions for every line on the pick order. */ @@ -365,7 +403,7 @@ open class SuggestedPickLotWorkbenchService( if (storeKey == null) true else normalizeStoreId(lot.warehouse?.store_id) == storeKey } - .sortedBy { it.inventoryLot?.expiryDate } + .sortedWith(this::compareSuggestedLots) val desired = buildDesiredAllocations(lots, remaining, pickOrderLineId) val created = syncSuggestedPickLotsForLine(lineFresh, desired) @@ -393,7 +431,7 @@ open class SuggestedPickLotWorkbenchService( } /** - * FP-MTMS Version Checklist | Functions Ref. No. 32 | v1.0.2 | 2026-08-03 + * FP-MTMS Version Checklist | Functions Ref. No. 32 | v1.0.3 | 2026-11-02 * Workbench explicit-qty remainder support (single-next-lot): * Create exactly ONE suggested lot row for this POL with [targetQty] pointing to the next available lot. * When [storeId] is provided, the next lot must be within the same store (warehouse.store_id). @@ -441,7 +479,7 @@ open class SuggestedPickLotWorkbenchService( if (storeKey == null) true else normalizeStoreId(lot.warehouse?.store_id) == storeKey } - .sortedBy { it.inventoryLot?.expiryDate } + .sortedWith(this::compareSuggestedLots) .firstOrNull() val desired = listOf(nextLot to targetQty) @@ -454,7 +492,7 @@ open class SuggestedPickLotWorkbenchService( } /** - * FP-MTMS Version Checklist | Functions Ref. No. 32 | v1.0.2 | 2026-08-03 + * FP-MTMS Version Checklist | Functions Ref. No. 32 | v1.0.3 | 2026-11-02 * Workbench explicit-qty split support: * Set suggestions for this POL to an exact [targetQty] (independent of POL.qty). * - If [targetQty] <= 0: soft-delete all active suggestions for the line. @@ -491,7 +529,7 @@ open class SuggestedPickLotWorkbenchService( .filter { it.inventoryLot?.expiryDate?.isBefore(today) != true } .filter { !isWarehouseExcluded(it, excludeSet) } .filter { matchesPolUom(it, line) } - .sortedBy { it.inventoryLot?.expiryDate } + .sortedWith(this::compareSuggestedLots) val desired = buildDesiredAllocations(lots, targetQty, pickOrderLineId) val created = syncSuggestedPickLotsForLine(line, desired)