| @@ -104,6 +104,9 @@ open class ItemUomService( | |||||
| open fun convertPurchaseQtyToStockQty(itemId: Long, purchaseQty: BigDecimal): BigDecimal { | open fun convertPurchaseQtyToStockQty(itemId: Long, purchaseQty: BigDecimal): BigDecimal { | ||||
| val purchaseUnit = findPurchaseUnitByItemId(itemId) ?: return purchaseQty; | val purchaseUnit = findPurchaseUnitByItemId(itemId) ?: return purchaseQty; | ||||
| val stockUnit = findStockUnitByItemId(itemId) ?: return purchaseQty; | val stockUnit = findStockUnitByItemId(itemId) ?: return purchaseQty; | ||||
| if (sameUomId(purchaseUnit, stockUnit)) { | |||||
| return purchaseQty.setScale(0, RoundingMode.UP) | |||||
| } | |||||
| val one = BigDecimal.ONE; | val one = BigDecimal.ONE; | ||||
| // IMPORTANT: | // IMPORTANT: | ||||
| @@ -129,6 +132,9 @@ open class ItemUomService( | |||||
| open fun convertPurchaseQtyToStockQtyRoundDown(itemId: Long, purchaseQty: BigDecimal): BigDecimal { | open fun convertPurchaseQtyToStockQtyRoundDown(itemId: Long, purchaseQty: BigDecimal): BigDecimal { | ||||
| val purchaseUnit = findPurchaseUnitByItemId(itemId) ?: return purchaseQty | val purchaseUnit = findPurchaseUnitByItemId(itemId) ?: return purchaseQty | ||||
| val stockUnit = findStockUnitByItemId(itemId) ?: return purchaseQty | val stockUnit = findStockUnitByItemId(itemId) ?: return purchaseQty | ||||
| if (sameUomId(purchaseUnit, stockUnit)) { | |||||
| return purchaseQty.setScale(0, RoundingMode.DOWN) | |||||
| } | |||||
| val one = BigDecimal.ONE | val one = BigDecimal.ONE | ||||
| // Use high precision for intermediate steps, and round down only at the end. | // 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 { | open fun convertStockQtyToPurchaseQty(itemId: Long, stockQty: BigDecimal): BigDecimal { | ||||
| val purchaseUnit = findPurchaseUnitByItemId(itemId) ?: return stockQty | val purchaseUnit = findPurchaseUnitByItemId(itemId) ?: return stockQty | ||||
| val stockUnit = findStockUnitByItemId(itemId) ?: return stockQty | val stockUnit = findStockUnitByItemId(itemId) ?: return stockQty | ||||
| if (sameUomId(purchaseUnit, stockUnit)) { | |||||
| return stockQty.setScale(0, RoundingMode.UP) | |||||
| } | |||||
| val one = BigDecimal.ONE | val one = BigDecimal.ONE | ||||
| val calcScale = 10 | val calcScale = 10 | ||||
| @@ -168,8 +177,11 @@ open class ItemUomService( | |||||
| * Returns source qty when source/purchase unit mapping is not found. | * Returns source qty when source/purchase unit mapping is not found. | ||||
| */ | */ | ||||
| open fun convertQtyToPurchaseQty(itemId: Long, uomId: Long, sourceQty: BigDecimal): BigDecimal { | open fun convertQtyToPurchaseQty(itemId: Long, uomId: Long, sourceQty: BigDecimal): BigDecimal { | ||||
| val sourceItemUom = findFirstByItemIdAndUomId(itemId, uomId) ?: return sourceQty | |||||
| val purchaseUnit = findPurchaseUnitByItemId(itemId) ?: 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 one = BigDecimal.ONE | ||||
| val calcScale = 10 | val calcScale = 10 | ||||
| @@ -185,9 +197,12 @@ open class ItemUomService( | |||||
| } | } | ||||
| open fun convertQtyToStockQty(itemId: Long, uomId: Long, sourceQty: BigDecimal): BigDecimal { | 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 one = BigDecimal.ONE; | ||||
| val calcScale = 10 | val calcScale = 10 | ||||
| @@ -208,8 +223,12 @@ open class ItemUomService( | |||||
| * Same as convertQtyToStockQty but rounds down the final stock qty to integer. | * Same as convertQtyToStockQty but rounds down the final stock qty to integer. | ||||
| */ | */ | ||||
| open fun convertQtyToStockQtyRoundDown(itemId: Long, uomId: Long, sourceQty: BigDecimal): BigDecimal { | 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 itemUom = findFirstByItemIdAndUomId(itemId, uomId) ?: return sourceQty | ||||
| val stockUnit = findStockUnitByItemId(itemId) ?: return BigDecimal.ZERO | |||||
| if (stockUnit == null) return BigDecimal.ZERO | |||||
| val one = BigDecimal.ONE | val one = BigDecimal.ONE | ||||
| val calcScale = 10 | val calcScale = 10 | ||||
| @@ -224,6 +243,18 @@ open class ItemUomService( | |||||
| return stockQty.setScale(0, RoundingMode.DOWN) | 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). | * 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 { | open fun convertPurchaseQtyToStockQtyPrecise(itemId: Long, purchaseQty: BigDecimal): BigDecimal { | ||||
| val purchaseUnit = findPurchaseUnitByItemId(itemId) ?: return purchaseQty | val purchaseUnit = findPurchaseUnitByItemId(itemId) ?: return purchaseQty | ||||
| val stockUnit = findStockUnitByItemId(itemId) ?: return purchaseQty | val stockUnit = findStockUnitByItemId(itemId) ?: return purchaseQty | ||||
| if (sameUomId(purchaseUnit, stockUnit)) { | |||||
| return finalizePreciseStockQty(stockUnit, purchaseQty) | |||||
| } | |||||
| val one = BigDecimal.ONE | val one = BigDecimal.ONE | ||||
| val calcScale = 10 | val calcScale = 10 | ||||
| @@ -266,8 +300,12 @@ open class ItemUomService( | |||||
| * Used for PO (M18 UOM) conversion where round-down is not allowed. | * Used for PO (M18 UOM) conversion where round-down is not allowed. | ||||
| */ | */ | ||||
| open fun convertQtyToStockQtyPrecise(itemId: Long, uomId: Long, sourceQty: BigDecimal): BigDecimal { | 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 itemUom = findFirstByItemIdAndUomId(itemId, uomId) ?: return sourceQty | ||||
| val stockUnit = findStockUnitByItemId(itemId) ?: return BigDecimal.ZERO | |||||
| if (stockUnit == null) return BigDecimal.ZERO | |||||
| val one = BigDecimal.ONE | val one = BigDecimal.ONE | ||||
| val calcScale = 10 | val calcScale = 10 | ||||
| @@ -331,6 +331,31 @@ open class StockInLineService( | |||||
| latestCode = latestCode, | 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) | @Throws(IOException::class) | ||||
| @Transactional | @Transactional | ||||
| open fun create(request: SaveStockInLineRequest): MessageResponse { | open fun create(request: SaveStockInLineRequest): MessageResponse { | ||||
| @@ -449,6 +474,7 @@ open class StockInLineService( | |||||
| this.type = "NOR" | this.type = "NOR" | ||||
| status = StockInLineStatus.PENDING.status | status = StockInLineStatus.PENDING.status | ||||
| } | } | ||||
| applyRequestedStockQtyRound(stockInLine, request, isCreate = true) | |||||
| if (jo != null) { | if (jo != null) { | ||||
| val planStartDate = jo.planStart?.toLocalDate() | val planStartDate = jo.planStart?.toLocalDate() | ||||
| stockInLine.lotNo = assignLotNoForJo(planStartDate) | stockInLine.lotNo = assignLotNoForJo(planStartDate) | ||||
| @@ -1030,6 +1056,7 @@ open class StockInLineService( | |||||
| this.expiryDate = stockInLine.expiryDate ?: request.expiryDate | this.expiryDate = stockInLine.expiryDate ?: request.expiryDate | ||||
| this.remarks = stockInLine.remarks ?: request.remarks | this.remarks = stockInLine.remarks ?: request.remarks | ||||
| } | } | ||||
| applyRequestedStockQtyRound(stockInLine, request, isCreate = false) | |||||
| _logStep("apply_fields", _tApplyFields) | _logStep("apply_fields", _tApplyFields) | ||||
| if (request.status == StockInLineStatus.RECEIVED.status) { | if (request.status == StockInLineStatus.RECEIVED.status) { | ||||
| val _tPutAwayTotal = System.nanoTime() | val _tPutAwayTotal = System.nanoTime() | ||||
| @@ -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<BigDecimal, String?> { | |||||
| 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" } | |||||
| } | |||||
| } | |||||
| @@ -63,7 +63,13 @@ data class SaveStockInLineRequest( | |||||
| var escalationLog: SaveEscalationLogRequest? = null, | var escalationLog: SaveEscalationLogRequest? = null, | ||||
| var warehouseId: Long? = null, | var warehouseId: Long? = null, | ||||
| var rejectQty: BigDecimal? = null, | var rejectQty: BigDecimal? = null, | ||||
| var inventoryLotLines: List<SaveInventoryLotLineForSil>? = null | |||||
| var inventoryLotLines: List<SaveInventoryLotLineForSil>? = 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 ( | data class SaveInventoryLotLineForSil ( | ||||
| @@ -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箱")) | |||||
| } | |||||
| } | |||||