From 8cccc01aa39ed755f92e78989bb7271ab4eb48c7 Mon Sep 17 00:00:00 2001 From: "kelvin.yau" Date: Thu, 16 Apr 2026 23:22:02 +0800 Subject: [PATCH] Stock Adj update --- .../stock/service/StockAdjustmentService.kt | 11 +- .../stock/service/StockOutLineService.kt | 131 ++++++++++++++++++ 2 files changed, 135 insertions(+), 7 deletions(-) diff --git a/src/main/java/com/ffii/fpsms/modules/stock/service/StockAdjustmentService.kt b/src/main/java/com/ffii/fpsms/modules/stock/service/StockAdjustmentService.kt index 37f5afd..df40884 100644 --- a/src/main/java/com/ffii/fpsms/modules/stock/service/StockAdjustmentService.kt +++ b/src/main/java/com/ffii/fpsms/modules/stock/service/StockAdjustmentService.kt @@ -32,12 +32,9 @@ open class StockAdjustmentService( // Branch 4: Removed entries — stock out full qty and mark unavailable val removed = request.originalLines.filter { it.id > 0 && it.id !in currentIds } for (line in removed) { - val stockOutLine = stockOutLineService.createStockOut( - StockOutRequest( - inventoryLotLineId = line.id, - qty = line.adjustedQty.toDouble(), - type = "ADJ" - ) + val stockOutLine = stockOutLineService.createStockOutForRemoval( + inventoryLotLineId = line.id, + type = "ADJ" ) saveAdjustmentRecordForStockOut(stockOutLine) } @@ -77,7 +74,7 @@ open class StockAdjustmentService( saveAdjustmentRecordForStockIn(stockInLine) } else { // Branch 3 (qty down): createStockOut - val stockOutLine = stockOutLineService.createStockOut( + val stockOutLine = stockOutLineService.createStockOutForAdjustment( StockOutRequest( inventoryLotLineId = current.id, qty = diff.abs().toDouble(), 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 50d5fec..af20dce 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 @@ -51,6 +51,8 @@ import com.ffii.fpsms.modules.stock.entity.InventoryLotLine import jakarta.persistence.EntityManager import jakarta.persistence.PersistenceContext import java.util.UUID +import org.springframework.http.HttpStatus +import org.springframework.web.server.ResponseStatusException @Service open class StockOutLineService( private val jdbcDao: JdbcDao, @@ -1589,6 +1591,135 @@ open fun newBatchSubmit(request: QrPickBatchSubmitRequest): MessageResponse { return savedStockOutLine } + /** + * Stock adjustment should only reduce physical on-hand (inQty - outQty) and must NOT affect holdQty. + * + * Rule: delta outQty cannot exceed availableQty = inQty - outQty - holdQty. + * This prevents ending in an invalid state where onHand < onHold and avoids negative trigger-derived quantities. + */ + open fun createStockOutForAdjustment(request: StockOutRequest): StockOutLine { + val inventoryLotLine = inventoryLotLineRepository.findById(request.inventoryLotLineId).orElseThrow() + val qtyBd = BigDecimal.valueOf(request.qty) + if (qtyBd <= BigDecimal.ZERO) { + throw ResponseStatusException(HttpStatus.BAD_REQUEST, "Adjustment qty must be > 0") + } + + val inQty = inventoryLotLine.inQty ?: BigDecimal.ZERO + val outQty = inventoryLotLine.outQty ?: BigDecimal.ZERO + val holdQty = inventoryLotLine.holdQty ?: BigDecimal.ZERO + val availableQty = inQty.subtract(outQty).subtract(holdQty) + if (qtyBd > availableQty) { + throw ResponseStatusException( + HttpStatus.BAD_REQUEST, + "Adjustment qty exceeds availableQty (availableQty=$availableQty, requested=$qtyBd). " + + "Rule: requested <= inQty - outQty - holdQty." + ) + } + + // Step 1: Increase outQty only; keep holdQty unchanged + val updatedInventoryLotLine = inventoryLotLine.apply { + this.outQty = outQty.add(qtyBd) + this.status = inventoryLotLineService.deriveInventoryLotLineStatus( + this.status, + this.inQty, + this.outQty, + this.holdQty + ) + } + inventoryLotLineRepository.save(updatedInventoryLotLine) + // inventory aggregates: handled by DB trigger `inventory_lot_line_AFTER_UPDATE` + + val itemId = updatedInventoryLotLine.inventoryLot?.item?.id + ?: throw IllegalArgumentException("InventoryLotLine must have an associated item") + + // Step 2: Create stock_out header + val currentUser = SecurityUtils.getUser().orElseThrow() + val stockOut = StockOut().apply { + this.type = request.type + this.completeDate = LocalDateTime.now() + this.handler = currentUser.id + this.status = StockOutStatus.COMPLETE.status + } + val savedStockOut = stockOutRepository.save(stockOut) + + // Step 3: Create stock_out_line + val item = itemRepository.findById(itemId).orElseThrow() + val stockOutLine = StockOutLine().apply { + this.item = item + this.qty = request.qty + this.stockOut = savedStockOut + this.inventoryLotLine = updatedInventoryLotLine + this.status = StockOutLineStatus.COMPLETE.status + this.pickTime = LocalDateTime.now() + this.handledBy = currentUser.id + this.type = request.type + } + val savedStockOutLine = saveAndFlush(stockOutLine) + + // Step 4: ledger + createStockLedgerForStockOut(savedStockOutLine) + return savedStockOutLine + } + + /** + * Removal flow (stock adjustment "remove lot line"): + * - Physically reduce on-hand to zero by setting outQty = inQty + * - Clear holdQty = 0 to avoid invalid state (onHand < onHold) and negative available calculations + * + * A stock-out document/ledger is still created for audit trail. + */ + open fun createStockOutForRemoval(inventoryLotLineId: Long, type: String = "ADJ"): StockOutLine { + val inventoryLotLine = inventoryLotLineRepository.findById(inventoryLotLineId).orElseThrow() + val inQty = inventoryLotLine.inQty ?: BigDecimal.ZERO + val outQty = inventoryLotLine.outQty ?: BigDecimal.ZERO + val deltaOut = inQty.subtract(outQty).coerceAtLeast(BigDecimal.ZERO) + + // Step 1: Set outQty=inQty and clear holdQty + val updatedInventoryLotLine = inventoryLotLine.apply { + this.outQty = inQty + this.holdQty = BigDecimal.ZERO + this.status = inventoryLotLineService.deriveInventoryLotLineStatus( + this.status, + this.inQty, + this.outQty, + this.holdQty + ) + } + inventoryLotLineRepository.save(updatedInventoryLotLine) + // inventory aggregates: handled by DB trigger `inventory_lot_line_AFTER_UPDATE` + + val itemId = updatedInventoryLotLine.inventoryLot?.item?.id + ?: throw IllegalArgumentException("InventoryLotLine must have an associated item") + + // Step 2: Create stock_out header + val currentUser = SecurityUtils.getUser().orElseThrow() + val stockOut = StockOut().apply { + this.type = type + this.completeDate = LocalDateTime.now() + this.handler = currentUser.id + this.status = StockOutStatus.COMPLETE.status + } + val savedStockOut = stockOutRepository.save(stockOut) + + // Step 3: Create stock_out_line (qty = physical delta) + val item = itemRepository.findById(itemId).orElseThrow() + val stockOutLine = StockOutLine().apply { + this.item = item + this.qty = deltaOut.toDouble() + this.stockOut = savedStockOut + this.inventoryLotLine = updatedInventoryLotLine + this.status = StockOutLineStatus.COMPLETE.status + this.pickTime = LocalDateTime.now() + this.handledBy = currentUser.id + this.type = type + } + val savedStockOutLine = saveAndFlush(stockOutLine) + + // Step 4: ledger + createStockLedgerForStockOut(savedStockOutLine) + return savedStockOutLine + } + private fun createStockLedgerForPickDelta( stockOutLine: StockOutLine,