| @@ -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 | |||
| @@ -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() | |||
| @@ -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 warehouseId: Long? = 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 ( | |||
| @@ -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箱")) | |||
| } | |||
| } | |||