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 df40884..a09f635 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 @@ -73,7 +73,7 @@ open class StockAdjustmentService( val stockInLine = stockInLineService.createStockIn(stockInRequest) saveAdjustmentRecordForStockIn(stockInLine) } else { - // Branch 3 (qty down): createStockOut + // Branch 3 (qty down): adjustment outbound only (not pick createStockOut) val stockOutLine = stockOutLineService.createStockOutForAdjustment( StockOutRequest( inventoryLotLineId = current.id, 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 af20dce..d68ad34 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 @@ -1533,19 +1533,50 @@ open fun newBatchSubmit(request: QrPickBatchSubmitRequest): MessageResponse { stockLedgerRepository.saveAndFlush(stockLedger) } - open fun createStockOut(request: StockOutRequest): StockOutLine { + /** Used for error messages when validating out-only flows (adjustment vs stock transfer). */ + private enum class OutOnlyLotLineContext { + ADJUSTMENT, + STOCK_TRANSFER, + } - val inventoryLotLine = inventoryLotLineRepository.findById(request.inventoryLotLineId).orElseThrow() - val qtyBd = BigDecimal.valueOf(request.qty) - val oldHold = inventoryLotLine.holdQty ?: BigDecimal.ZERO + /** + * Validates qty for flows that only increase [InventoryLotLine.outQty] (hold unchanged): + * qty > 0 and qty <= inQty - outQty - holdQty. + */ + private fun validateOutOnlyIncrease( + inventoryLotLine: InventoryLotLine, + qtyBd: BigDecimal, + context: OutOnlyLotLineContext, + ) { + if (qtyBd <= BigDecimal.ZERO) { + val msg = when (context) { + OutOnlyLotLineContext.ADJUSTMENT -> "Adjustment qty must be > 0" + OutOnlyLotLineContext.STOCK_TRANSFER -> "Stock transfer out qty must be > 0" + } + throw ResponseStatusException(HttpStatus.BAD_REQUEST, msg) + } + 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) { + val prefix = when (context) { + OutOnlyLotLineContext.ADJUSTMENT -> "Adjustment qty exceeds availableQty" + OutOnlyLotLineContext.STOCK_TRANSFER -> "Stock transfer qty exceeds availableQty" + } + throw ResponseStatusException( + HttpStatus.BAD_REQUEST, + "$prefix (availableQty=$availableQty, requested=$qtyBd). " + + "Rule: requested <= inQty - outQty - holdQty." + ) + } + } - // Step 1: Increase outQty in inventory_lot_line table and release hold (same idea as pick / QR scan) - val updatedInventoryLotLine = inventoryLotLine.apply { - val currentOutQty = this.outQty ?: BigDecimal.ZERO - val newOutQty = currentOutQty + qtyBd - this.outQty = newOutQty - val newHold = oldHold.subtract(qtyBd).coerceAtLeast(BigDecimal.ZERO) - this.holdQty = newHold + /** Applies validated out-only delta: increases [outQty] only, [holdQty] unchanged. */ + private fun applyOutOnlyIncreaseToLotLine(inventoryLotLine: InventoryLotLine, qtyBd: BigDecimal) { + val outQty = inventoryLotLine.outQty ?: BigDecimal.ZERO + inventoryLotLine.apply { + this.outQty = outQty.add(qtyBd) this.status = inventoryLotLineService.deriveInventoryLotLineStatus( this.status, this.inQty, @@ -1553,72 +1584,60 @@ open fun newBatchSubmit(request: QrPickBatchSubmitRequest): MessageResponse { this.holdQty ) } - inventoryLotLineRepository.save(updatedInventoryLotLine) - // inventory.onHandQty / onHoldQty / unavailableQty: 由 DB trigger `inventory_lot_line_AFTER_UPDATE` - // 依 old/new 的 in、out、hold、status 同步;此處勿再手動改 inventory.onHoldQty,否則與 trigger 重複扣會變負數。 + } + /** + * After the lot line has been saved: create [StockOut], [StockOutLine], and stock ledger. + * Shared by pick/QR [createStockOut], [createStockOutForAdjustment], and [createStockOutForStockTransfer]. + * Not used by [createStockOutForRemoval] (different line semantics). + */ + private fun completeStockOutAfterLotLineSave( + updatedInventoryLotLine: InventoryLotLine, + stockOutType: String, + qty: Double, + ): StockOutLine { val itemId = updatedInventoryLotLine.inventoryLot?.item?.id ?: throw IllegalArgumentException("InventoryLotLine must have an associated item") - // Step 2: Create a row of stock_out val currentUser = SecurityUtils.getUser().orElseThrow() val stockOut = StockOut().apply { - this.type = request.type + this.type = stockOutType this.completeDate = LocalDateTime.now() this.handler = currentUser.id this.status = StockOutStatus.COMPLETE.status } val savedStockOut = stockOutRepository.save(stockOut) - // Step 3: Create a row in stock_out_line table val item = itemRepository.findById(itemId).orElseThrow() - val stockOutLine = StockOutLine().apply { this.item = item - this.qty = request.qty + this.qty = qty this.stockOut = savedStockOut this.inventoryLotLine = updatedInventoryLotLine this.status = StockOutLineStatus.COMPLETE.status this.pickTime = LocalDateTime.now() this.handledBy = currentUser.id - this.type = request.type + this.type = stockOutType } val savedStockOutLine = saveAndFlush(stockOutLine) - - // Step 4: Create a row in stock_ledger table createStockLedgerForStockOut(savedStockOutLine) - 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. + * Pick / QR scan outbound: increases [outQty] and releases [holdQty] up to qty (same behaviour as before TRF was split out). + * For adjustment or stock-transfer source outbound, use [createStockOutForAdjustment] or [createStockOutForStockTransfer]. */ - open fun createStockOutForAdjustment(request: StockOutRequest): StockOutLine { + open fun createStockOut(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." - ) - } + val oldHold = inventoryLotLine.holdQty ?: BigDecimal.ZERO - // Step 1: Increase outQty only; keep holdQty unchanged + // Increase outQty and release hold (pick / QR); inventory aggregates via trigger inventory_lot_line_AFTER_UPDATE val updatedInventoryLotLine = inventoryLotLine.apply { - this.outQty = outQty.add(qtyBd) + val currentOutQty = this.outQty ?: BigDecimal.ZERO + this.outQty = currentOutQty + qtyBd + this.holdQty = oldHold.subtract(qtyBd).coerceAtLeast(BigDecimal.ZERO) this.status = inventoryLotLineService.deriveInventoryLotLineStatus( this.status, this.inQty, @@ -1627,38 +1646,39 @@ open fun newBatchSubmit(request: QrPickBatchSubmitRequest): MessageResponse { ) } inventoryLotLineRepository.save(updatedInventoryLotLine) - // inventory aggregates: handled by DB trigger `inventory_lot_line_AFTER_UPDATE` + // Do not manually adjust inventory.onHoldQty here — trigger syncs from lot line changes. - val itemId = updatedInventoryLotLine.inventoryLot?.item?.id - ?: throw IllegalArgumentException("InventoryLotLine must have an associated item") + return completeStockOutAfterLotLineSave(updatedInventoryLotLine, request.type, request.qty) + } - // 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) + /** + * Stock transfer (TRF) source outbound: [outQty] only, [holdQty] unchanged, qty capped by available on the line. + * Independent of [createStockOut] (pick/QR); shares out-only validation/apply with [createStockOutForAdjustment]. + */ + open fun createStockOutForStockTransfer(inventoryLotLineId: Long, qty: Double): StockOutLine { + val inventoryLotLine = inventoryLotLineRepository.findById(inventoryLotLineId).orElseThrow() + val qtyBd = BigDecimal.valueOf(qty) + validateOutOnlyIncrease(inventoryLotLine, qtyBd, OutOnlyLotLineContext.STOCK_TRANSFER) + applyOutOnlyIncreaseToLotLine(inventoryLotLine, qtyBd) + inventoryLotLineRepository.save(inventoryLotLine) + // inventory aggregates: DB trigger inventory_lot_line_AFTER_UPDATE - // 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) + return completeStockOutAfterLotLineSave(inventoryLotLine, "TRF", qty) + } - // Step 4: ledger - createStockLedgerForStockOut(savedStockOutLine) - return savedStockOutLine + /** + * Stock adjustment outbound: only increases [outQty]; must not change [holdQty]. + * Rule: qty <= inQty - outQty - holdQty (avoids invalid on-hand vs on-hold and trigger inconsistencies). + */ + open fun createStockOutForAdjustment(request: StockOutRequest): StockOutLine { + val inventoryLotLine = inventoryLotLineRepository.findById(request.inventoryLotLineId).orElseThrow() + val qtyBd = BigDecimal.valueOf(request.qty) + validateOutOnlyIncrease(inventoryLotLine, qtyBd, OutOnlyLotLineContext.ADJUSTMENT) + applyOutOnlyIncreaseToLotLine(inventoryLotLine, qtyBd) + inventoryLotLineRepository.save(inventoryLotLine) + // inventory aggregates: DB trigger inventory_lot_line_AFTER_UPDATE + + return completeStockOutAfterLotLineSave(inventoryLotLine, request.type, request.qty) } /** @@ -2162,7 +2182,7 @@ fun applyStockOutLineDelta( } } - // 3) stock_ledger (shared source with createStockOut/createStockLedgerForStockOut style) + // 3) stock_ledger (same ledger shape as createStockLedgerForStockOut / completeStockOutAfterLotLineSave) if (!skipLedgerWrite) { val item = savedSol.item ?: return savedSol val inventory = itemUomService.findInventoryForItemBaseUom(item.id!!) ?: return savedSol diff --git a/src/main/java/com/ffii/fpsms/modules/stock/service/StockTransferRecordService.kt b/src/main/java/com/ffii/fpsms/modules/stock/service/StockTransferRecordService.kt index 1d7a505..99c8a2a 100644 --- a/src/main/java/com/ffii/fpsms/modules/stock/service/StockTransferRecordService.kt +++ b/src/main/java/com/ffii/fpsms/modules/stock/service/StockTransferRecordService.kt @@ -9,7 +9,6 @@ import com.ffii.fpsms.modules.stock.entity.StockTransferRecord import com.ffii.fpsms.modules.stock.entity.StockTransferRecordRepository import com.ffii.fpsms.modules.stock.web.model.CreateStockTransferRequest import com.ffii.fpsms.modules.stock.web.model.StockInRequest -import com.ffii.fpsms.modules.stock.web.model.StockOutRequest import org.springframework.stereotype.Service import org.springframework.transaction.annotation.Transactional import com.ffii.fpsms.modules.stock.entity.InventoryLotLineRepository @@ -34,13 +33,13 @@ open class StockTransferRecordService( @Transactional open fun createStockTransfer(request: CreateStockTransferRequest): MessageResponse { - // Step 1: Stock Out - using generic function - val stockOutLine = createStockOut(request) + // Step 1: TRF outbound on source lot line (out-only; not pick/QR createStockOut) + val stockOutLine = createSourceStockOutForTransfer(request) - // Step 2: Stock In - using generic function + // Step 2: TRF inbound to target warehouse (merge or new lot line) val (stockInLine, stockInMode) = createStockIn(request) - // Step 3: Create Stock Transfer Record + // Step 3: Persist transfer record linking in/out lines val stockTransferRecord = createStockTransferRecord(request, stockOutLine, stockInLine) return MessageResponse( @@ -59,15 +58,11 @@ open class StockTransferRecordService( ) } - private fun createStockOut(request: CreateStockTransferRequest): StockOutLine { - - val stockOutRequest = StockOutRequest( + private fun createSourceStockOutForTransfer(request: CreateStockTransferRequest): StockOutLine { + return stockOutLineService.createStockOutForStockTransfer( inventoryLotLineId = request.inventoryLotLineId, qty = request.transferredQty.toDouble(), - type = "TRF" ) - - return stockOutLineService.createStockOut(stockOutRequest) } private fun createStockIn(request: CreateStockTransferRequest): Pair {