| Автор | 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 java.time.LocalTime | ||||
| import com.ffii.fpsms.modules.pickOrder.entity.Truck | import com.ffii.fpsms.modules.pickOrder.entity.Truck | ||||
| import com.ffii.fpsms.modules.stock.entity.InventoryLotLineRepository | 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.StockOutRepository | ||||
| import com.ffii.fpsms.modules.stock.entity.StockOutLIneRepository | import com.ffii.fpsms.modules.stock.entity.StockOutLIneRepository | ||||
| import com.ffii.fpsms.modules.stock.entity.StockOut | 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.master.entity.Items | ||||
| import com.ffii.fpsms.modules.pickOrder.entity.PickOrderLine | import com.ffii.fpsms.modules.pickOrder.entity.PickOrderLine | ||||
| import com.ffii.fpsms.modules.stock.entity.InventoryLotLine | 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 com.ffii.fpsms.modules.stock.entity.projection.StockOutLineInfo | ||||
| import java.util.Locale | import java.util.Locale | ||||
| import org.slf4j.Logger | import org.slf4j.Logger | ||||
| @@ -110,6 +110,7 @@ open class DeliveryOrderService( | |||||
| private val pickOrderRepository: PickOrderRepository, | private val pickOrderRepository: PickOrderRepository, | ||||
| private val suggestedPickLotService: SuggestedPickLotService, | private val suggestedPickLotService: SuggestedPickLotService, | ||||
| private val inventoryLotLineRepository: InventoryLotLineRepository, | private val inventoryLotLineRepository: InventoryLotLineRepository, | ||||
| private val inventoryRepository: InventoryRepository, | |||||
| private val stockOutRepository: StockOutRepository, | private val stockOutRepository: StockOutRepository, | ||||
| private val stockOutLineRepository: StockOutLIneRepository, | private val stockOutLineRepository: StockOutLIneRepository, | ||||
| private val pickOrderLineRepository: PickOrderLineRepository, | private val pickOrderLineRepository: PickOrderLineRepository, | ||||
| @@ -481,14 +482,18 @@ open class DeliveryOrderService( | |||||
| return deliveryOrderRepository.findByIdAndDeletedIsFalse(id); | 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 | 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 | val itemId = line.item?.id ?: return@mapNotNull null | ||||
| itemId to (line.qty ?: BigDecimal.ZERO) | itemId to (line.qty ?: BigDecimal.ZERO) | ||||
| }.toMap() | }.toMap() | ||||
| @@ -632,9 +639,14 @@ open class DeliveryOrderService( | |||||
| isExtra = deliveryOrder.isExtra, | isExtra = deliveryOrder.isExtra, | ||||
| handlerName = handlerName, | handlerName = handlerName, | ||||
| truckLaneCode = truckLaneCode, | truckLaneCode = truckLaneCode, | ||||
| deliveryOrderLines = deliveryOrder.deliveryOrderLines.filter { it.deleted != true }.map { line -> | |||||
| deliveryOrderLines = activeLines.map { line -> | |||||
| val itemId = line.item?.id | 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 requiredQty = line.qty ?: BigDecimal.ZERO | ||||
| val availableStatus = | val availableStatus = | ||||
| if (stockQty >= requiredQty) "available" else "insufficient" | if (stockQty >= requiredQty) "available" else "insufficient" | ||||
| @@ -111,14 +111,14 @@ WHERE ill.id = :id | |||||
| item.code AS itemCode, | item.code AS itemCode, | ||||
| item.name AS itemName, | item.name AS itemName, | ||||
| item.type AS itemType, | 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.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, | CAST(uom.id AS long) AS uomId, | ||||
| uom.code AS uomCode, | uom.code AS uomCode, | ||||
| uom.udfudesc AS uomUdfudesc, | uom.udfudesc AS uomUdfudesc, | ||||
| uom.udfShortDesc AS uomShortDesc, | 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.latestMarketUnitPrice) AS latestMarketUnitPrice, | ||||
| MAX(item.latestMupUpdatedDate) AS latestMupUpdatedDate | MAX(item.latestMupUpdatedDate) AS latestMupUpdatedDate | ||||
| FROM InventoryLotLine ill | 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( | open fun searchInventoriesByLatestInventory( | ||||
| request: SearchInventoryRequest, | request: SearchInventoryRequest, | ||||
| @@ -276,14 +278,14 @@ open class InventoryService( | |||||
| item.code AS itemCode, | item.code AS itemCode, | ||||
| item.name AS itemName, | item.name AS itemName, | ||||
| item.type AS itemType, | 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.id AS uomId, | ||||
| uom.code AS uomCode, | uom.code AS uomCode, | ||||
| uom.udfudesc AS uomUdfudesc, | uom.udfudesc AS uomUdfudesc, | ||||
| uom.udfShortDesc AS uomShortDesc, | 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.latestMarketUnitPrice) AS latestMarketUnitPrice, | ||||
| MAX(item.latestMupUpdatedDate) AS latestMupUpdatedDate | MAX(item.latestMupUpdatedDate) AS latestMupUpdatedDate | ||||
| FROM inventory_lot_line ill | 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")) | |||||
| } | |||||
| } | |||||