From e7889a0e5998d9d792328e00cad4349bc374e865 Mon Sep 17 00:00:00 2001 From: "CANCERYS\\kw093" Date: Thu, 15 Oct 2026 14:20:00 +0800 Subject: [PATCH] po round up/down backend part --- .../stock/service/StockInLineService.kt | 26 ++++ .../stock/service/StockQtyRoundSupport.kt | 114 ++++++++++++++++++ .../stock/web/model/SaveStockInRequest.kt | 8 +- 3 files changed, 147 insertions(+), 1 deletion(-) create mode 100644 src/main/java/com/ffii/fpsms/modules/stock/service/StockQtyRoundSupport.kt 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 44e90b06..a68a082b 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 @@ -332,6 +332,29 @@ 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 { @@ -453,6 +476,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) @@ -472,6 +496,7 @@ open class StockInLineService( errorPosition = null, entity = lineInfo ) + } @Throws(IOException::class) @@ -1034,6 +1059,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..d7955c7e --- /dev/null +++ b/src/main/java/com/ffii/fpsms/modules/stock/service/StockQtyRoundSupport.kt @@ -0,0 +1,114 @@ +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 + } + + /** FP-MTMS Version Checklist | Functions Ref. No. 56 | v1.0.6 | 2026-10-14 */ + 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) + } + + /** 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 + return try { + Mode.valueOf(value) + } catch (_: IllegalArgumentException) { + throw BadRequestException("Invalid stockQtyRoundMode: $raw") + } + } + + /** 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) + 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) + } + } + } + + /** FP-MTMS Version Checklist | Functions Ref. No. 56 | v1.0.6 | 2026-10-14 */ + 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)}]" + } + + /** 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" + 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 (