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.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 { | |||
| @@ -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 { | |||
| 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 | |||
| @@ -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("超過可用量")) | |||
| } | |||
| } | |||