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 6cd69bdb..f30b6606 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,48 +20,40 @@ open class StockLedgerFixAdjService( private val log = LoggerFactory.getLogger(StockLedgerFixAdjService::class.java) /** - * Line in/out vs Σ ledger in/out. Any non-zero gap becomes ADJ on [adjDate] - * (default yesterday; pass today on freeze-night dump). - * Step 1: over-issue (`lineIn < lineOut`) sets lot inQty = outQty (trigger updates inventory) - * and writes SIL + ledger ADJ in. Step 2: miss ADJ (no further lot qty write). - * Positive miss → ADJ in/out; negative miss → reverse ADJ out/in. + * 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. + * A bucket whose ledger net already equals onHandQty is skipped. A second run inserts nothing. * All inbound lines share one stock_in; all outbound lines share one stock_out (per adjDate). - * Ledger gap sum uses `date < adjDate+1`. + * 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 gaps = loadAdjGaps(asOfExclusive) - val work = gaps.filter { it.hasWork() } - val over = work.filter { it.hasOverIssue() } - val miss = work.filter { it.hasMiss() } - val sumIn = miss.fold(BigDecimal.ZERO) { a, g -> a + g.missIn } - val sumOut = miss.fold(BigDecimal.ZERO) { a, g -> a + g.missOut } - val sumOver = over.fold(BigDecimal.ZERO) { a, g -> a + g.overIssue } + val plans = planBucketAdjs(asOfExclusive, resolveHost = false) + 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 lots={} over={} in={} out={} revIn={} revOut={} {}ms", - work.size, - over.size, - miss.count { it.missIn > BigDecimal.ZERO }, - miss.count { it.missOut > BigDecimal.ZERO }, - miss.count { it.missIn < BigDecimal.ZERO }, - miss.count { it.missOut < BigDecimal.ZERO }, + "stock-ledger-fix adj preview buckets={} in={} out={} {}ms", + plans.size, + plans.count { it.missIn > BigDecimal.ZERO }, + plans.count { it.missOut > BigDecimal.ZERO }, (System.nanoTime() - t0) / 1_000_000, ) - val rows = (over + miss.filter { !it.hasOverIssue() }).distinctBy { it.lotLineId }.take(limit) return StockLedgerFixAdjPreview( adjDate = adjDate.toString(), - lotCount = work.size, - adjInCount = miss.count { it.missIn > BigDecimal.ZERO }, - adjOutCount = miss.count { it.missOut > BigDecimal.ZERO }, - skippedNegCount = miss.count { it.missIn < BigDecimal.ZERO || it.missOut < BigDecimal.ZERO }, + lotCount = plans.size, + adjInCount = plans.count { it.missIn > BigDecimal.ZERO }, + adjOutCount = plans.count { it.missOut > BigDecimal.ZERO }, + skippedNegCount = 0, sumMissIn = sumIn.stripTrailingZeros().toPlainString(), sumMissOut = sumOut.stripTrailingZeros().toPlainString(), skuNet = (sumIn - sumOut).stripTrailingZeros().toPlainString(), - overIssueCount = over.size, - sumOverIssue = sumOver.stripTrailingZeros().toPlainString(), - rows = rows.map { it.toRow() }, + overIssueCount = 0, + sumOverIssue = "0", + rows = plans.take(limit).map { it.toRow() }, ) } @@ -72,14 +64,11 @@ open class StockLedgerFixAdjService( val (adjDate, asOfExclusive) = resolveAdjWindow(adjDateRaw) val t0 = System.nanoTime() try { - val gaps = loadAdjGaps(asOfExclusive) - val work = gaps.filter { it.hasWork() } - val over = work.filter { it.hasOverIssue() } - val miss = work.filter { it.hasMiss() } + val plans = planBucketAdjs(asOfExclusive, resolveHost = true) var insertedIn = 0 var insertedOut = 0 - var overIssuePatched = 0 - if (work.isNotEmpty()) { + val overIssuePatched = 0 + if (plans.isNotEmpty()) { val insTx = TransactionTemplate(support.transactionManager) insTx.timeout = 300 insTx.execute { @@ -89,34 +78,20 @@ open class StockLedgerFixAdjService( stockInId ?: ensureAdjStockInHeader(adjDate).also { stockInId = it } fun outHeader(): Long = stockOutId ?: ensureAdjStockOutHeader(adjDate).also { stockOutId = it } - over.forEach { g -> - if (patchLotRemainToZero(g.lotLineId)) { - insertAdjLedger( - g, adjDate, inQty = g.overIssue, outQty = null, - remarks = OVER_ISSUE_REMARKS, stockInId = inHeader(), - ) - insertedIn++ - overIssuePatched++ - } - } - miss.forEach { g -> - if (g.missIn.compareTo(BigDecimal.ZERO) > 0) { - insertAdjLedger(g, adjDate, inQty = g.missIn, outQty = null, stockInId = inHeader()) - insertedIn++ - } else if (g.missIn.compareTo(BigDecimal.ZERO) < 0) { - insertAdjLedger( - g, adjDate, inQty = null, outQty = g.missIn.negate(), stockOutId = outHeader(), - ) - insertedOut++ - } - if (g.missOut.compareTo(BigDecimal.ZERO) > 0) { - insertAdjLedger(g, adjDate, inQty = null, outQty = g.missOut, stockOutId = outHeader()) - insertedOut++ - } else if (g.missOut.compareTo(BigDecimal.ZERO) < 0) { - insertAdjLedger( - g, adjDate, inQty = g.missOut.negate(), outQty = null, stockInId = inHeader(), - ) - insertedIn++ + plans.forEach { g -> + when { + g.missIn > BigDecimal.ZERO -> { + insertAdjLedger( + g, adjDate, inQty = g.missIn, outQty = null, stockInId = inHeader(), + ) + insertedIn++ + } + g.missOut > BigDecimal.ZERO -> { + insertAdjLedger( + g, adjDate, inQty = null, outQty = g.missOut, stockOutId = outHeader(), + ) + insertedOut++ + } } } } @@ -125,7 +100,7 @@ open class StockLedgerFixAdjService( fun addLot(lotId: Long, invId: Long?) { if (lotId > 0) lotIds.add(lotId) } - work.forEach { addLot(it.lotLineId, it.inventoryId) } + plans.forEach { addLot(it.lotLineId, it.inventoryId) } support.jdbcDao.queryForList( """ SELECT DISTINCT inventoryLotLineId AS lotId, inventoryId AS invId @@ -147,8 +122,9 @@ open class StockLedgerFixAdjService( support.timed("adj 2.6") { backfill.upsertStockLotDayRange(lotIds.toList(), range.first, range.second) } } else 0 log.info( - "stock-ledger-fix adj {} over={} in={} out={} lotQty={} bal={} day={} total={}ms", - adjDate, overIssuePatched, insertedIn, insertedOut, filledLotQty, filledBalance, dayRows, + "stock-ledger-fix adj {} buckets={} in={} out={} lotQty={} bal={} day={} total={}ms", + adjDate, plans.size, insertedIn, insertedOut, + filledLotQty, filledBalance, dayRows, (System.nanoTime() - t0) / 1_000_000, ) return StockLedgerFixAdjResponse( @@ -185,141 +161,206 @@ open class StockLedgerFixAdjService( return adjDate to adjDate.plusDays(1) } - private fun loadAdjGaps(today: LocalDate): List { - val tx = TransactionTemplate(support.transactionManager) - tx.timeout = 300 - val args = mapOf("today" to today) - return tx.execute { - support.jdbcDao.executeUpdate("DROP TEMPORARY TABLE IF EXISTS tmp_adj_line") - support.jdbcDao.executeUpdate("DROP TEMPORARY TABLE IF EXISTS tmp_adj_led") - support.jdbcDao.executeUpdate( - """ - CREATE TEMPORARY TABLE tmp_adj_line ( - lotLineId BIGINT NOT NULL PRIMARY KEY, - inventoryId INT NULL, - itemId INT NULL, - itemCode VARCHAR(64) NULL, - uomId INT NULL, - lineIn DECIMAL(14,2) NOT NULL, - lineOut DECIMAL(14,2) NOT NULL + /** + * 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, ) - """.trimIndent(), + 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(), ) - support.jdbcDao.executeUpdate( - """ - CREATE TEMPORARY TABLE tmp_adj_led ( - inventoryLotLineId BIGINT NOT NULL PRIMARY KEY, - ledger_in DECIMAL(14,2) NOT NULL, - ledger_out DECIMAL(14,2) NOT NULL - ) - """.trimIndent(), + } + } + + 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. + */ + private fun loadMismatchedByLastBalance(today: LocalDate): List { + return support.jdbcDao.queryForList( + """ + SELECT i.id AS inventoryId, + i.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 + """.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(), ) - try { - support.timed("adj line-all") { - support.jdbcDao.executeUpdate( - """ - INSERT INTO tmp_adj_line ( - lotLineId, inventoryId, itemId, itemCode, uomId, lineIn, lineOut - ) - SELECT - ill.id, - inv.inventoryId, - il.itemId, - it.code, - iu.uomId, - CAST(COALESCE(ill.inQty, 0) AS DECIMAL(14,2)), - CAST(COALESCE(ill.outQty, 0) AS DECIMAL(14,2)) - FROM inventory_lot_line ill - INNER JOIN inventory_lot il - ON il.id = ill.inventoryLotId AND IFNULL(il.deleted, 0) = 0 - LEFT JOIN items it ON it.id = il.itemId - LEFT JOIN item_uom iu ON iu.id = ill.stockItemUomId - LEFT JOIN ( - SELECT itemId, COALESCE(stockUomId, uomId) AS bucketUomId, MIN(id) AS inventoryId - FROM inventory - WHERE IFNULL(deleted, 0) = 0 - GROUP BY itemId, COALESCE(stockUomId, uomId) - ) inv ON inv.itemId = il.itemId AND inv.bucketUomId = iu.uomId - WHERE IFNULL(ill.deleted, 0) = 0 - AND ( - COALESCE(ill.inQty, 0) <> 0 - OR COALESCE(ill.outQty, 0) <> 0 - ) - """.trimIndent(), - ) - } - val lineIds = support.jdbcDao.queryForList( - "SELECT lotLineId FROM tmp_adj_line", - ).map { support.toLong(it["lotLineId"]) }.filter { it > 0 } - log.info("stock-ledger-fix adj line rows={}", lineIds.size) - if (lineIds.isEmpty()) return@execute emptyList() - support.timed("adj ledger-by-line") { - lineIds.chunked(40).sumOf { chunk -> - support.jdbcDao.executeUpdate( - """ - INSERT INTO tmp_adj_led (inventoryLotLineId, ledger_in, ledger_out) - SELECT - sl.inventoryLotLineId, - CAST(SUM(COALESCE(sl.inQty, 0)) AS DECIMAL(14,2)), - CAST(SUM(COALESCE(sl.outQty, 0)) AS DECIMAL(14,2)) - FROM stock_ledger sl FORCE INDEX (idx_ledger_lot_date_id) - WHERE sl.deleted = 0 - AND sl.date < :today - AND sl.inventoryLotLineId IN (:ids) - GROUP BY sl.inventoryLotLineId - """.trimIndent(), - mapOf("today" to today, "ids" to chunk), - ) - } - } + } + } + + /** 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 - l.lotLineId, - l.inventoryId, - l.itemId, - l.itemCode, - l.uomId, - l.lineIn, - l.lineOut, - CAST(COALESCE(agg.ledger_in, 0) AS DECIMAL(14,2)) AS ledgerIn, - CAST(COALESCE(agg.ledger_out, 0) AS DECIMAL(14,2)) AS ledgerOut - FROM tmp_adj_line l - LEFT JOIN tmp_adj_led agg ON agg.inventoryLotLineId = l.lotLineId - WHERE l.lineIn <> CAST(COALESCE(agg.ledger_in, 0) AS DECIMAL(14,2)) - OR l.lineOut <> CAST(COALESCE(agg.ledger_out, 0) AS DECIMAL(14,2)) - OR l.lineIn < l.lineOut + 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(), - ).map { row -> - val lineIn = support.toDecimal(row["lineIn"]) - val lineOut = support.toDecimal(row["lineOut"]) - val ledgerIn = support.toDecimal(row["ledgerIn"]) - val ledgerOut = support.toDecimal(row["ledgerOut"]) - AdjGap( - lotLineId = support.toLong(row["lotLineId"]), - inventoryId = support.toLongOrNull(row["inventoryId"]), - itemId = support.toLongOrNull(row["itemId"]), - itemCode = row["itemCode"]?.toString(), - uomId = support.toLongOrNull(row["uomId"]), - lineIn = lineIn, - lineOut = lineOut, - ledgerIn = ledgerIn, - ledgerOut = ledgerOut, - missIn = lineIn.subtract(ledgerIn), - missOut = lineOut.subtract(ledgerOut), - ) - } - } finally { - try { - support.jdbcDao.executeUpdate("DROP TEMPORARY TABLE IF EXISTS tmp_adj_led") - support.jdbcDao.executeUpdate("DROP TEMPORARY TABLE IF EXISTS tmp_adj_line") - } catch (e: Exception) { - log.warn("stock-ledger-fix drop tmp_adj skipped: {}", e.message) - } - } - } ?: emptyList() + 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 -> + 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) + """.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() + } + 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) } } + private fun insertAdjLedger( g: AdjGap, adjDate: LocalDate, @@ -369,28 +410,6 @@ open class StockLedgerFixAdjService( ) } - /** Sets lot inQty = outQty so remain = 0; trigger recomputes inventory. */ - private fun patchLotRemainToZero(lotLineId: Long): Boolean { - val n = support.jdbcDao.executeUpdate( - """ - UPDATE inventory_lot_line - SET inQty = CAST(COALESCE(outQty, 0) AS DECIMAL(14,2)), - status = 'unavailable', - modified = NOW(), - modifiedBy = 'stock-ledger-fix', - version = IFNULL(version, 0) + 1 - WHERE id = :lotLineId - AND IFNULL(deleted, 0) = 0 - AND CAST(COALESCE(inQty, 0) AS DECIMAL(14,2)) - < CAST(COALESCE(outQty, 0) AS DECIMAL(14,2)) - """.trimIndent(), - mapOf("lotLineId" to lotLineId), - ) - if (n == 0) { - log.warn("stock-ledger-fix over-issue skip lot update: lotLineId={}", lotLineId) - } - return n > 0 - } private fun adjStockInCode(adjDate: LocalDate): String = "SLF-I-$adjDate" @@ -609,8 +628,8 @@ open class StockLedgerFixAdjService( } companion object { - private const val ADJ_DOC_REMARKS = "stock-ledger-fix miss ADJ (no lot qty change)" - private const val OVER_ISSUE_REMARKS = "stock-ledger-fix over-issue remain→0 (lot inQty patched)" + 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_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 2dbf3f02..38588957 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 @@ -1301,7 +1301,7 @@ open class StockLedgerFixBackfillService( - CAST(COALESCE(sl2.outQty, 0) AS DECIMAL(14,2)) ) OVER ( PARTITION BY sl2.inventoryLotLineId - ORDER BY sl2.id + ORDER BY sl2.date, sl2.id ROWS BETWEEN UNBOUNDED PRECEDING AND CURRENT ROW ) AS DECIMAL(14,2)) AS lotAfter, @@ -1330,7 +1330,10 @@ open class StockLedgerFixBackfillService( } } - /** Chain balance on new ADJ rows only; older rows for the same SKU are unchanged. */ + /** + * 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). + */ open internal fun fillBalanceOnNewAdjRows(adjDate: LocalDate, today: LocalDate): Int { val rows = support.jdbcDao.queryForList( """ @@ -1363,7 +1366,8 @@ open class StockLedgerFixBackfillService( AND inventoryId = :invId AND date < :today AND id < :adjId - ORDER BY id DESC + AND balance IS NOT NULL + ORDER BY date DESC, id DESC LIMIT 1 """.trimIndent(), mapOf("invId" to invId, "today" to today, "adjId" to id), diff --git a/src/main/resources/db/changelog/changes/20260930_ledger_inv_date_index/01_ledger_inv_date_index.sql b/src/main/resources/db/changelog/changes/20260930_ledger_inv_date_index/01_ledger_inv_date_index.sql new file mode 100644 index 00000000..239655a8 --- /dev/null +++ b/src/main/resources/db/changelog/changes/20260930_ledger_inv_date_index/01_ledger_inv_date_index.sql @@ -0,0 +1,19 @@ +--liquibase formatted sql + +--changeset fpsms:ledger_inv_date_covering_index +--comment: Cover 2.7 bucket net by inventoryId + date without reading the full stock_ledger row +--preconditions onFail:MARK_RAN +--precondition-sql-check expectedResult:0 SELECT COUNT(*) FROM information_schema.STATISTICS WHERE TABLE_SCHEMA = DATABASE() AND TABLE_NAME = 'stock_ledger' AND INDEX_NAME = 'idx_ledger_inv_date' + +ALTER TABLE `stock_ledger` + ADD INDEX `idx_ledger_inv_date` (`inventoryId`, `date`, `inventoryLotLineId`, `inQty`, `outQty`, `deleted`), + ALGORITHM=INPLACE, LOCK=NONE; + +--changeset fpsms:ledger_inv_last_balance_index +--comment: Last ledger balance per inventory for 2.7 preview (date desc, id desc, limit 1) +--preconditions onFail:MARK_RAN +--precondition-sql-check expectedResult:0 SELECT COUNT(*) FROM information_schema.STATISTICS WHERE TABLE_SCHEMA = DATABASE() AND TABLE_NAME = 'stock_ledger' AND INDEX_NAME = 'idx_ledger_inv_last' + +ALTER TABLE `stock_ledger` + ADD INDEX `idx_ledger_inv_last` (`inventoryId`, `deleted`, `date`, `id`, `balance`), + ALGORITHM=INPLACE, LOCK=NONE;