Browse Source

Cap Miss/Bad stock-out to lot physical remain and drop findByItemId fallback.

Prevent negative onHand via createStockOut/issue paths; resolve ledger inventory by stock-UOM bucket only, with unit coverage for the guard and ErrorHandler 45000→400.

Co-authored-by: Cursor <cursoragent@cursor.com>
fix負數倉
kelvin.yau 12 hours ago
parent
commit
e48b4f70e1
6 changed files with 302 additions and 3 deletions
  1. +8
    -2
      src/main/java/com/ffii/fpsms/modules/pickOrder/service/PickExecutionIssueService.kt
  2. +76
    -0
      src/main/java/com/ffii/fpsms/modules/stock/service/InventoryLotLineQtyGuard.kt
  3. +18
    -1
      src/main/java/com/ffii/fpsms/modules/stock/service/StockOutLineService.kt
  4. +41
    -0
      src/test/java/com/ffii/core/support/ErrorHandlerSqlSignalTest.java
  5. +82
    -0
      src/test/kotlin/com/ffii/fpsms/modules/stock/service/InventoryBucketResolverTest.kt
  6. +77
    -0
      src/test/kotlin/com/ffii/fpsms/modules/stock/service/InventoryLotLineQtyGuardTest.kt

+ 8
- 2
src/main/java/com/ffii/fpsms/modules/pickOrder/service/PickExecutionIssueService.kt View File

