|
|
@@ -1533,19 +1533,50 @@ open fun newBatchSubmit(request: QrPickBatchSubmitRequest): MessageResponse { |
|
|
stockLedgerRepository.saveAndFlush(stockLedger) |
|
|
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 = inventoryLotLineService.deriveInventoryLotLineStatus( |
|
|
this.status, |
|
|
this.status, |
|
|
this.inQty, |
|
|
this.inQty, |
|
|
@@ -1553,72 +1584,60 @@ open fun newBatchSubmit(request: QrPickBatchSubmitRequest): MessageResponse { |
|
|
this.holdQty |
|
|
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 |
|
|
val itemId = updatedInventoryLotLine.inventoryLot?.item?.id |
|
|
?: throw IllegalArgumentException("InventoryLotLine must have an associated item") |
|
|
?: throw IllegalArgumentException("InventoryLotLine must have an associated item") |
|
|
|
|
|
|
|
|
// Step 2: Create a row of stock_out |
|
|
|
|
|
val currentUser = SecurityUtils.getUser().orElseThrow() |
|
|
val currentUser = SecurityUtils.getUser().orElseThrow() |
|
|
val stockOut = StockOut().apply { |
|
|
val stockOut = StockOut().apply { |
|
|
this.type = request.type |
|
|
|
|
|
|
|
|
this.type = stockOutType |
|
|
this.completeDate = LocalDateTime.now() |
|
|
this.completeDate = LocalDateTime.now() |
|
|
this.handler = currentUser.id |
|
|
this.handler = currentUser.id |
|
|
this.status = StockOutStatus.COMPLETE.status |
|
|
this.status = StockOutStatus.COMPLETE.status |
|
|
} |
|
|
} |
|
|
val savedStockOut = stockOutRepository.save(stockOut) |
|
|
val savedStockOut = stockOutRepository.save(stockOut) |
|
|
|
|
|
|
|
|
// Step 3: Create a row in stock_out_line table |
|
|
|
|
|
val item = itemRepository.findById(itemId).orElseThrow() |
|
|
val item = itemRepository.findById(itemId).orElseThrow() |
|
|
|
|
|
|
|
|
val stockOutLine = StockOutLine().apply { |
|
|
val stockOutLine = StockOutLine().apply { |
|
|
this.item = item |
|
|
this.item = item |
|
|
this.qty = request.qty |
|
|
|
|
|
|
|
|
this.qty = qty |
|
|
this.stockOut = savedStockOut |
|
|
this.stockOut = savedStockOut |
|
|
this.inventoryLotLine = updatedInventoryLotLine |
|
|
this.inventoryLotLine = updatedInventoryLotLine |
|
|
this.status = StockOutLineStatus.COMPLETE.status |
|
|
this.status = StockOutLineStatus.COMPLETE.status |
|
|
this.pickTime = LocalDateTime.now() |
|
|
this.pickTime = LocalDateTime.now() |
|
|
this.handledBy = currentUser.id |
|
|
this.handledBy = currentUser.id |
|
|
this.type = request.type |
|
|
|
|
|
|
|
|
this.type = stockOutType |
|
|
} |
|
|
} |
|
|
val savedStockOutLine = saveAndFlush(stockOutLine) |
|
|
val savedStockOutLine = saveAndFlush(stockOutLine) |
|
|
|
|
|
|
|
|
// Step 4: Create a row in stock_ledger table |
|
|
|
|
|
createStockLedgerForStockOut(savedStockOutLine) |
|
|
createStockLedgerForStockOut(savedStockOutLine) |
|
|
|
|
|
|
|
|
return 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 inventoryLotLine = inventoryLotLineRepository.findById(request.inventoryLotLineId).orElseThrow() |
|
|
val qtyBd = BigDecimal.valueOf(request.qty) |
|
|
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 { |
|
|
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 = inventoryLotLineService.deriveInventoryLotLineStatus( |
|
|
this.status, |
|
|
this.status, |
|
|
this.inQty, |
|
|
this.inQty, |
|
|
@@ -1627,38 +1646,39 @@ open fun newBatchSubmit(request: QrPickBatchSubmitRequest): MessageResponse { |
|
|
) |
|
|
) |
|
|
} |
|
|
} |
|
|
inventoryLotLineRepository.save(updatedInventoryLotLine) |
|
|
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) { |
|
|
if (!skipLedgerWrite) { |
|
|
val item = savedSol.item ?: return savedSol |
|
|
val item = savedSol.item ?: return savedSol |
|
|
val inventory = itemUomService.findInventoryForItemBaseUom(item.id!!) ?: return savedSol |
|
|
val inventory = itemUomService.findInventoryForItemBaseUom(item.id!!) ?: return savedSol |
|
|
|