| @@ -2665,12 +2665,13 @@ return MessageResponse( | |||
| } | |||
| private fun resolveWorkbenchPreviousBalance( | |||
| itemId: Long, | |||
| inventory: Inventory, | |||
| onHandQtyBeforeUpdate: Double?, | |||
| ): Double { | |||
| if (onHandQtyBeforeUpdate != null) return onHandQtyBeforeUpdate | |||
| val latestLedger = stockLedgerRepository.findFirstByItemIdAndDeletedFalseOrderByDateDescIdDesc(itemId) | |||
| val latestLedger = inventory.id?.let { | |||
| stockLedgerRepository.findFirstByInventory_IdAndDeletedFalseOrderByDateDescIdDesc(it) | |||
| } | |||
| return latestLedger?.balance ?: (inventory.onHandQty ?: BigDecimal.ZERO).toDouble() | |||
| } | |||
| @@ -482,8 +482,7 @@ open class StockLedgerFixService( | |||
| } | |||
| open fun inventoryScopeDetail(inventoryId: Long): StockLedgerFixScopeDetail { | |||
| val today = LocalDate.now() | |||
| val args = mapOf("inventoryId" to inventoryId, "today" to today) | |||
| val args = mapOf("inventoryId" to inventoryId, "today" to inventoryScopeToExclusive()) | |||
| val head = jdbcDao.queryForList( | |||
| """ | |||
| SELECT | |||
| @@ -556,10 +555,14 @@ open class StockLedgerFixService( | |||
| ) | |||
| } | |||
| /** | |||
| * Rebuild 2.1–2.6 for one inventory bucket through calendar today | |||
| * (`date < tomorrow`). Calendar day-fix still excludes today unless Allow today. | |||
| */ | |||
| open fun fixInventoryScope(inventoryId: Long): StockLedgerFixRunResponse { | |||
| if (inventoryId <= 0) throw IllegalArgumentException("inventoryId is required") | |||
| if (!inFlight.add(runLock)) throw IllegalStateException("fix already running") | |||
| val today = LocalDate.now() | |||
| val toExclusive = inventoryScopeToExclusive() | |||
| val t0 = System.nanoTime() | |||
| try { | |||
| val inv = jdbcDao.queryForList( | |||
| @@ -572,26 +575,26 @@ open class StockLedgerFixService( | |||
| "inventoryId" to inventoryId, | |||
| "itemId" to (inv["itemId"] ?: 0), | |||
| "uomId" to (inv["uomId"] ?: 0), | |||
| "today" to today, | |||
| "today" to toExclusive, | |||
| ) | |||
| val filledLotLineId = timed("inv 2.1") { fillLotLineIdScope(args, byInventory = true) } | |||
| val filledUomId = timed("inv 2.2") { fillUomIdScope(args, byInventory = true) } | |||
| val filledInventoryId = timed("inv 2.3") { fillInventoryIdScope(args, byInventory = true) } | |||
| val filledLotQty = timed("inv 2.4") { fillLotQtyHistory(args, byInventory = true) } | |||
| val filledBalance = timed("inv 2.5") { fillBalanceHistory(args) } | |||
| val lotIds = lotIdsForInventory(inventoryId, today) | |||
| val range = dateRangeForFilter("inventoryId = :inventoryId", mapOf("inventoryId" to inventoryId, "today" to today)) | |||
| val lotIds = lotIdsForInventory(inventoryId, toExclusive) | |||
| val range = dateRangeForFilter("inventoryId = :inventoryId", mapOf("inventoryId" to inventoryId, "today" to toExclusive)) | |||
| val dayRows = if (range != null) { | |||
| timed("inv 2.6") { upsertStockLotDayRange(lotIds, range.first, range.second) } | |||
| } else 0 | |||
| val still = stillMissScope("inventoryId = :inventoryId", mapOf("inventoryId" to inventoryId, "today" to today)) | |||
| val still = stillMissScope("inventoryId = :inventoryId", mapOf("inventoryId" to inventoryId, "today" to toExclusive)) | |||
| log.info( | |||
| "stock-ledger-fix inventory {} lotLine={} uom={} inv={} lotQty={} bal={} dayRows={} total={}ms", | |||
| inventoryId, filledLotLineId, filledUomId, filledInventoryId, filledLotQty, filledBalance, dayRows, | |||
| (System.nanoTime() - t0) / 1_000_000, | |||
| ) | |||
| return StockLedgerFixRunResponse( | |||
| date = range?.first?.toString() ?: today.toString(), | |||
| date = range?.first?.toString() ?: LocalDate.now().toString(), | |||
| filledLotLineId = filledLotLineId, | |||
| filledUomId = filledUomId, | |||
| filledInventoryId = filledInventoryId, | |||
| @@ -1730,6 +1733,9 @@ open class StockLedgerFixService( | |||
| ) | |||
| } | |||
| /** Exclusive bound for inventory-scope inspect/fix: include calendar today. */ | |||
| private fun inventoryScopeToExclusive(): LocalDate = LocalDate.now().plusDays(1) | |||
| private fun fillBalanceHistory(args: Map<String, Any>): Int { | |||
| return jdbcDao.executeUpdate( | |||
| """ | |||
| @@ -10,6 +10,7 @@ import com.ffii.fpsms.modules.pickOrder.entity.HandleStatus | |||
| import com.ffii.fpsms.modules.stock.entity.StockOutLIneRepository | |||
| import com.ffii.fpsms.modules.stock.entity.InventoryLotLine | |||
| import com.ffii.fpsms.modules.stock.entity.InventoryLotLineRepository | |||
| import com.ffii.fpsms.modules.stock.entity.Inventory | |||
| import com.ffii.fpsms.modules.stock.entity.InventoryRepository | |||
| import com.ffii.fpsms.modules.stock.service.StockOutLineService | |||
| @@ -46,6 +47,7 @@ import com.ffii.fpsms.modules.stock.web.model.BatchStockOutResult | |||
| import com.ffii.fpsms.modules.stock.entity.StockLedger | |||
| import com.ffii.fpsms.modules.stock.entity.StockLedgerRepository | |||
| import com.ffii.fpsms.modules.stock.service.StockLedgerLotSnapshot | |||
| import com.ffii.fpsms.modules.stock.service.InventoryBucketResolver | |||
| import com.ffii.fpsms.modules.deliveryOrder.entity.DeliveryOrderRepository | |||
| import com.ffii.fpsms.modules.deliveryOrder.enums.DeliveryOrderStatus | |||
| import com.ffii.fpsms.modules.stock.entity.SuggestPickLotRepository | |||
| @@ -72,7 +74,8 @@ open class PickExecutionIssueService( | |||
| private val stockLedgerRepository: StockLedgerRepository, | |||
| private val deliveryOrderRepository: DeliveryOrderRepository, | |||
| private val suggestedPickLotRepository: SuggestPickLotRepository, | |||
| private val doPickOrderRepository: DoPickOrderRepository | |||
| private val doPickOrderRepository: DoPickOrderRepository, | |||
| private val inventoryBucketResolver: InventoryBucketResolver, | |||
| ) { | |||
| // 配置属性:是否在批次拒绝时自动重新建议批次(默认为 false,不自动重新建议) | |||
| @@ -858,13 +861,8 @@ private fun handleMissItemWithPartialPick(request: PickExecutionIssueRequest, ac | |||
| println("Miss Qty: ${missQty}") | |||
| val lotId = request.lotId ?: return | |||
| val itemId = request.itemId ?: return | |||
| val inventoryLotLine = inventoryLotLineRepository.findById(lotId).orElse(null) | |||
| // ✅ 修复:在更新 lot line 之前获取 inventory 的 onHandQty | |||
| val inventoryBeforeUpdate = inventoryRepository.findByItemId(itemId).orElse(null) | |||
| val onHandQtyBeforeUpdate = (inventoryBeforeUpdate?.onHandQty ?: BigDecimal.ZERO).toDouble() | |||
| if (inventoryLotLine != null) { | |||
| // ✅ 修改:更新 outQty 为 actualPickQty | |||
| val currentOutQty = inventoryLotLine.outQty ?: BigDecimal.ZERO | |||
| @@ -927,9 +925,7 @@ private fun handleMissItemOnly(request: PickExecutionIssueRequest, missQty: BigD | |||
| val itemId = request.itemId ?: return | |||
| val inventoryLotLine = inventoryLotLineRepository.findById(lotId).orElse(null) | |||
| // ✅ 修复:在更新 lot line 之前获取 inventory 的 onHandQty | |||
| val inventoryBeforeUpdate = inventoryRepository.findByItemId(itemId).orElse(null) | |||
| val onHandQtyBeforeUpdate = (inventoryBeforeUpdate?.onHandQty ?: BigDecimal.ZERO).toDouble() | |||
| val onHandQtyBeforeUpdate = bucketOnHandQty(itemId, inventoryLotLine) | |||
| if (inventoryLotLine != null) { | |||
| // ✅ 修改:释放 holdQty(减少 holdQty) | |||
| @@ -1128,10 +1124,9 @@ private fun handleBothMissAndBadItem(request: PickExecutionIssueRequest, missQty | |||
| val lotId = request.lotId ?: return | |||
| val itemId = request.itemId ?: return | |||
| val inventoryLotLine = inventoryLotLineRepository.findById(lotId).orElse(null) | |||
| // ✅ 修复:在更新 lot line 之前获取 inventory 的 onHandQty | |||
| val inventoryBeforeUpdate = inventoryRepository.findByItemId(itemId).orElse(null) | |||
| val onHandQtyBeforeUpdate = (inventoryBeforeUpdate?.onHandQty ?: BigDecimal.ZERO).toDouble() | |||
| val onHandQtyBeforeUpdate = bucketOnHandQty(itemId, inventoryLotLine) | |||
| // ✅ 修复:更新 stock_out_line,但不要累积 qty | |||
| val stockOutLines = stockOutLineRepository.findByPickOrderLineIdAndInventoryLotLineIdAndDeletedFalse( | |||
| @@ -1163,7 +1158,6 @@ private fun handleBothMissAndBadItem(request: PickExecutionIssueRequest, missQty | |||
| } | |||
| // ✅ 修复:更新 inventory_lot_line 的 outQty | |||
| val inventoryLotLine = inventoryLotLineRepository.findById(lotId).orElse(null) | |||
| if (inventoryLotLine != null) { | |||
| // ✅ 修复:计算新的 outQty,考虑之前的 outQty | |||
| val currentOutQty = inventoryLotLine.outQty ?: BigDecimal.ZERO | |||
| @@ -2324,23 +2318,19 @@ private fun createStockLedgerForStockOut( | |||
| val item = stockOutLine.item ?: return | |||
| val outQty = stockOutLine.qty?.toDouble() ?: 0.0 | |||
| // ✅ 修复:如果传入了 balance,直接使用;否则重新查询 inventory | |||
| val inventory = resolveLedgerInventory(item.id!!, stockOutLine.inventoryLotLine) ?: return | |||
| val finalBalance = if (balance != null) { | |||
| if (balance < 0) 0.0 else balance | |||
| } else { | |||
| // 重新查询 inventory 以获取触发器更新后的最新 onHandQty | |||
| val inventory = inventoryRepository.findByItemId(item.id!!).orElse(null) ?: return | |||
| val currentOnHandQty = (inventory.onHandQty ?: BigDecimal.ZERO).toDouble() | |||
| val newBalance = currentOnHandQty - outQty | |||
| val previous = previousBalanceForInventory(inventory) | |||
| val newBalance = previous - outQty | |||
| if (newBalance < 0) 0.0 else newBalance | |||
| } | |||
| // 使用传入的 ledgerType,如果没有则使用 stockOutLine.type | |||
| val ledgerTypeToUse = ledgerType ?: stockOutLine.type ?: "Nor" | |||
| val inventory = inventoryRepository.findByItemId(item.id!!).orElse(null) ?: return | |||
| val ill = stockOutLine.inventoryLotLine | |||
| val outQtyBd = BigDecimal.valueOf(outQty) | |||
| val lotAfter = ill?.let { StockLedgerLotSnapshot.availableQty(it) } | |||
| @@ -2736,8 +2726,11 @@ open fun submitIssueWithQty(request: SubmitIssueWithQtyRequest): MessageResponse | |||
| ) | |||
| } | |||
| // ✅ 修复:在创建和保存 stockOutLine 之前获取 inventory 的当前 onHandQty | |||
| val inventoryBeforeUpdate = inventoryRepository.findByItemId(request.itemId).orElse(null) | |||
| val lotLine = request.lotId.let { | |||
| 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() | |||
| println("=== submitIssueWithQty: Before update ===") | |||
| @@ -2746,9 +2739,6 @@ open fun submitIssueWithQty(request: SubmitIssueWithQtyRequest): MessageResponse | |||
| println("OnHandQty Before Update: ${onHandQtyBeforeUpdate}") | |||
| println("==========================================") | |||
| val lotLine = request.lotId.let { | |||
| inventoryLotLineRepository.findById(it).orElse(null) | |||
| } | |||
| println("Lot line: id=${lotLine?.id}, lotNo=${lotLine?.inventoryLot?.lotNo}") | |||
| val item = itemsRepository.findById(request.itemId).orElse(null) | |||
| @@ -2835,7 +2825,7 @@ private fun createStockLedgerForStockOutWithBalance( | |||
| // Use传入的 ledgerType,如果没有则使用 stockOutLine.type | |||
| val ledgerTypeToUse = ledgerType ?: stockOutLine.type ?: "Nor" | |||
| val inventory = inventoryRepository.findByItemId(item.id!!).orElse(null) ?: return | |||
| val inventory = resolveLedgerInventory(item.id!!, stockOutLine.inventoryLotLine) ?: return | |||
| val ill = stockOutLine.inventoryLotLine | |||
| val outQtyBd = BigDecimal.valueOf(outQty) | |||
| @@ -2947,4 +2937,20 @@ private fun resolveLotUomInfo(lotId: Long?): Pair<String?, String?> { | |||
| return Pair(uomCode, uomDesc) | |||
| } | |||
| private fun bucketOnHandQty(itemId: Long, lotLine: InventoryLotLine?): Double { | |||
| val inventory = resolveLedgerInventory(itemId, lotLine) | |||
| return (inventory?.onHandQty ?: BigDecimal.ZERO).toDouble() | |||
| } | |||
| 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 { | |||
| stockLedgerRepository.findFirstByInventory_IdAndDeletedFalseOrderByDateDescIdDesc(it) | |||
| } | |||
| return latest?.balance ?: (inventory.onHandQty ?: BigDecimal.ZERO).toDouble() | |||
| } | |||
| } | |||
| @@ -60,6 +60,7 @@ interface StockLedgerRepository: AbstractRepository<StockLedger, Long> { | |||
| """) | |||
| fun findLatestByItemId(@Param("itemId") itemId: Long): List<StockLedger> | |||
| fun findFirstByItemIdAndDeletedFalseOrderByDateDescIdDesc(itemId: Long): StockLedger? | |||
| fun findFirstByInventory_IdAndDeletedFalseOrderByDateDescIdDesc(inventoryId: Long): StockLedger? | |||
| @Query(""" | |||
| SELECT sl FROM StockLedger sl | |||
| @@ -1074,7 +1074,7 @@ open class StockInLineService( | |||
| if (putAwayDeltaStockQty > BigDecimal.ZERO) { | |||
| val _tLedger = System.nanoTime() | |||
| createStockLedgerForStockIn(stockInLine, putAwayDeltaStockQty.toDouble()) | |||
| _logStep("createStockLedgerForStockIn(includes findLatestByItemId)", _tLedger) | |||
| _logStep("createStockLedgerForStockIn(includes findLatestByInventoryId)", _tLedger) | |||
| } | |||
| val _tQtyCalc = System.nanoTime() | |||
| @@ -1396,13 +1396,14 @@ open class StockInLineService( | |||
| val inventory = inventoryBucketResolver.findInventoryBucket(item.id!!, stockInLine.inventoryLotLine) ?: return | |||
| _logStep("inventoryBucketResolver.findInventoryBucket", _tInv) | |||
| // ✅ 修复:查询最新的 stock_ledger 记录,基于前一笔 balance 计算 | |||
| // Chain running balance on this inventory bucket only (not the whole item). | |||
| val _tLatest = System.nanoTime() | |||
| val latestLedger = stockLedgerRepository.findFirstByItemIdAndDeletedFalseOrderByDateDescIdDesc(item.id!!) | |||
| _logStep("stockLedgerRepository.findLatestByItemId", _tLatest) | |||
| val latestLedger = inventory.id?.let { | |||
| stockLedgerRepository.findFirstByInventory_IdAndDeletedFalseOrderByDateDescIdDesc(it) | |||
| } | |||
| _logStep("stockLedgerRepository.findLatestByInventoryId", _tLatest) | |||
| // ✅ 修复:如果 latestLedger 为 null(第一次 stock in),previousBalance 应该是 0 | |||
| // 因为 inventory.onHandQty 已经被触发器更新为包含本次 stock in 的数量 | |||
| // First ledger on this bucket starts at 0: inventory.onHandQty already includes this stock-in. | |||
| val previousBalance = latestLedger?.balance ?: 0.0 | |||
| val newBalance = previousBalance + inQty | |||
| @@ -1450,9 +1450,7 @@ open fun newBatchSubmit(request: QrPickBatchSubmitRequest): MessageResponse { | |||
| val inventory = inventoryBucketResolver.findInventoryBucket(item.id!!, stockOutLine.inventoryLotLine) ?: return | |||
| val outQty = stockOutLine.qty?.toDouble() ?: 0.0 | |||
| // Use latest ledger balance (same pattern as createStockLedgerForStockIn) so balance is correct when multiple actions run in one transaction | |||
| val latestLedger = stockLedgerRepository.findFirstByItemIdAndDeletedFalseOrderByDateDescIdDesc(item.id!!) | |||
| val previousBalance = latestLedger?.balance ?: (inventory.onHandQty ?: BigDecimal.ZERO).toDouble() | |||
| val previousBalance = resolvePreviousBalance(inventory = inventory) | |||
| val newBalance = previousBalance - outQty | |||
| val ill = stockOutLine.inventoryLotLine | |||
| @@ -1726,7 +1724,6 @@ open fun newBatchSubmit(request: QrPickBatchSubmitRequest): MessageResponse { | |||
| } | |||
| val previousBalance = resolvePreviousBalance( | |||
| itemId = item.id!!, | |||
| inventory = inventory, | |||
| onHandQtyBeforeUpdate = onHandQtyBeforeUpdate | |||
| ) | |||
| @@ -1774,14 +1771,15 @@ open fun newBatchSubmit(request: QrPickBatchSubmitRequest): MessageResponse { | |||
| } | |||
| private fun resolvePreviousBalance( | |||
| itemId: Long, | |||
| inventory: Inventory, | |||
| onHandQtyBeforeUpdate: Double? = null | |||
| ): Double { | |||
| if (onHandQtyBeforeUpdate != null) { | |||
| return onHandQtyBeforeUpdate | |||
| } | |||
| val latestLedger = stockLedgerRepository.findFirstByItemIdAndDeletedFalseOrderByDateDescIdDesc(itemId) | |||
| val latestLedger = inventory.id?.let { | |||
| stockLedgerRepository.findFirstByInventory_IdAndDeletedFalseOrderByDateDescIdDesc(it) | |||
| } | |||
| return latestLedger?.balance ?: (inventory.onHandQty ?: BigDecimal.ZERO).toDouble() | |||
| } | |||
| @@ -2137,10 +2135,7 @@ fun applyStockOutLineDelta( | |||
| val item = savedSol.item ?: return savedSol | |||
| val inventory = inventoryBucketResolver.findInventoryBucket(item.id!!, savedSol.inventoryLotLine) ?: return savedSol | |||
| val previousBalance = resolvePreviousBalance( | |||
| itemId = item.id!!, | |||
| inventory = inventory | |||
| ) | |||
| val previousBalance = resolvePreviousBalance(inventory = inventory) | |||
| val outQty = deltaQty.toDouble() | |||
| val newBalance = previousBalance - outQty | |||
| @@ -2287,10 +2282,7 @@ open fun createStockOutBatch(request: BatchStockOutRequest): BatchStockOutResult | |||
| ?: return@forEach | |||
| val delta = BigDecimal.valueOf(sol.qty ?: 0.0) | |||
| val prevBalance = runningLedgerBalanceByBucket[bucketKey] | |||
| ?: run { | |||
| val latestLedger = stockLedgerRepository.findFirstByItemIdAndDeletedFalseOrderByDateDescIdDesc(itemId) | |||
| latestLedger?.balance ?: (inv.onHandQty ?: BigDecimal.ZERO).toDouble() | |||
| } | |||
| ?: resolvePreviousBalance(inventory = inv) | |||
| val newBalance = prevBalance - delta.toDouble() | |||
| val ill = sol.inventoryLotLine | |||
| @@ -547,12 +547,13 @@ if (updated == 0) { | |||
| } | |||
| private fun resolveWorkbenchPreviousBalance( | |||
| itemId: Long, | |||
| inventory: Inventory, | |||
| onHandQtyBeforeUpdate: Double?, | |||
| ): Double { | |||
| if (onHandQtyBeforeUpdate != null) return onHandQtyBeforeUpdate | |||
| val latestLedger = stockLedgerRepository.findFirstByItemIdAndDeletedFalseOrderByDateDescIdDesc(itemId) | |||
| val latestLedger = inventory.id?.let { | |||
| stockLedgerRepository.findFirstByInventory_IdAndDeletedFalseOrderByDateDescIdDesc(it) | |||
| } | |||
| return latestLedger?.balance ?: (inventory.onHandQty ?: BigDecimal.ZERO).toDouble() | |||
| } | |||
| @@ -2482,7 +2482,7 @@ private fun applyVarianceAdjustment( | |||
| ?: throw IllegalArgumentException("Item ID not found for stock take ledger") | |||
| val ledgerKey = stockTakeLedgerBucketKey(itemIdForLedger, latestLine, inventory) | |||
| val previousBalance = runtimeCache?.runningLedgerBalanceByBucket?.get(ledgerKey) | |||
| ?: (inventory.onHandQty ?: BigDecimal.ZERO).toDouble() | |||
| ?: previousBalanceForInventoryBucket(inventory) | |||
| val newBalance = previousBalance - qtyToRemove.toDouble() | |||
| // FP-MTMS Version Checklist | Functions Ref. No. 46 | v1.0.1 | 2026-08-05 | |||
| @@ -2618,7 +2618,7 @@ private fun applyVarianceAdjustment( | |||
| ?: throw IllegalArgumentException("Item ID not found for stock take ledger (in)") | |||
| val ledgerKey = stockTakeLedgerBucketKey(itemIdForLedger, latestLine, inventory) | |||
| val previousBalance = runtimeCache?.runningLedgerBalanceByBucket?.get(ledgerKey) | |||
| ?: (inventory.onHandQty ?: BigDecimal.ZERO).toDouble() | |||
| ?: previousBalanceForInventoryBucket(inventory) | |||
| val newBalance = previousBalance + plusQty.toDouble() | |||
| // latestLine.inQty already includes plusQty above — snapshot live lot line after move. | |||
| @@ -2680,6 +2680,15 @@ private fun stockTakeLedgerBucketKey( | |||
| return itemId to stockUomId | |||
| } | |||
| private fun previousBalanceForInventoryBucket( | |||
| inventory: com.ffii.fpsms.modules.stock.entity.Inventory, | |||
| ): Double { | |||
| val latest = inventory.id?.let { | |||
| stockLedgerRepository.findFirstByInventory_IdAndDeletedFalseOrderByDateDescIdDesc(it) | |||
| } | |||
| return latest?.balance ?: (inventory.onHandQty ?: BigDecimal.ZERO).toDouble() | |||
| } | |||
| private data class BatchAdjustmentCache( | |||
| val inventoryLotLineByWarehouseLot: Map<Pair<Long, Long>, InventoryLotLine>, | |||
| val inventoryLotById: Map<Long, InventoryLot>, | |||