@@ -52,6 +52,8 @@ import com.ffii.fpsms.modules.deliveryOrder.entity.DeliveryOrderRepository
import com.ffii.fpsms.modules.deliveryOrder.enums.DeliveryOrderStatus
import com.ffii.fpsms.modules.stock.entity.SuggestPickLotRepository
import org.springframework.beans.factory.annotation.Value
import org.springframework.http.HttpStatus
import org.springframework.web.server.ResponseStatusException
import com.ffii.fpsms.modules.stock.web.model.BatchStockOutRequest
import com.ffii.fpsms.modules.stock.web.model.BatchStockOutLineRequest
@Service
@@ -2730,8 +2732,13 @@ open fun submitIssueWithQty(request: SubmitIssueWithQtyRequest): MessageResponse
inventoryLotLineRepository.findById(it).orElse(null)
}
val inventoryBeforeUpdate = inventoryBucketResolver.findInventoryBucket(request.itemId, lotLine)
?: inventoryRepository.findByItemId(request.itemId).orElse(null)
val onHandQtyBeforeUpdate = (inventoryBeforeUpdate?.onHandQty ?: BigDecimal.ZERO).toDouble()
if (lotLine != null && inventoryBeforeUpdate == null) {
throw ResponseStatusException(
HttpStatus.BAD_REQUEST,
"找不到對應庫存單位的庫存桶,無法提交不良品/短少。",
)
}
println("=== submitIssueWithQty: Before update ===")
println("Item ID: ${request.itemId}")
@@ -2945,7 +2952,6 @@ private fun bucketOnHandQty(itemId: Long, lotLine: InventoryLotLine?): Double {

private fun resolveLedgerInventory(itemId: Long, lotLine: InventoryLotLine?): Inventory? =
inventoryBucketResolver.findInventoryBucket(itemId, lotLine)
?: inventoryRepository.findByItemId(itemId).orElse(null)

private fun previousBalanceForInventory(inventory: Inventory): Double {
val latest = inventory.id?.let {


+ 76
- 0
src/main/java/com/ffii/fpsms/modules/stock/service/InventoryLotLineQtyGuard.kt View File

@@ -0,0 +1,76 @@
package com.ffii.fpsms.modules.stock.service

import org.springframework.http.HttpStatus
import org.springframework.web.server.ResponseStatusException
import java.math.BigDecimal

/**
* Shared lot-line remain rules.
*
* Option A triggers set inventory.onHand = Σ(in−out). Hold is lot metadata only.
* Pick / Miss / Bad / Expiry via [StockOutLineService.createStockOut] do
* `out += qty` and `hold = max(0, hold−qty)` — cap is **physical remain** `in−out`.
* Adjustment / TRF out-only paths keep hold unchanged — cap is `in−out−hold`.
*/
object InventoryLotLineQtyGuard {
fun physicalRemain(inQty: BigDecimal?, outQty: BigDecimal?): BigDecimal {
val i = inQty ?: BigDecimal.ZERO
val o = outQty ?: BigDecimal.ZERO
return i.subtract(o)
}

fun availableQty(
inQty: BigDecimal?,
outQty: BigDecimal?,
holdQty: BigDecimal?,
): BigDecimal {
val h = holdQty ?: BigDecimal.ZERO
return physicalRemain(inQty, outQty).subtract(h)
}

fun isIssueStockOutType(type: String?): Boolean {
val t = type?.trim()?.lowercase() ?: return false
return t == "miss" || t == "bad" || t == "expiry"
}

/** Cap for createStockOut / issue posting: qty ≤ in − out. */
fun requireQtyWithinPhysicalRemain(
inQty: BigDecimal?,
outQty: BigDecimal?,
qty: BigDecimal,
label: String = "數量",
) {
if (qty <= BigDecimal.ZERO) {
throw ResponseStatusException(HttpStatus.BAD_REQUEST, "${label}必須大於 0")
}
val remain = physicalRemain(inQty, outQty)
if (qty > remain) {
throw ResponseStatusException(
HttpStatus.BAD_REQUEST,
"${label}超過實體剩餘(remain=$remain, requested=$qty)。" +
"規則:requested ≤ inQty − outQty。",
)
}
}

/** Cap for adjustment / stock-transfer out-only: qty ≤ in − out − hold. */
fun requireQtyWithinAvailable(
inQty: BigDecimal?,
outQty: BigDecimal?,
holdQty: BigDecimal?,
qty: BigDecimal,
label: String = "數量",
) {
if (qty <= BigDecimal.ZERO) {
throw ResponseStatusException(HttpStatus.BAD_REQUEST, "${label}必須大於 0")
}
val available = availableQty(inQty, outQty, holdQty)
if (qty > available) {
throw ResponseStatusException(
HttpStatus.BAD_REQUEST,
"${label}超過可用量(availableQty=$available, requested=$qty)。" +
"規則:requested ≤ inQty − outQty − holdQty。",
)
}
}
}

+ 18
- 1
src/main/java/com/ffii/fpsms/modules/stock/service/StockOutLineService.kt View File

@@ -1580,9 +1580,20 @@ open fun newBatchSubmit(request: QrPickBatchSubmitRequest): MessageResponse {
open fun createStockOut(request: StockOutRequest): StockOutLine {
val inventoryLotLine = inventoryLotLineRepository.findById(request.inventoryLotLineId).orElseThrow()
val qtyBd = BigDecimal.valueOf(request.qty)
val label = when {
InventoryLotLineQtyGuard.isIssueStockOutType(request.type) ->
request.type!!.trim().replaceFirstChar { it.uppercase() } + " 數量"
else -> "出倉數量"
}
InventoryLotLineQtyGuard.requireQtyWithinPhysicalRemain(
inventoryLotLine.inQty,
inventoryLotLine.outQty,
qtyBd,
label,
)
val oldHold = inventoryLotLine.holdQty ?: BigDecimal.ZERO

// Increase outQty and release hold (pick / QR); inventory aggregates via trigger inventory_lot_line_AFTER_UPDATE
// Increase outQty and release hold (pick / QR / Miss / Bad); inventory aggregates via trigger
val updatedInventoryLotLine = inventoryLotLine.apply {
val currentOutQty = this.outQty ?: BigDecimal.ZERO
this.outQty = currentOutQty + qtyBd
@@ -2095,6 +2106,12 @@ fun applyStockOutLineDelta(
if (isIssuePosting) {
val latestLotLine = inventoryLotLineRepository.findById(lotLine.id!!).orElse(null)
if (latestLotLine != null) {
InventoryLotLineQtyGuard.requireQtyWithinPhysicalRemain(
latestLotLine.inQty,
latestLotLine.outQty,
deltaQty,
postingType.replaceFirstChar { it.uppercase() } + " 數量",
)
val prevStatus = latestLotLine.status
val currentHoldQty = latestLotLine.holdQty ?: BigDecimal.ZERO
val currentOutQty = latestLotLine.outQty ?: BigDecimal.ZERO


+ 41
- 0
src/test/java/com/ffii/core/support/ErrorHandlerSqlSignalTest.java View File

@@ -0,0 +1,41 @@
package com.ffii.core.support;

import static org.junit.jupiter.api.Assertions.assertEquals;
import static org.junit.jupiter.api.Assertions.assertTrue;

import java.sql.SQLException;

import org.junit.jupiter.api.Test;
import org.springframework.dao.DataIntegrityViolationException;
import org.springframework.http.HttpStatus;
import org.springframework.http.ResponseEntity;

import com.ffii.core.response.FailureRes;

class ErrorHandlerSqlSignalTest {

private final ErrorHandler handler = new ErrorHandler();

@Test
void sqlSignal_45000_returns_http_400_with_chinese_message() {
SQLException signal = new SQLException("同一批次不可混用不同庫存單位", "45000");
DataIntegrityViolationException wrapped =
new DataIntegrityViolationException("nested", new SQLException("wrapper", "HY000", signal));

ResponseEntity<?> response = handler.sqlSignal(wrapped);

assertEquals(HttpStatus.BAD_REQUEST, response.getStatusCode());
assertTrue(response.getBody() instanceof FailureRes);
assertEquals("同一批次不可混用不同庫存單位", ((FailureRes) response.getBody()).getError());
}

@Test
void sqlSignal_non_45000_falls_through_to_500() {
DataIntegrityViolationException plain =
new DataIntegrityViolationException("db down", new SQLException("gone", "08001"));

ResponseEntity<?> response = handler.sqlSignal(plain);

assertEquals(HttpStatus.INTERNAL_SERVER_ERROR, response.getStatusCode());
}
}

+ 82
- 0
src/test/kotlin/com/ffii/fpsms/modules/stock/service/InventoryBucketResolverTest.kt View File

@@ -0,0 +1,82 @@
package com.ffii.fpsms.modules.stock.service

import com.ffii.fpsms.modules.master.entity.ItemUom
import com.ffii.fpsms.modules.master.entity.ItemUomRespository
import com.ffii.fpsms.modules.master.entity.UomConversion
import com.ffii.fpsms.modules.stock.entity.Inventory
import com.ffii.fpsms.modules.stock.entity.InventoryLotLine
import com.ffii.fpsms.modules.stock.entity.InventoryRepository
import org.junit.jupiter.api.Assertions.assertEquals
import org.junit.jupiter.api.Assertions.assertNull
import org.junit.jupiter.api.Assertions.assertSame
import org.junit.jupiter.api.Test
import org.mockito.Mockito.mock
import org.mockito.Mockito.times
import org.mockito.Mockito.verify
import org.mockito.Mockito.`when`

class InventoryBucketResolverTest {

private val itemUomRepository = mock(ItemUomRespository::class.java)
private val inventoryRepository = mock(InventoryRepository::class.java)
private val resolver = InventoryBucketResolver(itemUomRepository, inventoryRepository)

@Test
fun resolveStockUomId_from_item_uom_id_uses_uom_conversion_id() {
val uom = UomConversion().apply { id = 207L }
val itemUom = ItemUom().apply {
id = 99L
this.uom = uom
}
`when`(itemUomRepository.findByIdAndDeletedIsFalse(99L)).thenReturn(itemUom)

assertEquals(207L, resolver.resolveStockUomId(99L))
}

@Test
fun findInventoryBucket_by_item_and_stock_uom_returns_exact_bucket() {
val bucket = Inventory().apply { id = 10L }
`when`(inventoryRepository.findByItemIdAndStockUomIdAndDeletedIsFalse(1L, 207L))
.thenReturn(bucket)

assertSame(bucket, resolver.findInventoryBucket(1L, 207L))
verify(inventoryRepository, times(1))
.findByItemIdAndStockUomIdAndDeletedIsFalse(1L, 207L)
}

@Test
fun findInventoryBucket_from_lot_line_uses_lot_stock_uom_not_stock_unit_fallback() {
val stockUomConv = UomConversion().apply { id = 2149L }
val itemUom = ItemUom().apply {
id = 55L
uom = stockUomConv
}
val lotLine = InventoryLotLine().apply { stockUom = itemUom }
val bucket = Inventory().apply { id = 22L }
`when`(inventoryRepository.findByItemIdAndStockUomIdAndDeletedIsFalse(7L, 2149L))
.thenReturn(bucket)

assertSame(bucket, resolver.findInventoryBucket(7L, lotLine))
verify(inventoryRepository, times(1))
.findByItemIdAndStockUomIdAndDeletedIsFalse(7L, 2149L)
}

@Test
fun findInventoryBucket_without_lot_stock_uom_falls_back_to_item_stock_unit() {
val stockUnitUom = UomConversion().apply { id = 100L }
val stockUnit = ItemUom().apply { uom = stockUnitUom }
val bucket = Inventory().apply { id = 3L }
`when`(itemUomRepository.findByItemIdAndStockUnitIsTrueAndDeletedIsFalse(5L))
.thenReturn(stockUnit)
`when`(inventoryRepository.findByItemIdAndStockUomIdAndDeletedIsFalse(5L, 100L))
.thenReturn(bucket)

assertSame(bucket, resolver.findInventoryBucket(5L, null as InventoryLotLine?))
}

@Test
fun findInventoryBucket_returns_null_when_stock_uom_unresolvable() {
`when`(itemUomRepository.findByIdAndDeletedIsFalse(1L)).thenReturn(null)
assertNull(resolver.findInventoryBucketByStockItemUomId(9L, 1L))
}
}

+ 77
- 0
src/test/kotlin/com/ffii/fpsms/modules/stock/service/InventoryLotLineQtyGuardTest.kt View File

@@ -0,0 +1,77 @@
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 org.junit.jupiter.api.assertThrows
import org.springframework.web.server.ResponseStatusException
import java.math.BigDecimal

class InventoryLotLineQtyGuardTest {

@Test
fun physical_remain_is_in_minus_out() {
assertEquals(
BigDecimal("7"),
InventoryLotLineQtyGuard.physicalRemain(BigDecimal("10"), BigDecimal("3")),
)
}

@Test
fun available_subtracts_hold() {
assertEquals(
BigDecimal("4"),
InventoryLotLineQtyGuard.availableQty(BigDecimal("10"), BigDecimal("3"), BigDecimal("3")),
)
}

@Test
fun issue_types_recognized() {
assertTrue(InventoryLotLineQtyGuard.isIssueStockOutType("Bad"))
assertTrue(InventoryLotLineQtyGuard.isIssueStockOutType("MISS"))
assertTrue(InventoryLotLineQtyGuard.isIssueStockOutType("expiry"))
assertFalse(InventoryLotLineQtyGuard.isIssueStockOutType("pick"))
assertFalse(InventoryLotLineQtyGuard.isIssueStockOutType(null))
}

@Test
fun require_physical_remain_allows_pick_from_hold() {
// hold=10, avail(in-out-hold)=0, but physical remain=10 — pick/issue OK
InventoryLotLineQtyGuard.requireQtyWithinPhysicalRemain(
BigDecimal("10"),
BigDecimal.ZERO,
BigDecimal("10"),
"Bad 數量",
)
}

@Test
fun require_physical_remain_rejects_overshoot() {
val ex = assertThrows<ResponseStatusException> {
InventoryLotLineQtyGuard.requireQtyWithinPhysicalRemain(
BigDecimal("10"),
BigDecimal("8"),
BigDecimal("3"),
"Bad 數量",
)
}
assertEquals(400, ex.statusCode.value())
assertTrue(ex.reason!!.contains("超過實體剩餘"))
}

@Test
fun require_available_rejects_when_held() {
val ex = assertThrows<ResponseStatusException> {
InventoryLotLineQtyGuard.requireQtyWithinAvailable(
BigDecimal("10"),
BigDecimal.ZERO,
BigDecimal("10"),
BigDecimal("1"),
"調整數量",
)
}
assertEquals(400, ex.statusCode.value())
assertTrue(ex.reason!!.contains("超過可用量"))
}
}

Loading…
Cancel
Save