From 02a58538e697ef146f724d8d44ed8c57df119e6b Mon Sep 17 00:00:00 2001 From: "CANCERYS\\kw093" Date: Tue, 1 Sep 2026 11:28:17 +0800 Subject: [PATCH] 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箱")) + } +}