From b36c68666aa96e1076bb471e1d6bb40e8ec78809 Mon Sep 17 00:00:00 2001 From: "kelvin.yau" Date: Mon, 5 Oct 2026 12:50:08 +0800 Subject: [PATCH] Fix DO stockQty and Location Search to use per-stock-UOM buckets. Stop mixing sibling UOMs on DO detail and keep Location available as onHand minus unavailable so hold is not subtracted. Co-authored-by: Cursor --- .../service/DeliveryOrderService.kt | 44 ++++++++++++------- .../entity/InventoryLotLineRepository.kt | 6 +-- .../service/InventoryLocationSearchQty.kt | 26 +++++++++++ .../modules/stock/service/InventoryService.kt | 14 +++--- .../service/DoLineStockQtyBucketTest.kt | 25 +++++++++++ .../service/InventoryLocationSearchQtyTest.kt | 31 +++++++++++++ 6 files changed, 121 insertions(+), 25 deletions(-) create mode 100644 src/main/java/com/ffii/fpsms/modules/stock/service/InventoryLocationSearchQty.kt create mode 100644 src/test/kotlin/com/ffii/fpsms/modules/deliveryOrder/service/DoLineStockQtyBucketTest.kt create mode 100644 src/test/kotlin/com/ffii/fpsms/modules/stock/service/InventoryLocationSearchQtyTest.kt diff --git a/src/main/java/com/ffii/fpsms/modules/deliveryOrder/service/DeliveryOrderService.kt b/src/main/java/com/ffii/fpsms/modules/deliveryOrder/service/DeliveryOrderService.kt index cee583ca..bf7d0677 100644 --- a/src/main/java/com/ffii/fpsms/modules/deliveryOrder/service/DeliveryOrderService.kt +++ b/src/main/java/com/ffii/fpsms/modules/deliveryOrder/service/DeliveryOrderService.kt @@ -43,6 +43,7 @@ import com.ffii.fpsms.modules.pickOrder.entity.TruckRepository import java.time.LocalTime import com.ffii.fpsms.modules.pickOrder.entity.Truck import com.ffii.fpsms.modules.stock.entity.InventoryLotLineRepository +import com.ffii.fpsms.modules.stock.entity.InventoryRepository import com.ffii.fpsms.modules.stock.entity.StockOutRepository import com.ffii.fpsms.modules.stock.entity.StockOutLIneRepository import com.ffii.fpsms.modules.stock.entity.StockOut @@ -91,7 +92,6 @@ import com.ffii.fpsms.modules.deliveryOrder.entity.models.DeliveryOrderLineInfo import com.ffii.fpsms.modules.master.entity.Items import com.ffii.fpsms.modules.pickOrder.entity.PickOrderLine import com.ffii.fpsms.modules.stock.entity.InventoryLotLine -import com.ffii.fpsms.modules.stock.entity.enum.InventoryLotLineStatus import com.ffii.fpsms.modules.stock.entity.projection.StockOutLineInfo import java.util.Locale import org.slf4j.Logger @@ -110,6 +110,7 @@ open class DeliveryOrderService( private val pickOrderRepository: PickOrderRepository, private val suggestedPickLotService: SuggestedPickLotService, private val inventoryLotLineRepository: InventoryLotLineRepository, + private val inventoryRepository: InventoryRepository, private val stockOutRepository: StockOutRepository, private val stockOutLineRepository: StockOutLIneRepository, private val pickOrderLineRepository: PickOrderLineRepository, @@ -481,14 +482,18 @@ open class DeliveryOrderService( return deliveryOrderRepository.findByIdAndDeletedIsFalse(id); } */ - private fun computeAvailableStockQtyForItem(itemId: Long): BigDecimal { - val availableLots = inventoryLotLineRepository - .findAllByInventoryLotItemIdAndStatus(itemId, InventoryLotLineStatus.AVAILABLE) - return availableLots.sumOf { lot -> - (lot.inQty ?: BigDecimal.ZERO) - .minus(lot.outQty ?: BigDecimal.ZERO) - .minus(lot.holdQty ?: BigDecimal.ZERO) - } + /** + * DO line stock figure: one inventory bucket `(itemId, stockUomId)` where + * [stockUomId] is the line's UOM (`uom_conversion.id`). + * `available = onHand − unavailable` (hold not subtracted), same as Item Search. + * Must not sum lot lines across all stock UOMs of the item. + */ + private fun computeAvailableStockQtyForItem(itemId: Long, stockUomId: Long?): BigDecimal { + if (stockUomId == null) return BigDecimal.ZERO + val bucket = inventoryRepository.findByItemIdAndStockUomIdAndDeletedIsFalse(itemId, stockUomId) + ?: return BigDecimal.ZERO + return (bucket.onHandQty ?: BigDecimal.ZERO) + .minus(bucket.unavailableQty ?: BigDecimal.ZERO) } /** @@ -603,11 +608,13 @@ open class DeliveryOrderService( null } - val itemIds = deliveryOrder.deliveryOrderLines.filter { it.deleted != true }.mapNotNull { it.item?.id }.distinct() - val stockQtyByItemId = itemIds.associateWith { itemId -> - computeAvailableStockQtyForItem(itemId) - } - val fallbackQtyByItemId = deliveryOrder.deliveryOrderLines.filter { it.deleted != true }.mapNotNull { line -> + val activeLines = deliveryOrder.deliveryOrderLines.filter { it.deleted != true } + val stockQtyByBucket = activeLines.mapNotNull { line -> + val itemId = line.item?.id ?: return@mapNotNull null + val stockUomId = line.uom?.id ?: return@mapNotNull null + (itemId to stockUomId) to computeAvailableStockQtyForItem(itemId, stockUomId) + }.toMap() + val fallbackQtyByItemId = activeLines.mapNotNull { line -> val itemId = line.item?.id ?: return@mapNotNull null itemId to (line.qty ?: BigDecimal.ZERO) }.toMap() @@ -632,9 +639,14 @@ open class DeliveryOrderService( isExtra = deliveryOrder.isExtra, handlerName = handlerName, truckLaneCode = truckLaneCode, - deliveryOrderLines = deliveryOrder.deliveryOrderLines.filter { it.deleted != true }.map { line -> + deliveryOrderLines = activeLines.map { line -> val itemId = line.item?.id - val stockQty = itemId?.let { stockQtyByItemId[it] } ?: BigDecimal.ZERO + val stockUomId = line.uom?.id + val stockQty = if (itemId != null && stockUomId != null) { + stockQtyByBucket[itemId to stockUomId] ?: BigDecimal.ZERO + } else { + BigDecimal.ZERO + } val requiredQty = line.qty ?: BigDecimal.ZERO val availableStatus = if (stockQty >= requiredQty) "available" else "insufficient" diff --git a/src/main/java/com/ffii/fpsms/modules/stock/entity/InventoryLotLineRepository.kt b/src/main/java/com/ffii/fpsms/modules/stock/entity/InventoryLotLineRepository.kt index 75b60c7d..baf034fa 100644 --- a/src/main/java/com/ffii/fpsms/modules/stock/entity/InventoryLotLineRepository.kt +++ b/src/main/java/com/ffii/fpsms/modules/stock/entity/InventoryLotLineRepository.kt @@ -111,14 +111,14 @@ WHERE ill.id = :id item.code AS itemCode, item.name AS itemName, item.type AS itemType, - SUM(COALESCE(ill.inQty, 0)) AS onHandQty, + SUM(COALESCE(ill.inQty, 0) - COALESCE(ill.outQty, 0)) AS onHandQty, SUM(COALESCE(ill.holdQty, 0)) AS onHoldQty, - SUM(COALESCE(ill.inQty, 0) - COALESCE(ill.outQty, 0) - COALESCE(ill.holdQty, 0)) AS availableQty, + SUM(COALESCE(ill.inQty, 0) - COALESCE(ill.outQty, 0)) AS availableQty, CAST(uom.id AS long) AS uomId, uom.code AS uomCode, uom.udfudesc AS uomUdfudesc, uom.udfShortDesc AS uomShortDesc, - SUM(COALESCE(ill.inQty, 0) - COALESCE(ill.outQty, 0) - COALESCE(ill.holdQty, 0)) AS qtyPerSmallestUnit, + SUM(COALESCE(ill.inQty, 0) - COALESCE(ill.outQty, 0)) AS qtyPerSmallestUnit, MAX(item.latestMarketUnitPrice) AS latestMarketUnitPrice, MAX(item.latestMupUpdatedDate) AS latestMupUpdatedDate FROM InventoryLotLine ill diff --git a/src/main/java/com/ffii/fpsms/modules/stock/service/InventoryLocationSearchQty.kt b/src/main/java/com/ffii/fpsms/modules/stock/service/InventoryLocationSearchQty.kt new file mode 100644 index 00000000..5037017c --- /dev/null +++ b/src/main/java/com/ffii/fpsms/modules/stock/service/InventoryLocationSearchQty.kt @@ -0,0 +1,26 @@ +package com.ffii.fpsms.modules.stock.service + +import java.math.BigDecimal + +/** + * Location Search qty rules aligned with inventory **bucket** display + * (`available = onHand − unavailable`; hold is separate, not subtracted from available). + * + * Location Search only aggregates `status = available` lot lines at the filtered location, + * so unavailable at that slice is 0 and available collapses to physical remain `in − out`. + */ +object InventoryLocationSearchQty { + fun onHand(inQty: BigDecimal?, outQty: BigDecimal?): BigDecimal { + val inn = inQty ?: BigDecimal.ZERO + val out = outQty ?: BigDecimal.ZERO + return inn.subtract(out) + } + + /** Bucket-aligned available for available-status lines: physical remain (hold not subtracted). */ + fun available(inQty: BigDecimal?, outQty: BigDecimal?): BigDecimal = onHand(inQty, outQty) + + /** SQL fragments used by [InventoryService.buildLocationSearchUnionSql]. */ + const val SQL_ON_HAND = "SUM(COALESCE(ill.inQty, 0) - COALESCE(ill.outQty, 0))" + const val SQL_AVAILABLE = "SUM(COALESCE(ill.inQty, 0) - COALESCE(ill.outQty, 0))" + const val SQL_ON_HOLD = "SUM(COALESCE(ill.holdQty, 0))" +} diff --git a/src/main/java/com/ffii/fpsms/modules/stock/service/InventoryService.kt b/src/main/java/com/ffii/fpsms/modules/stock/service/InventoryService.kt index 6570d0cb..5af3b38f 100644 --- a/src/main/java/com/ffii/fpsms/modules/stock/service/InventoryService.kt +++ b/src/main/java/com/ffii/fpsms/modules/stock/service/InventoryService.kt @@ -101,8 +101,10 @@ open class InventoryService( } /** - * Location Search: one row per item + lot-line UoM, plus current master stockUnit - * as a 0-qty row when that UoM has no matching lots. + * Location Search: one row per item + lot-line stock UoM (bucket key), plus current + * master stockUnit as a 0-qty row when that UoM has no matching lots. + * Qty formula matches inventory bucket display: available = onHand − unavailable + * (hold shown separately; not subtracted). See [InventoryLocationSearchQty]. */ open fun searchInventoriesByLatestInventory( request: SearchInventoryRequest, @@ -276,14 +278,14 @@ open class InventoryService( item.code AS itemCode, item.name AS itemName, item.type AS itemType, - SUM(COALESCE(ill.inQty, 0)) AS onHandQty, - SUM(COALESCE(ill.holdQty, 0)) AS onHoldQty, - SUM(COALESCE(ill.inQty, 0) - COALESCE(ill.outQty, 0) - COALESCE(ill.holdQty, 0)) AS availableQty, + ${InventoryLocationSearchQty.SQL_ON_HAND} AS onHandQty, + ${InventoryLocationSearchQty.SQL_ON_HOLD} AS onHoldQty, + ${InventoryLocationSearchQty.SQL_AVAILABLE} AS availableQty, uom.id AS uomId, uom.code AS uomCode, uom.udfudesc AS uomUdfudesc, uom.udfShortDesc AS uomShortDesc, - SUM(COALESCE(ill.inQty, 0) - COALESCE(ill.outQty, 0) - COALESCE(ill.holdQty, 0)) AS qtyPerSmallestUnit, + ${InventoryLocationSearchQty.SQL_AVAILABLE} AS qtyPerSmallestUnit, MAX(item.latestMarketUnitPrice) AS latestMarketUnitPrice, MAX(item.latestMupUpdatedDate) AS latestMupUpdatedDate FROM inventory_lot_line ill diff --git a/src/test/kotlin/com/ffii/fpsms/modules/deliveryOrder/service/DoLineStockQtyBucketTest.kt b/src/test/kotlin/com/ffii/fpsms/modules/deliveryOrder/service/DoLineStockQtyBucketTest.kt new file mode 100644 index 00000000..eb5a2393 --- /dev/null +++ b/src/test/kotlin/com/ffii/fpsms/modules/deliveryOrder/service/DoLineStockQtyBucketTest.kt @@ -0,0 +1,25 @@ +package com.ffii.fpsms.modules.deliveryOrder.service + +import org.junit.jupiter.api.Assertions.assertEquals +import org.junit.jupiter.api.Test +import java.math.BigDecimal + +/** + * Documents DO detail stockQty rule after bucket fix: + * available = onHand − unavailable for one (itemId, stockUomId); never sum sibling UOMs. + */ +class DoLineStockQtyBucketTest { + + private fun bucketAvailable(onHand: BigDecimal?, unavailable: BigDecimal?): BigDecimal = + (onHand ?: BigDecimal.ZERO).minus(unavailable ?: BigDecimal.ZERO) + + @Test + fun two_buckets_must_not_be_added() { + // MA0879-style: 箱 avail 12 + 個 avail 760 — DO line in 箱 must show 12, not 772 + val box = bucketAvailable(BigDecimal("15"), BigDecimal("3")) + val each = bucketAvailable(BigDecimal("880"), BigDecimal("120")) + assertEquals(BigDecimal("12"), box) + assertEquals(BigDecimal("760"), each) + assertEquals(BigDecimal("772"), box.add(each)) // old bug total — must not be returned for either line + } +} diff --git a/src/test/kotlin/com/ffii/fpsms/modules/stock/service/InventoryLocationSearchQtyTest.kt b/src/test/kotlin/com/ffii/fpsms/modules/stock/service/InventoryLocationSearchQtyTest.kt new file mode 100644 index 00000000..d9e6c7f6 --- /dev/null +++ b/src/test/kotlin/com/ffii/fpsms/modules/stock/service/InventoryLocationSearchQtyTest.kt @@ -0,0 +1,31 @@ +package com.ffii.fpsms.modules.stock.service + +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 + +class InventoryLocationSearchQtyTest { + + @Test + fun onHand_and_available_are_physical_remain_not_minus_hold() { + // in=10, out=3 → remain 7; hold must not shrink Location Search available + assertEquals(BigDecimal("7"), InventoryLocationSearchQty.onHand(BigDecimal("10"), BigDecimal("3"))) + assertEquals(BigDecimal("7"), InventoryLocationSearchQty.available(BigDecimal("10"), BigDecimal("3"))) + } + + @Test + fun sql_fragments_match_bucket_not_pickable_formula() { + assertEquals( + "SUM(COALESCE(ill.inQty, 0) - COALESCE(ill.outQty, 0))", + InventoryLocationSearchQty.SQL_ON_HAND, + ) + assertEquals( + "SUM(COALESCE(ill.inQty, 0) - COALESCE(ill.outQty, 0))", + InventoryLocationSearchQty.SQL_AVAILABLE, + ) + assertFalse(InventoryLocationSearchQty.SQL_AVAILABLE.contains("holdQty")) + assertTrue(InventoryLocationSearchQty.SQL_ON_HOLD.contains("holdQty")) + } +}