Explorar el Código

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]>
fix負數倉
kelvin.yau hace 1 mes
padre
commit
b36c68666a
Se han modificado 6 ficheros con 121 adiciones y 25 borrados
  1. +28
    -16
      src/main/java/com/ffii/fpsms/modules/deliveryOrder/service/DeliveryOrderService.kt
  2. +3
    -3
      src/main/java/com/ffii/fpsms/modules/stock/entity/InventoryLotLineRepository.kt
  3. +26
    -0
      src/main/java/com/ffii/fpsms/modules/stock/service/InventoryLocationSearchQty.kt
  4. +8
    -6
      src/main/java/com/ffii/fpsms/modules/stock/service/InventoryService.kt
  5. +25
    -0
      src/test/kotlin/com/ffii/fpsms/modules/deliveryOrder/service/DoLineStockQtyBucketTest.kt
  6. +31
    -0
      src/test/kotlin/com/ffii/fpsms/modules/stock/service/InventoryLocationSearchQtyTest.kt

+ 28
- 16
src/main/java/com/ffii/fpsms/modules/deliveryOrder/service/DeliveryOrderService.kt Ver fichero

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


+ 3
- 3
src/main/java/com/ffii/fpsms/modules/stock/entity/InventoryLotLineRepository.kt Ver fichero

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


+ 26
- 0
src/main/java/com/ffii/fpsms/modules/stock/service/InventoryLocationSearchQty.kt Ver fichero

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

+ 8
- 6
src/main/java/com/ffii/fpsms/modules/stock/service/InventoryService.kt Ver fichero

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


+ 25
- 0
src/test/kotlin/com/ffii/fpsms/modules/deliveryOrder/service/DoLineStockQtyBucketTest.kt Ver fichero

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

+ 31
- 0
src/test/kotlin/com/ffii/fpsms/modules/stock/service/InventoryLocationSearchQtyTest.kt Ver fichero

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

Cargando…
Cancelar
Guardar