Bladeren bron

update

item_tracing
CANCERYS\kw093 2 maanden geleden
bovenliggende
commit
02a58538e6
5 gewijzigde bestanden met toevoegingen van 269 en 7 verwijderingen
  1. +44
    -6
      src/main/java/com/ffii/fpsms/modules/master/service/ItemUomService.kt
  2. +27
    -0
      src/main/java/com/ffii/fpsms/modules/stock/service/StockInLineService.kt
  3. +109
    -0
      src/main/java/com/ffii/fpsms/modules/stock/service/StockQtyRoundSupport.kt
  4. +7
    -1
      src/main/java/com/ffii/fpsms/modules/stock/web/model/SaveStockInRequest.kt
  5. +82
    -0
      src/test/kotlin/com/ffii/fpsms/modules/stock/service/StockQtyRoundSupportTest.kt

+ 44
- 6
src/main/java/com/ffii/fpsms/modules/master/service/ItemUomService.kt Bestand weergeven

@@ -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



+ 27
- 0
src/main/java/com/ffii/fpsms/modules/stock/service/StockInLineService.kt Bestand weergeven

@@ -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()


+ 109
- 0
src/main/java/com/ffii/fpsms/modules/stock/service/StockQtyRoundSupport.kt Bestand weergeven

@@ -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" }
}
}

+ 7
- 1
src/main/java/com/ffii/fpsms/modules/stock/web/model/SaveStockInRequest.kt Bestand weergeven

@@ -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 (


+ 82
- 0
src/test/kotlin/com/ffii/fpsms/modules/stock/service/StockQtyRoundSupportTest.kt Bestand weergeven

@@ -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箱"))
}
}

Laden…
Annuleren
Opslaan