diff --git a/src/main/java/com/ffii/fpsms/modules/pickOrder/service/PickExecutionIssueService.kt b/src/main/java/com/ffii/fpsms/modules/pickOrder/service/PickExecutionIssueService.kt index a392e3a8..a5b3437b 100644 --- a/src/main/java/com/ffii/fpsms/modules/pickOrder/service/PickExecutionIssueService.kt +++ b/src/main/java/com/ffii/fpsms/modules/pickOrder/service/PickExecutionIssueService.kt @@ -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 { diff --git a/src/main/java/com/ffii/fpsms/modules/stock/service/InventoryLotLineQtyGuard.kt b/src/main/java/com/ffii/fpsms/modules/stock/service/InventoryLotLineQtyGuard.kt new file mode 100644 index 00000000..522215b4 --- /dev/null +++ b/src/main/java/com/ffii/fpsms/modules/stock/service/InventoryLotLineQtyGuard.kt @@ -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。", + ) + } + } +} diff --git a/src/main/java/com/ffii/fpsms/modules/stock/service/StockOutLineService.kt b/src/main/java/com/ffii/fpsms/modules/stock/service/StockOutLineService.kt index 1901a760..47a0f371 100644 --- a/src/main/java/com/ffii/fpsms/modules/stock/service/StockOutLineService.kt +++ b/src/main/java/com/ffii/fpsms/modules/stock/service/StockOutLineService.kt @@ -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 diff --git a/src/test/java/com/ffii/core/support/ErrorHandlerSqlSignalTest.java b/src/test/java/com/ffii/core/support/ErrorHandlerSqlSignalTest.java new file mode 100644 index 00000000..004f8fe4 --- /dev/null +++ b/src/test/java/com/ffii/core/support/ErrorHandlerSqlSignalTest.java @@ -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()); + } +} diff --git a/src/test/kotlin/com/ffii/fpsms/modules/stock/service/InventoryBucketResolverTest.kt b/src/test/kotlin/com/ffii/fpsms/modules/stock/service/InventoryBucketResolverTest.kt new file mode 100644 index 00000000..6aef1298 --- /dev/null +++ b/src/test/kotlin/com/ffii/fpsms/modules/stock/service/InventoryBucketResolverTest.kt @@ -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)) + } +} diff --git a/src/test/kotlin/com/ffii/fpsms/modules/stock/service/InventoryLotLineQtyGuardTest.kt b/src/test/kotlin/com/ffii/fpsms/modules/stock/service/InventoryLotLineQtyGuardTest.kt new file mode 100644 index 00000000..816472de --- /dev/null +++ b/src/test/kotlin/com/ffii/fpsms/modules/stock/service/InventoryLotLineQtyGuardTest.kt @@ -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 { + 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 { + InventoryLotLineQtyGuard.requireQtyWithinAvailable( + BigDecimal("10"), + BigDecimal.ZERO, + BigDecimal("10"), + BigDecimal("1"), + "調整數量", + ) + } + assertEquals(400, ex.statusCode.value()) + assertTrue(ex.reason!!.contains("超過可用量")) + } +}