From e78b88c9d399b086e9ad2d23848cdd7245c4ec79 Mon Sep 17 00:00:00 2001 From: "CANCERYS\\kw093" Date: Fri, 9 Oct 2026 16:06:53 +0800 Subject: [PATCH] 2.7 b fix --- .../ledgerfix/StockLedgerFixAdjService.kt | 283 +++++------------- .../StockLedgerFixBackfillService.kt | 39 +++ 2 files changed, 121 insertions(+), 201 deletions(-) diff --git a/src/main/java/com/ffii/fpsms/modules/master/service/ledgerfix/StockLedgerFixAdjService.kt b/src/main/java/com/ffii/fpsms/modules/master/service/ledgerfix/StockLedgerFixAdjService.kt index daede986..70d1ad0e 100644 --- a/src/main/java/com/ffii/fpsms/modules/master/service/ledgerfix/StockLedgerFixAdjService.kt +++ b/src/main/java/com/ffii/fpsms/modules/master/service/ledgerfix/StockLedgerFixAdjService.kt @@ -20,24 +20,22 @@ open class StockLedgerFixAdjService( private val log = LoggerFactory.getLogger(StockLedgerFixAdjService::class.java) /** - * 2.7b. One ADJ per inventory bucket on [adjDate] (default yesterday; pass today on freeze-night dump). - * Quantity is onHandQty minus the bucket ledger net, including rows with no lot. - * The row is attached to the lot line whose own net is furthest from its ledger. - * Lot lines and inventory are not updated, so the lot-line trigger does not run. - * Each ADJ line also gets one stock_adjustment_record. A later run fills any ADJ line that still has none. - * A bucket whose ledger net already equals onHandQty is skipped. A second run inserts no new ADJ. - * All inbound lines share one stock_in; all outbound lines share one stock_out (per adjDate). + * 2.7b. One ADJ per lot line whose remain (in − out) differs from its ledger net. + * Quantity is that lot's own gap. Out greater than in is left to 2.7a. + * Lot lines and inventory are not updated. After the ADJ, the bucket balance is + * rebuilt from live in/out so a stale balance is not copied forward. + * A lot that already matches is skipped. A second run inserts no new ADJ. * Ledger net uses `date < adjDate+1`. */ open fun adjPreview(rowLimit: Int = 20, adjDateRaw: String? = null): StockLedgerFixAdjPreview { val (adjDate, asOfExclusive) = resolveAdjWindow(adjDateRaw) val t0 = System.nanoTime() - val plans = planBucketAdjs(asOfExclusive, resolveHost = false) + val plans = planLotAdjs(asOfExclusive) val sumIn = plans.fold(BigDecimal.ZERO) { a, g -> a + g.missIn } val sumOut = plans.fold(BigDecimal.ZERO) { a, g -> a + g.missOut } val limit = rowLimit.coerceIn(0, 50) log.info( - "stock-ledger-fix adj preview buckets={} in={} out={} {}ms", + "stock-ledger-fix 2.7b preview lots={} in={} out={} {}ms", plans.size, plans.count { it.missIn > BigDecimal.ZERO }, plans.count { it.missOut > BigDecimal.ZERO }, @@ -65,7 +63,7 @@ open class StockLedgerFixAdjService( val (adjDate, asOfExclusive) = resolveAdjWindow(adjDateRaw) val t0 = System.nanoTime() try { - val plans = planBucketAdjs(asOfExclusive, resolveHost = true) + val plans = planLotAdjs(asOfExclusive) var insertedIn = 0 var insertedOut = 0 val overIssuePatched = 0 @@ -118,7 +116,12 @@ open class StockLedgerFixAdjService( mapOf("adjDate" to adjDate, "asOf" to asOfExclusive), ).forEach { addLot(support.toLong(it["lotId"]), support.toLongOrNull(it["invId"])) } val filledLotQty = support.timed("adj 2.4") { backfill.fillLotQtyForLotIds(lotIds.toList(), asOfExclusive) } - val filledBalance = support.timed("adj 2.5") { backfill.fillBalanceOnNewAdjRows(adjDate, asOfExclusive) } + val filledBalance = support.timed("adj 2.5") { + backfill.fillBalanceForInventoryIds( + plans.mapNotNull { it.inventoryId }.distinct(), + asOfExclusive, + ) + } val range = if (lotIds.isEmpty()) null else backfill.dateRangeForFilter( "inventoryLotLineId IN (:lotIds)", mapOf("today" to asOfExclusive, "lotIds" to lotIds.toList()), @@ -127,7 +130,7 @@ open class StockLedgerFixAdjService( support.timed("adj 2.6") { backfill.upsertStockLotDayRange(lotIds.toList(), range.first, range.second) } } else 0 log.info( - "stock-ledger-fix adj {} buckets={} in={} out={} adjRecord={} lotQty={} bal={} day={} total={}ms", + "stock-ledger-fix 2.7b {} lots={} in={} out={} adjRecord={} lotQty={} bal={} day={} total={}ms", adjDate, plans.size, insertedIn, insertedOut, adjustmentRecords, filledLotQty, filledBalance, dayRows, (System.nanoTime() - t0) / 1_000_000, @@ -295,202 +298,80 @@ open class StockLedgerFixAdjService( } /** - * One ADJ per bucket whose latest balance differs from onHandQty. - * After 2.5 that balance equals the bucket ledger net, so the ADJ qty is onHandQty − balance. - * Preview does not scan lot history. Apply resolves which lot line the ADJ is attached to. - */ - private fun planBucketAdjs(today: LocalDate, resolveHost: Boolean): List { - val buckets = loadMismatchedByLastBalance(today) - if (buckets.isEmpty()) return emptyList() - if (!resolveHost) { - val lines = loadLotLines(buckets.mapNotNull { it.lotLineId }.distinct()) - return buckets.map { bucket -> previewGap(bucket, lines[bucket.lotLineId]) } - } - val lotsByInv = loadLotsOnBuckets(today, buckets.map { it.inventoryId }) - return buckets.mapNotNull { bucket -> - val host = chooseAdjHost(lotsByInv[bucket.inventoryId].orEmpty(), bucket.delta) - if (host == null) { - log.warn( - "stock-ledger-fix adj skip inventoryId={} delta={}: no lot line to attach", - bucket.inventoryId, bucket.delta, - ) - return@mapNotNull null - } - val needIn = bucket.delta > BigDecimal.ZERO - AdjGap( - lotLineId = host.lotLineId, - inventoryId = bucket.inventoryId, - itemId = host.itemId, - itemCode = host.itemCode, - uomId = host.uomId, - lineIn = host.lineIn, - lineOut = host.lineOut, - ledgerIn = host.ledgerIn, - ledgerOut = host.ledgerOut, - missIn = if (needIn) bucket.delta else BigDecimal.ZERO, - missOut = if (needIn) BigDecimal.ZERO else bucket.delta.negate(), - ) - } - } - - private data class BucketDelta( - val inventoryId: Long, - val delta: BigDecimal, - val lotLineId: Long?, - val itemId: Long?, - val itemCode: String?, - ) - - /** Preview row. Ledger in/out stay 0; the bucket delta is missIn / missOut. */ - private fun previewGap(bucket: BucketDelta, line: Map?) = AdjGap( - lotLineId = bucket.lotLineId ?: 0L, - inventoryId = bucket.inventoryId, - itemId = support.toLongOrNull(line?.get("itemId") ?: bucket.itemId), - itemCode = (line?.get("itemCode") ?: bucket.itemCode)?.toString(), - uomId = support.toLongOrNull(line?.get("uomId")), - lineIn = support.toDecimal(line?.get("lineIn")), - lineOut = support.toDecimal(line?.get("lineOut")), - ledgerIn = BigDecimal.ZERO, - ledgerOut = BigDecimal.ZERO, - missIn = if (bucket.delta > BigDecimal.ZERO) bucket.delta else BigDecimal.ZERO, - missOut = if (bucket.delta > BigDecimal.ZERO) BigDecimal.ZERO else bucket.delta.negate(), - ) - - private data class LotOnBucket( - val lotLineId: Long, - val itemId: Long?, - val itemCode: String?, - val uomId: Long?, - val lineIn: BigDecimal, - val lineOut: BigDecimal, - val ledgerIn: BigDecimal, - val ledgerOut: BigDecimal, - ) { - val lotGap: BigDecimal - get() = lineIn.subtract(lineOut).subtract(ledgerIn.subtract(ledgerOut)) - } - - /** - * Latest balance per bucket (date, then id). After 2.5 this equals the ledger net, - * so onHand − balance is the ADJ qty. One index seek per inventory, not a full sum. + * One ADJ per lot whose remain differs from its own ledger net. + * Gap = (line in − line out) − (ledger in − ledger out). Positive gap is an inbound ADJ. + * Lots with out greater than in are skipped; 2.7a owns those. */ - private fun loadMismatchedByLastBalance(today: LocalDate): List { - return support.jdbcDao.queryForList( + private fun planLotAdjs(today: LocalDate): List { + val lines = support.jdbcDao.queryForList( """ - SELECT i.id AS inventoryId, - i.itemId AS itemId, + SELECT ill.id AS lotLineId, + CAST(COALESCE(ill.inQty, 0) AS DECIMAL(14,2)) AS lineIn, + CAST(COALESCE(ill.outQty, 0) AS DECIMAL(14,2)) AS lineOut, + il.itemId AS itemId, it.code AS itemCode, - CAST(COALESCE(i.onHandQty, 0) AS DECIMAL(14,2)) AS onHand, - x.balance AS balance, - x.lotLineId AS lotLineId - FROM inventory i - LEFT JOIN items it ON it.id = i.itemId - INNER JOIN LATERAL ( - SELECT s.balance AS balance, - s.inventoryLotLineId AS lotLineId - FROM stock_ledger s FORCE INDEX (idx_ledger_inv_last) - WHERE s.inventoryId = i.id - AND s.deleted = 0 - AND s.date < :today - AND s.balance IS NOT NULL - ORDER BY s.date DESC, s.id DESC - LIMIT 1 - ) x ON TRUE - WHERE IFNULL(i.deleted, 0) = 0 - AND ABS(CAST(x.balance AS DECIMAL(14,2)) - - CAST(COALESCE(i.onHandQty, 0) AS DECIMAL(14,2))) > 0.01 + iu.uomId AS uomId, + i.id AS inventoryId + FROM inventory_lot_line ill + INNER JOIN inventory_lot il + ON il.id = ill.inventoryLotId AND IFNULL(il.deleted, 0) = 0 + INNER JOIN items it ON it.id = il.itemId + INNER JOIN item_uom iu ON iu.id = ill.stockItemUomId + LEFT JOIN inventory i + ON i.itemId = il.itemId + AND i.stockUomId = iu.uomId + AND IFNULL(i.deleted, 0) = 0 + WHERE IFNULL(ill.deleted, 0) = 0 + AND COALESCE(ill.outQty, 0) <= COALESCE(ill.inQty, 0) """.trimIndent(), - mapOf("today" to today), - ).mapNotNull { row -> - val delta = support.toDecimal(row["onHand"]).subtract(support.toDecimal(row["balance"])) - if (delta.abs().compareTo(ADJ_TOLERANCE) <= 0) null - else BucketDelta( - inventoryId = support.toLong(row["inventoryId"]), - delta = delta, - lotLineId = support.toLongOrNull(row["lotLineId"]), - itemId = support.toLongOrNull(row["itemId"]), - itemCode = row["itemCode"]?.toString(), - ) - } - } - - /** Lot nets only for buckets that need an ADJ, so the host lot can be chosen. */ - private fun loadLotsOnBuckets(today: LocalDate, inventoryIds: List): Map> { - val ledgerRows = mutableListOf>() - inventoryIds.chunked(50).forEach { chunk -> - ledgerRows.addAll( - support.jdbcDao.queryForList( - """ - SELECT sl.inventoryId, - sl.inventoryLotLineId AS lotLineId, - CAST(SUM(COALESCE(sl.inQty, 0)) AS DECIMAL(14,2)) AS ledgerIn, - CAST(SUM(COALESCE(sl.outQty, 0)) AS DECIMAL(14,2)) AS ledgerOut, - MIN(sl.itemId) AS itemId, - MIN(sl.itemCode) AS itemCode, - MIN(sl.uomId) AS uomId - FROM stock_ledger sl FORCE INDEX (idx_ledger_inv_date) - WHERE sl.deleted = 0 - AND sl.date < :today - AND sl.inventoryId IN (:ids) - AND sl.inventoryLotLineId IS NOT NULL - GROUP BY sl.inventoryId, sl.inventoryLotLineId - """.trimIndent(), - mapOf("today" to today, "ids" to chunk), - ), - ) - } - if (ledgerRows.isEmpty()) return emptyMap() - val lines = loadLotLines(ledgerRows.map { support.toLong(it["lotLineId"]) }.distinct()) - return ledgerRows.map { row -> - val lotLineId = support.toLong(row["lotLineId"]) - val line = lines[lotLineId] - LotOnBucket( - lotLineId = lotLineId, - itemId = support.toLongOrNull(line?.get("itemId") ?: row["itemId"]), - itemCode = (line?.get("itemCode") ?: row["itemCode"])?.toString(), - uomId = support.toLongOrNull(row["uomId"] ?: line?.get("uomId")), - lineIn = support.toDecimal(line?.get("lineIn")), - lineOut = support.toDecimal(line?.get("lineOut")), - ledgerIn = support.toDecimal(row["ledgerIn"]), - ledgerOut = support.toDecimal(row["ledgerOut"]), - ) to support.toLong(row["inventoryId"]) - }.groupBy({ it.second }, { it.first }) - } - - private fun loadLotLines(lotIds: List): Map> { - if (lotIds.isEmpty()) return emptyMap() - val lines = HashMap>() - lotIds.chunked(200).forEach { chunk -> + ) + if (lines.isEmpty()) return emptyList() + val byId = HashMap>(lines.size) + lines.forEach { byId[support.toLong(it["lotLineId"])] = it } + val gaps = ArrayList() + byId.keys.chunked(400).forEach { chunk -> + val netByLot = HashMap>(chunk.size) support.jdbcDao.queryForList( """ - SELECT ill.id AS lotLineId, - CAST(COALESCE(ill.inQty, 0) AS DECIMAL(14,2)) AS lineIn, - CAST(COALESCE(ill.outQty, 0) AS DECIMAL(14,2)) AS lineOut, - il.itemId AS itemId, - it.code AS itemCode, - iu.uomId AS uomId - FROM inventory_lot_line ill - INNER JOIN inventory_lot il ON il.id = ill.inventoryLotId - LEFT JOIN items it ON it.id = il.itemId - LEFT JOIN item_uom iu ON iu.id = ill.stockItemUomId - WHERE ill.id IN (:ids) + SELECT sl.inventoryLotLineId AS lotLineId, + CAST(SUM(COALESCE(sl.inQty, 0)) AS DECIMAL(14,2)) AS ledgerIn, + CAST(SUM(COALESCE(sl.outQty, 0)) AS DECIMAL(14,2)) AS ledgerOut + FROM stock_ledger sl + WHERE sl.deleted = 0 + AND sl.date < :today + AND sl.inventoryLotLineId IN (:ids) + GROUP BY sl.inventoryLotLineId """.trimIndent(), - mapOf("ids" to chunk), - ).forEach { lines[support.toLong(it["lotLineId"])] = it } - } - return lines - } - - /** Prefer the lot whose own gap has the same sign as the bucket delta, largest first. */ - private fun chooseAdjHost(lots: List, delta: BigDecimal): LotOnBucket? { - if (lots.isEmpty()) return null - val sameSign = lots.filter { - it.lotGap.abs().compareTo(ADJ_TOLERANCE) > 0 && it.lotGap.signum() == delta.signum() + mapOf("today" to today, "ids" to chunk), + ).forEach { netByLot[support.toLong(it["lotLineId"])] = it } + chunk.forEach lotLoop@{ lotId -> + val line = byId.getValue(lotId) + val lineIn = support.toDecimal(line["lineIn"]) + val lineOut = support.toDecimal(line["lineOut"]) + val net = netByLot[lotId] + val ledgerIn = support.toDecimal(net?.get("ledgerIn")) + val ledgerOut = support.toDecimal(net?.get("ledgerOut")) + val gap = lineIn.subtract(lineOut).subtract(ledgerIn.subtract(ledgerOut)) + if (gap.abs().compareTo(ADJ_TOLERANCE) <= 0) return@lotLoop + val needIn = gap.signum() > 0 + gaps.add( + AdjGap( + lotLineId = lotId, + inventoryId = support.toLongOrNull(line["inventoryId"]), + itemId = support.toLongOrNull(line["itemId"]), + itemCode = line["itemCode"]?.toString(), + uomId = support.toLongOrNull(line["uomId"]), + lineIn = lineIn, + lineOut = lineOut, + ledgerIn = ledgerIn, + ledgerOut = ledgerOut, + missIn = if (needIn) gap else BigDecimal.ZERO, + missOut = if (needIn) BigDecimal.ZERO else gap.negate(), + ), + ) + } } - if (sameSign.isNotEmpty()) return sameSign.maxBy { it.lotGap.abs() } - val anyGap = lots.filter { it.lotGap.abs().compareTo(ADJ_TOLERANCE) > 0 } - if (anyGap.isNotEmpty()) return anyGap.maxBy { it.lotGap.abs() } - return lots.maxBy { it.ledgerIn.add(it.ledgerOut) } + return gaps } @@ -806,7 +687,7 @@ open class StockLedgerFixAdjService( companion object { private val ADJ_TOLERANCE = BigDecimal("0.01") - private const val ADJ_DOC_REMARKS = "stock-ledger-fix bucket ADJ (lot line unchanged)" + private const val ADJ_DOC_REMARKS = "stock-ledger-fix 2.7b ADJ" private const val ADJ_LOT_LINE_REMARKS = "stock-ledger-fix 2.7a ADJ" private const val ADJ_HEADER_REMARKS = "stock-ledger-fix 2.7 ADJ" } diff --git a/src/main/java/com/ffii/fpsms/modules/master/service/ledgerfix/StockLedgerFixBackfillService.kt b/src/main/java/com/ffii/fpsms/modules/master/service/ledgerfix/StockLedgerFixBackfillService.kt index 47213821..0940d4f3 100644 --- a/src/main/java/com/ffii/fpsms/modules/master/service/ledgerfix/StockLedgerFixBackfillService.kt +++ b/src/main/java/com/ffii/fpsms/modules/master/service/ledgerfix/StockLedgerFixBackfillService.kt @@ -1556,6 +1556,45 @@ open class StockLedgerFixBackfillService( * Chain balance on new ADJ rows only; older rows for the same SKU are unchanged. * Opening balance is the latest date, then the highest id on that date (same rule as 2.5 lookback). */ + /** + * Rebuild bucket balance from live in/out (date, then id), so a stale stored balance is not kept. + */ + open internal fun fillBalanceForInventoryIds(inventoryIds: List, today: LocalDate): Int { + if (inventoryIds.isEmpty()) return 0 + var rows = 0 + inventoryIds.distinct().forEach { invId -> + rows += support.jdbcDao.executeUpdate( + """ + UPDATE stock_ledger sl + INNER JOIN ( + SELECT + sl2.id, + CAST( + SUM( + CAST(COALESCE(sl2.inQty, 0) AS DECIMAL(14,2)) + - CAST(COALESCE(sl2.outQty, 0) AS DECIMAL(14,2)) + ) OVER ( + PARTITION BY sl2.inventoryId + ORDER BY sl2.date, sl2.id + ROWS BETWEEN UNBOUNDED PRECEDING AND CURRENT ROW + ) + AS DECIMAL(14,2)) AS newBalance + FROM stock_ledger sl2 + WHERE sl2.deleted = 0 + AND sl2.date < :today + AND sl2.inventoryId = :inventoryId + ) calc ON calc.id = sl.id + SET sl.balance = calc.newBalance + WHERE sl.deleted = 0 + AND sl.date < :today + AND sl.inventoryId = :inventoryId + """.trimIndent(), + mapOf("today" to today, "inventoryId" to invId), + ) + } + return rows + } + open internal fun fillBalanceOnNewAdjRows(adjDate: LocalDate, today: LocalDate): Int { val rows = support.jdbcDao.queryForList( """