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 <[email protected]>fix負數倉
| @@ -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.deliveryOrder.enums.DeliveryOrderStatus | ||||
| import com.ffii.fpsms.modules.stock.entity.SuggestPickLotRepository | import com.ffii.fpsms.modules.stock.entity.SuggestPickLotRepository | ||||
| import org.springframework.beans.factory.annotation.Value | 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.BatchStockOutRequest | ||||
| import com.ffii.fpsms.modules.stock.web.model.BatchStockOutLineRequest | import com.ffii.fpsms.modules.stock.web.model.BatchStockOutLineRequest | ||||
| @Service | @Service | ||||
| @@ -2730,8 +2732,13 @@ open fun submitIssueWithQty(request: SubmitIssueWithQtyRequest): MessageResponse | |||||
| inventoryLotLineRepository.findById(it).orElse(null) | inventoryLotLineRepository.findById(it).orElse(null) | ||||
| } | } | ||||
| val inventoryBeforeUpdate = inventoryBucketResolver.findInventoryBucket(request.itemId, lotLine) | val inventoryBeforeUpdate = inventoryBucketResolver.findInventoryBucket(request.itemId, lotLine) | ||||
| ?: inventoryRepository.findByItemId(request.itemId).orElse(null) | |||||
| val onHandQtyBeforeUpdate = (inventoryBeforeUpdate?.onHandQty ?: BigDecimal.ZERO).toDouble() | val onHandQtyBeforeUpdate = (inventoryBeforeUpdate?.onHandQty ?: BigDecimal.ZERO).toDouble() | ||||
| if (lotLine != null && inventoryBeforeUpdate == null) { | |||||
| throw ResponseStatusException( | |||||
| HttpStatus.BAD_REQUEST, | |||||
| "找不到對應庫存單位的庫存桶,無法提交不良品/短少。", | |||||
| ) | |||||
| } | |||||
| println("=== submitIssueWithQty: Before update ===") | println("=== submitIssueWithQty: Before update ===") | ||||
| println("Item ID: ${request.itemId}") | 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? = | private fun resolveLedgerInventory(itemId: Long, lotLine: InventoryLotLine?): Inventory? = | ||||
| inventoryBucketResolver.findInventoryBucket(itemId, lotLine) | inventoryBucketResolver.findInventoryBucket(itemId, lotLine) | ||||
| ?: inventoryRepository.findByItemId(itemId).orElse(null) | |||||
| private fun previousBalanceForInventory(inventory: Inventory): Double { | private fun previousBalanceForInventory(inventory: Inventory): Double { | ||||
| val latest = inventory.id?.let { | val latest = inventory.id?.let { | ||||
| @@ -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。", | |||||
| ) | |||||
| } | |||||
| } | |||||
| } | |||||
| @@ -1580,9 +1580,20 @@ open fun newBatchSubmit(request: QrPickBatchSubmitRequest): MessageResponse { | |||||
| open fun createStockOut(request: StockOutRequest): StockOutLine { | open fun createStockOut(request: StockOutRequest): StockOutLine { | ||||
| val inventoryLotLine = inventoryLotLineRepository.findById(request.inventoryLotLineId).orElseThrow() | val inventoryLotLine = inventoryLotLineRepository.findById(request.inventoryLotLineId).orElseThrow() | ||||
| val qtyBd = BigDecimal.valueOf(request.qty) | 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 | 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 updatedInventoryLotLine = inventoryLotLine.apply { | ||||
| val currentOutQty = this.outQty ?: BigDecimal.ZERO | val currentOutQty = this.outQty ?: BigDecimal.ZERO | ||||
| this.outQty = currentOutQty + qtyBd | this.outQty = currentOutQty + qtyBd | ||||
| @@ -2095,6 +2106,12 @@ fun applyStockOutLineDelta( | |||||
| if (isIssuePosting) { | if (isIssuePosting) { | ||||
| val latestLotLine = inventoryLotLineRepository.findById(lotLine.id!!).orElse(null) | val latestLotLine = inventoryLotLineRepository.findById(lotLine.id!!).orElse(null) | ||||
| if (latestLotLine != null) { | if (latestLotLine != null) { | ||||
| InventoryLotLineQtyGuard.requireQtyWithinPhysicalRemain( | |||||
| latestLotLine.inQty, | |||||
| latestLotLine.outQty, | |||||
| deltaQty, | |||||
| postingType.replaceFirstChar { it.uppercase() } + " 數量", | |||||
| ) | |||||
| val prevStatus = latestLotLine.status | val prevStatus = latestLotLine.status | ||||
| val currentHoldQty = latestLotLine.holdQty ?: BigDecimal.ZERO | val currentHoldQty = latestLotLine.holdQty ?: BigDecimal.ZERO | ||||
| val currentOutQty = latestLotLine.outQty ?: BigDecimal.ZERO | val currentOutQty = latestLotLine.outQty ?: BigDecimal.ZERO | ||||
| @@ -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()); | |||||
| } | |||||
| } | |||||
| @@ -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)) | |||||
| } | |||||
| } | |||||
| @@ -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("超過可用量")) | |||||
| } | |||||
| } | |||||