| 作者 | SHA1 | 備註 | 提交日期 |
|---|---|---|---|
|
|
86b98b804c | Merge branch 'fix負數倉' of https://git.2fi-solutions.com/derek/FPSMS-backend into fix負數倉 | 1 月之前 |
|
|
b36c68666a |
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 <[email protected]> |
1 月之前 |
| @@ -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" | |||
| @@ -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 | |||
| @@ -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))" | |||
| } | |||
| @@ -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 | |||
| @@ -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 | |||
| } | |||
| } | |||
| @@ -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")) | |||
| } | |||
| } | |||