From 193a8cbab5aa249126c08afe45fb3408f82bd3e0 Mon Sep 17 00:00:00 2001 From: "CANCERYS\\kw093" Date: Mon, 14 Sep 2026 15:01:47 +0800 Subject: [PATCH] =?UTF-8?q?Liquibase=2020260910=EF=BC=9A#1=20stockUomId?= =?UTF-8?q?=E3=80=81#2=E2=80=933=20Option=20A=20trigger=E3=80=81#35=20inde?= =?UTF-8?q?x=20=E4=B8=8A=E7=B7=9A=20app=EF=BC=9A#5=20entity=E3=80=81#7=20a?= =?UTF-8?q?vailable=20=3D=20onHand=20=E2=88=92=20unavailable=E3=80=81#8=20?= =?UTF-8?q?=E6=90=9C=E5=B0=8B=E5=85=A8=20bucket=E3=80=81#34=20Resolver?= =?UTF-8?q?=EF=BC=88#10=EF=BC=8F#11=20=E5=B7=B2=E6=8E=A5=EF=BC=89=20Admin?= =?UTF-8?q?=201.0=EF=BC=9A=E8=A3=9C=20stockUomId=E3=80=81uomId=3Dbase?= =?UTF-8?q?=EF=BC=88=E9=80=99=E5=B0=B1=E6=98=AF=20#1=20=E7=9A=84=20data=20?= =?UTF-8?q?patch=EF=BC=89=202.3=20=E6=8A=8A=20ledger=20=E7=B6=81=E5=88=B0?= =?UTF-8?q?=E8=A9=B2=20bucket=202.7=EF=BC=9A=E5=85=88=E8=B6=85=E7=99=BC=20?= =?UTF-8?q?remain=E2=86=920=EF=BC=88=E6=94=B9=20lot=EF=BC=89=EF=BC=8C?= =?UTF-8?q?=E5=86=8D=20miss=EF=BC=9B=E5=85=B1=E7=94=A8=E4=B8=80=E5=BC=B5?= =?UTF-8?q?=E5=85=A5=E5=80=89=E5=96=AE=E3=80=81=E4=B8=80=E5=BC=B5=E5=87=BA?= =?UTF-8?q?=E5=80=89=E5=96=AE=202.6=20=E6=97=A5=E7=B5=90?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../master/service/StockLedgerFixService.kt | 759 +++++++++++++++--- .../master/web/models/StockLedgerFixModels.kt | 7 + .../stock/service/StockLotDayCloseService.kt | 124 ++- .../01_create_stock_lot_day.sql | 2 +- 4 files changed, 741 insertions(+), 151 deletions(-) diff --git a/src/main/java/com/ffii/fpsms/modules/master/service/StockLedgerFixService.kt b/src/main/java/com/ffii/fpsms/modules/master/service/StockLedgerFixService.kt index 7ef5a34..d780d4d 100644 --- a/src/main/java/com/ffii/fpsms/modules/master/service/StockLedgerFixService.kt +++ b/src/main/java/com/ffii/fpsms/modules/master/service/StockLedgerFixService.kt @@ -17,7 +17,9 @@ import com.ffii.fpsms.modules.master.web.models.StockLedgerFixSearchLotHit import com.ffii.fpsms.modules.stock.service.StockLotDayCloseService import org.slf4j.LoggerFactory import org.springframework.jdbc.core.RowCallbackHandler +import org.springframework.jdbc.core.namedparam.MapSqlParameterSource import org.springframework.jdbc.core.namedparam.NamedParameterJdbcTemplate +import org.springframework.jdbc.support.GeneratedKeyHolder import org.springframework.stereotype.Service import org.springframework.transaction.PlatformTransactionManager import org.springframework.transaction.annotation.Transactional @@ -36,7 +38,10 @@ import javax.sql.DataSource /** * Inventory 1.0 (A) then per-day stock_ledger backfill (2.1–2.6) and [stock_lot_day]. * 2.4 opening looks back to last lotQtyAfter (not yesterday day-table or 0). - * 2.7 ADJ aligns lot-line in/out to ledger sums (yesterday, new ids; reverse when ledger over). + * 2.7: (1) over-issue `lineIn < lineOut` → set lot inQty = outQty (trigger → inventory 0) + * plus SIL+ledger ADJ in; (2) miss ADJ: line vs Σ ledger, SIL/SOL on existing lot, no lot qty write. + * One Apply shares a single stock_in header and a single stock_out header (reused by adjDate); + * each lot is a line on that document. Ledger FKs the line ids. * * SIL/SOL have no inventoryId; lot is inventoryLotLineId / inventoryLotId only. */ @@ -48,6 +53,7 @@ open class StockLedgerFixService( private val stockLotDayCloseService: StockLotDayCloseService, ) { private val log = LoggerFactory.getLogger(StockLedgerFixService::class.java) + private val namedJdbc = NamedParameterJdbcTemplate(dataSource) private val inFlight = ConcurrentHashMap.newKeySet() private val inventoryLock = "inventory-1.0" private val runLock = "ledger-fix-run" @@ -111,9 +117,11 @@ open class StockLedgerFixService( } /** - * A: keep existing inventory ids; INSERT missing (itemId, uomId) from lot lines; - * recalc onHand/onHold/unavailable from Σ lot qty by item+UOM (0 if no lots). - * Does not UPDATE inventory_lot_line. + * A: keep existing inventory ids; + * backfill stockUomId from lot stock UOM (and set uomId to base); + * INSERT missing (itemId, stockUomId); + * recalc onHand/unavailable from Σ lot qty by item+stock UOM (0 if no lots). + * Does not write inventory.onHoldQty (hold stays on lot line). Does not UPDATE inventory_lot_line. */ open fun inventoryPreview(): StockLedgerFixInventoryPreview { val inventoryRows = toInt( @@ -127,10 +135,14 @@ open class StockLedgerFixService( val missingUomPairs = toInt( jdbcDao.queryForList(MISSING_UOM_PAIR_COUNT_SQL).firstOrNull()?.get("c"), ) + val nullStockUomId = toInt( + jdbcDao.queryForList(NULL_STOCK_UOM_COUNT_SQL).firstOrNull()?.get("c"), + ) return StockLedgerFixInventoryPreview( inventoryRows = inventoryRows, lotUomPairs = lotUomPairs, missingUomPairs = missingUomPairs, + nullStockUomId = nullStockUomId, ) } @@ -140,21 +152,31 @@ open class StockLedgerFixService( throw IllegalStateException("inventory 1.0 already running") } try { + val patchedFromUom = jdbcDao.executeUpdate(BACKFILL_STOCK_UOM_WHEN_UOM_MATCHES_LOT_SQL) + val patchedSingleLot = jdbcDao.executeUpdate(BACKFILL_STOCK_UOM_SINGLE_LOT_SQL) + val patchedStockUomId = patchedFromUom + patchedSingleLot val inserted = jdbcDao.executeUpdate(INSERT_MISSING_INVENTORY_SQL) val updated = jdbcDao.executeUpdate(UPDATE_INVENTORY_QTY_SQL) val missingAfter = toInt( jdbcDao.queryForList(MISSING_UOM_PAIR_COUNT_SQL).firstOrNull()?.get("c"), ) + val nullStockUomIdAfter = toInt( + jdbcDao.queryForList(NULL_STOCK_UOM_COUNT_SQL).firstOrNull()?.get("c"), + ) log.info( - "stock-ledger-fix inventory-1.0 inserted={} updated={} missingAfter={}", + "stock-ledger-fix inventory-1.0 patchedStockUomId={} inserted={} updated={} missingAfter={} nullStockUomIdAfter={}", + patchedStockUomId, inserted, updated, missingAfter, + nullStockUomIdAfter, ) return StockLedgerFixInventoryResponse( inserted = inserted, updated = updated, missingUomPairsAfter = missingAfter, + patchedStockUomId = patchedStockUomId, + nullStockUomIdAfter = nullStockUomIdAfter, ) } finally { inFlight.remove(inventoryLock) @@ -190,7 +212,7 @@ open class StockLedgerFixService( WHERE inv.id = sl.inventoryId AND IFNULL(inv.deleted, 0) = 0 AND inv.itemId = sl.itemId - AND inv.uomId = sl.uomId + AND COALESCE(inv.stockUomId, inv.uomId) = sl.uomId ) ) AS invIncorrect, SUM(sl.lotQtyAfter IS NULL OR sl.lotQtyBefore IS NULL) AS lotQtyMiss, @@ -641,37 +663,46 @@ open class StockLedgerFixService( /** * 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. - * Goal: last lotQtyAfter = line remain (negative allowed for true over-issue). + * All inbound lines share one stock_in; all outbound lines share one stock_out (per adjDate). * Ledger gap sum 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 auto = gaps.filter { it.missIn.compareTo(BigDecimal.ZERO) != 0 || it.missOut.compareTo(BigDecimal.ZERO) != 0 } - val sumIn = auto.fold(BigDecimal.ZERO) { a, g -> a + g.missIn } - val sumOut = auto.fold(BigDecimal.ZERO) { a, g -> a + g.missOut } + 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 limit = rowLimit.coerceIn(0, 50) log.info( - "stock-ledger-fix adj preview lots={} in={} out={} revIn={} revOut={} {}ms", - auto.size, - auto.count { it.missIn > BigDecimal.ZERO }, - auto.count { it.missOut > BigDecimal.ZERO }, - auto.count { it.missIn < BigDecimal.ZERO }, - auto.count { it.missOut < BigDecimal.ZERO }, + "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 }, (System.nanoTime() - t0) / 1_000_000, ) + val rows = (over + miss.filter { !it.hasOverIssue() }).distinctBy { it.lotLineId }.take(limit) return StockLedgerFixAdjPreview( adjDate = adjDate.toString(), - lotCount = auto.size, - adjInCount = auto.count { it.missIn > BigDecimal.ZERO }, - adjOutCount = auto.count { it.missOut > BigDecimal.ZERO }, - skippedNegCount = auto.count { it.missIn < BigDecimal.ZERO || it.missOut < BigDecimal.ZERO }, + 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 }, sumMissIn = sumIn.stripTrailingZeros().toPlainString(), sumMissOut = sumOut.stripTrailingZeros().toPlainString(), skuNet = (sumIn - sumOut).stripTrailingZeros().toPlainString(), - rows = auto.take(limit).map { it.toRow() }, + overIssueCount = over.size, + sumOverIssue = sumOver.stripTrailingZeros().toPlainString(), + rows = rows.map { it.toRow() }, ) } @@ -683,26 +714,49 @@ open class StockLedgerFixService( val t0 = System.nanoTime() try { val gaps = loadAdjGaps(asOfExclusive) - val auto = gaps.filter { it.missIn.compareTo(BigDecimal.ZERO) != 0 || it.missOut.compareTo(BigDecimal.ZERO) != 0 } + val work = gaps.filter { it.hasWork() } + val over = work.filter { it.hasOverIssue() } + val miss = work.filter { it.hasMiss() } var insertedIn = 0 var insertedOut = 0 - if (auto.isNotEmpty()) { + var overIssuePatched = 0 + if (work.isNotEmpty()) { val insTx = TransactionTemplate(transactionManager) insTx.timeout = 300 insTx.execute { - auto.forEach { g -> + var stockInId: Long? = null + var stockOutId: Long? = null + fun inHeader(): Long = + 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) + 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()) + 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) + 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) + insertAdjLedger( + g, adjDate, inQty = g.missOut.negate(), outQty = null, stockInId = inHeader(), + ) insertedIn++ } } @@ -712,7 +766,7 @@ open class StockLedgerFixService( fun addLot(lotId: Long, invId: Long?) { if (lotId > 0) lotIds.add(lotId) } - auto.forEach { addLot(it.lotLineId, it.inventoryId) } + work.forEach { addLot(it.lotLineId, it.inventoryId) } jdbcDao.queryForList( """ SELECT DISTINCT inventoryLotLineId AS lotId, inventoryId AS invId @@ -734,14 +788,15 @@ open class StockLedgerFixService( timed("adj 2.6") { upsertStockLotDayRange(lotIds.toList(), range.first, range.second) } } else 0 log.info( - "stock-ledger-fix adj {} in={} out={} lotQty={} bal={} day={} total={}ms", - adjDate, insertedIn, insertedOut, filledLotQty, filledBalance, dayRows, + "stock-ledger-fix adj {} over={} in={} out={} lotQty={} bal={} day={} total={}ms", + adjDate, overIssuePatched, insertedIn, insertedOut, filledLotQty, filledBalance, dayRows, (System.nanoTime() - t0) / 1_000_000, ) return StockLedgerFixAdjResponse( adjDate = adjDate.toString(), insertedIn = insertedIn, insertedOut = insertedOut, + overIssuePatched = overIssuePatched, filledLotQty = filledLotQty, filledBalance = filledBalance, dayRowsWritten = dayRows, @@ -817,9 +872,8 @@ open class StockLedgerFixService( } /** - * One HTTP call for [from]..[to] inclusive (before today). - * 2.1–2.5 use a single date window (same SQL as [fixDay]); 2.6 still walks days - * because stock_lot_day carry depends on the previous close. + * One HTTP call for [from]..[to] inclusive. + * Walks days like [fixDay] (each day commits) so 2.3–2.5 do not lock the whole range. */ open fun fixRange(fromRaw: String, toRaw: String, stepsRaw: List? = null): StockLedgerFixRunResponse { val from = parseDay(fromRaw) @@ -840,26 +894,28 @@ open class StockLedgerFixService( } val t0 = System.nanoTime() try { - val args = rangeArgs(from, to) - val filledLotLineId = if ("2.1" in steps) timed("range 2.1 fillLotLineId") { fillLotLineId(args) } else 0 - val filledUomId = if ("2.2" in steps) timed("range 2.2 fillUomId") { fillUomId(args) } else 0 - val filledInventoryId = if ("2.3" in steps) timed("range 2.3 fillInventoryId") { fillInventoryId(args) } else 0 - val filledLotQty = if ("2.4" in steps) timed("range 2.4 fillLotQty") { fillLotQty(args) } else 0 - val filledBalance = if ("2.5" in steps) timed("range 2.5 fillBalance") { fillBalance(args) } else 0 - val dayRowsWritten = if ("2.6" in steps) { - timed("range 2.6 upsertStockLotDay") { - var n = 0 - var d = from - while (!d.isAfter(to)) { - n += stockLotDayCloseService.upsertDayForFix(d).rowsWritten - d = d.plusDays(1) + var filledLotLineId = 0 + var filledUomId = 0 + var filledInventoryId = 0 + var filledLotQty = 0 + var filledBalance = 0 + var dayRowsWritten = 0 + var d = from + while (!d.isAfter(to)) { + val args = dayArgs(d) + if ("2.1" in steps) filledLotLineId += timed("range $d 2.1") { fillLotLineId(args) } + if ("2.2" in steps) filledUomId += timed("range $d 2.2") { fillUomId(args) } + if ("2.3" in steps) filledInventoryId += timed("range $d 2.3") { fillInventoryId(args) } + if ("2.4" in steps) filledLotQty += timed("range $d 2.4") { fillLotQty(args) } + if ("2.5" in steps) filledBalance += timed("range $d 2.5") { fillBalance(args) } + if ("2.6" in steps) { + dayRowsWritten += timed("range $d 2.6") { + stockLotDayCloseService.upsertDayForFix(d).rowsWritten } - n } - } else { - 0 + d = d.plusDays(1) } - val still = stillMiss(args) + val still = stillMiss(rangeArgs(from, to)) log.info( "stock-ledger-fix {} steps={} lotLine={} uom={} inv={} lotQty={} bal={} dayRows={} total={}ms", key, steps.joinToString(","), filledLotLineId, filledUomId, filledInventoryId, @@ -964,33 +1020,65 @@ open class StockLedgerFixService( ) } - /** 2.3 inventory by (itemId, uomId) from 1.0 MySQL step. */ + /** + * 2.3: ledger stock UOM (`sl.uomId` from 2.2) → inventory bucket. + * A joins `inventory.stockUomId` (indexable). B is leftover rows with null stockUomId. + */ private fun fillInventoryId(args: Map): Int { - return jdbcDao.executeUpdate( + return applyInventoryIdUpdates( + args, + extraJoin = "", + extraWhere = "sl.date >= :day AND sl.date < :dayNext", + ) + } + + private fun applyInventoryIdUpdates( + args: Map, + extraJoin: String, + extraWhere: String, + ): Int { + val byStockUom = jdbcDao.executeUpdate( """ UPDATE stock_ledger sl + $extraJoin INNER JOIN ( - SELECT i.itemId, i.uomId, MIN(i.id) AS inventoryId - FROM inventory i - INNER JOIN ( - SELECT DISTINCT itemId, uomId - FROM stock_ledger - WHERE deleted = 0 - AND date >= :day AND date < :dayNext - AND itemId IS NOT NULL - AND uomId IS NOT NULL - ) t ON t.itemId = i.itemId AND t.uomId = i.uomId - WHERE IFNULL(i.deleted, 0) = 0 - GROUP BY i.itemId, i.uomId + SELECT itemId, stockUomId, MIN(id) AS inventoryId + FROM inventory + WHERE IFNULL(deleted, 0) = 0 + AND stockUomId IS NOT NULL + GROUP BY itemId, stockUomId + ) inv ON inv.itemId = sl.itemId AND inv.stockUomId = sl.uomId + SET sl.inventoryId = inv.inventoryId + WHERE sl.deleted = 0 + AND sl.itemId IS NOT NULL + AND sl.uomId IS NOT NULL + AND (sl.inventoryId IS NULL OR sl.inventoryId <> inv.inventoryId) + AND ($extraWhere) + """.trimIndent(), + args, + ) + val byLegacyUom = jdbcDao.executeUpdate( + """ + UPDATE stock_ledger sl + $extraJoin + INNER JOIN ( + SELECT itemId, uomId, MIN(id) AS inventoryId + FROM inventory + WHERE IFNULL(deleted, 0) = 0 + AND stockUomId IS NULL + AND uomId IS NOT NULL + GROUP BY itemId, uomId ) inv ON inv.itemId = sl.itemId AND inv.uomId = sl.uomId SET sl.inventoryId = inv.inventoryId WHERE sl.deleted = 0 - AND sl.date >= :day AND sl.date < :dayNext AND sl.itemId IS NOT NULL AND sl.uomId IS NOT NULL + AND sl.inventoryId IS NULL + AND ($extraWhere) """.trimIndent(), args, ) + return byStockUom + byLegacyUom } /** @@ -1552,23 +1640,10 @@ open class StockLedgerFixService( private fun fillInventoryIdScope(args: Map, byInventory: Boolean): Int { if (!byInventory) { - return jdbcDao.executeUpdate( - """ - UPDATE stock_ledger sl - INNER JOIN ( - SELECT i.itemId, i.uomId, MIN(i.id) AS inventoryId - FROM inventory i - WHERE IFNULL(i.deleted, 0) = 0 - GROUP BY i.itemId, i.uomId - ) inv ON inv.itemId = sl.itemId AND inv.uomId = sl.uomId - SET sl.inventoryId = inv.inventoryId - WHERE sl.deleted = 0 - AND sl.date < :today - AND sl.itemId IS NOT NULL - AND sl.uomId IS NOT NULL - AND sl.inventoryLotLineId = :lotId - """.trimIndent(), + return applyInventoryIdUpdates( args, + extraJoin = "", + extraWhere = "sl.date < :today AND sl.inventoryLotLineId = :lotId", ) } return withTempTx { @@ -1607,18 +1682,10 @@ open class StockLedgerFixService( """.trimIndent(), args, ) - jdbcDao.executeUpdate( - """ - UPDATE stock_ledger sl - INNER JOIN tmp_sl_fix_inv_ids t ON t.id = sl.id - INNER JOIN ( - SELECT i.itemId, i.uomId, MIN(i.id) AS inventoryId - FROM inventory i - WHERE IFNULL(i.deleted, 0) = 0 - GROUP BY i.itemId, i.uomId - ) inv ON inv.itemId = sl.itemId AND inv.uomId = sl.uomId - SET sl.inventoryId = inv.inventoryId - """.trimIndent(), + applyInventoryIdUpdates( + args, + extraJoin = "INNER JOIN tmp_sl_fix_inv_ids t ON t.id = sl.id", + extraWhere = "1=1", ) } finally { jdbcDao.executeUpdate("DROP TEMPORARY TABLE IF EXISTS tmp_sl_fix_inv_ids") @@ -2063,8 +2130,8 @@ open class StockLedgerFixService( /** * Export SQL patch for target DB (same dump lineage / freeze). * [partsRaw] omit / empty / all = ledger + 2.6 (legacy). Else subset: - * 1.0 inventory, 2.3 inventoryId, ledger (lot/uom/lotQty/balance), 2.6 day, 2.7 ADJ INSERT. - * Apply order in file: 1.0 → ledger(+2.3) → 2.7 → 2.6. + * 1.0 inventory, 2.3 inventoryId, ledger (lot/uom/lotQty/balance), 2.6 day, 2.7 SIL/SOL + ADJ INSERT. + * Apply order in file: 1.0 → ledger(+2.3) → 2.7 docs → 2.7 ADJ → 2.6. */ open fun writeExportSql( from: LocalDate, @@ -2152,7 +2219,7 @@ open class StockLedgerFixService( writer.write("-- stock-ledger-fix patch\n") writer.write("-- from $from inclusive to ${toExclusive.minusDays(1)} inclusive (date < $toExclusive)\n") writer.write("-- parts=${parts.joinToString(",")}\n") - writer.write("-- apply order: 1.0 inventory → ledger(+2.3) UPDATE → 2.7 ADJ INSERT → 2.6 stock_lot_day\n") + writer.write("-- apply order: 1.0 inventory → ledger(+2.3) UPDATE → 2.7 SIL/SOL then ADJ INSERT → 2.6 stock_lot_day\n") writer.write("-- INNER JOIN id: target rows without matching id are skipped on UPDATE\n") if (want23 && !want10) { writer.write("-- WARN: 2.3 without 1.0 — new inventory ids may be missing on target; prefer include 1.0\n") @@ -2170,6 +2237,7 @@ open class StockLedgerFixService( writeExportLedgerPatch(writer, jdbc, args, wantLedger, want23) } if (want27) { + writeExportAdjDocs(writer, jdbc, args) writeExportAdjInsert(writer, jdbc, args) } if (want26) { @@ -2193,7 +2261,7 @@ open class StockLedgerFixService( jdbc.query( """ SELECT id, created, createdBy, version, modified, modifiedBy, deleted, - itemId, uomId, onHandQty, onHoldQty, unavailableQty, + itemId, uomId, stockUomId, onHandQty, onHoldQty, unavailableQty, price, currencyId, cpu, cpuUnit, cpm, cpmUnit, status FROM inventory WHERE IFNULL(deleted, 0) = 0 @@ -2205,7 +2273,7 @@ open class StockLedgerFixService( writer.write( "INSERT INTO inventory (\n" + " id, created, createdBy, version, modified, modifiedBy, deleted,\n" + - " itemId, uomId, onHandQty, onHoldQty, unavailableQty,\n" + + " itemId, uomId, stockUomId, onHandQty, onHoldQty, unavailableQty,\n" + " price, currencyId, cpu, cpuUnit, cpm, cpmUnit, status\n" + ") VALUES\n", ) @@ -2215,7 +2283,7 @@ open class StockLedgerFixService( writer.write( "(${rsSql(rs, "id")}, ${rsSql(rs, "created")}, ${rsSql(rs, "createdBy")}, ${rsSql(rs, "version")}, " + "${rsSql(rs, "modified")}, ${rsSql(rs, "modifiedBy")}, ${rsSql(rs, "deleted")}, " + - "${rsSql(rs, "itemId")}, ${rsSql(rs, "uomId")}, ${rsSql(rs, "onHandQty")}, " + + "${rsSql(rs, "itemId")}, ${rsSql(rs, "uomId")}, ${rsSql(rs, "stockUomId")}, ${rsSql(rs, "onHandQty")}, " + "${rsSql(rs, "onHoldQty")}, ${rsSql(rs, "unavailableQty")}, ${rsSql(rs, "price")}, " + "${rsSql(rs, "currencyId")}, ${rsSql(rs, "cpu")}, ${rsSql(rs, "cpuUnit")}, " + "${rsSql(rs, "cpm")}, ${rsSql(rs, "cpmUnit")}, ${rsSql(rs, "status")})", @@ -2226,6 +2294,7 @@ open class StockLedgerFixService( "\nON DUPLICATE KEY UPDATE " + "onHandQty=VALUES(onHandQty), onHoldQty=VALUES(onHoldQty), " + "unavailableQty=VALUES(unavailableQty), status=VALUES(status), " + + "uomId=VALUES(uomId), stockUomId=VALUES(stockUomId), " + "price=VALUES(price), currencyId=VALUES(currencyId), " + "cpu=VALUES(cpu), cpuUnit=VALUES(cpuUnit), cpm=VALUES(cpm), cpmUnit=VALUES(cpmUnit), " + "modified=VALUES(modified), modifiedBy=VALUES(modifiedBy), " + @@ -2240,6 +2309,7 @@ open class StockLedgerFixService( "\nON DUPLICATE KEY UPDATE " + "onHandQty=VALUES(onHandQty), onHoldQty=VALUES(onHoldQty), " + "unavailableQty=VALUES(unavailableQty), status=VALUES(status), " + + "uomId=VALUES(uomId), stockUomId=VALUES(stockUomId), " + "price=VALUES(price), currencyId=VALUES(currencyId), " + "cpu=VALUES(cpu), cpuUnit=VALUES(cpuUnit), cpm=VALUES(cpm), cpmUnit=VALUES(cpmUnit), " + "modified=VALUES(modified), modifiedBy=VALUES(modifiedBy), " + @@ -2325,6 +2395,119 @@ open class StockLedgerFixService( writer.write("\n") } + private fun writeExportAdjDocs( + writer: BufferedWriter, + jdbc: NamedParameterJdbcTemplate, + args: Map, + ) { + writer.write("-- === 2.7 ADJ stock_in / stock_out (1 header + many lines; apply before ledger ADJ FKs) ===\n") + writeExportRows( + writer, + jdbc, + """ + SELECT DISTINCT si.id, si.created, si.createdBy, si.version, si.modified, si.modifiedBy, si.deleted, + si.code, si.status, si.orderDate, si.completeDate + FROM stock_in si + INNER JOIN stock_in_line sil ON sil.stockInId = si.id AND IFNULL(sil.deleted, 0) = 0 + INNER JOIN stock_ledger sl ON sl.stockInLineId = sil.id + WHERE sl.deleted = 0 + AND sl.date >= :from AND sl.date < :toExclusive + AND sl.type = 'ADJ' + AND sl.createdBy = 'stock-ledger-fix' + ORDER BY si.id + """.trimIndent(), + args, + table = "stock_in", + columns = listOf( + "id", "created", "createdBy", "version", "modified", "modifiedBy", "deleted", + "code", "status", "orderDate", "completeDate", + ), + onDup = "code=VALUES(code), status=VALUES(status), orderDate=VALUES(orderDate), " + + "completeDate=VALUES(completeDate), modified=VALUES(modified), modifiedBy=VALUES(modifiedBy), " + + "version=stock_in.version+1, deleted=0", + emptyComment = "-- (no ADJ stock_in headers in range)", + ) + writeExportRows( + writer, + jdbc, + """ + SELECT sil.id, sil.created, sil.createdBy, sil.version, sil.modified, sil.modifiedBy, sil.deleted, + sil.itemId, sil.itemNo, sil.stockInId, sil.acceptedQty, sil.receiptDate, sil.status, + sil.inventoryLotId, sil.inventoryLotLineId, sil.lotNo, sil.remarks, sil.type + FROM stock_in_line sil + INNER JOIN stock_ledger sl ON sl.stockInLineId = sil.id + WHERE sl.deleted = 0 + AND sl.date >= :from AND sl.date < :toExclusive + AND sl.type = 'ADJ' + AND sl.createdBy = 'stock-ledger-fix' + ORDER BY sil.id + """.trimIndent(), + args, + table = "stock_in_line", + columns = listOf( + "id", "created", "createdBy", "version", "modified", "modifiedBy", "deleted", + "itemId", "itemNo", "stockInId", "acceptedQty", "receiptDate", "status", + "inventoryLotId", "inventoryLotLineId", "lotNo", "remarks", "type", + ), + onDup = "acceptedQty=VALUES(acceptedQty), inventoryLotId=VALUES(inventoryLotId), " + + "inventoryLotLineId=VALUES(inventoryLotLineId), lotNo=VALUES(lotNo), status=VALUES(status), " + + "type=VALUES(type), remarks=VALUES(remarks), modified=VALUES(modified), " + + "modifiedBy=VALUES(modifiedBy), version=stock_in_line.version+1, deleted=0", + emptyComment = "-- (no ADJ stock_in_line in range)", + ) + writeExportRows( + writer, + jdbc, + """ + SELECT DISTINCT so.id, so.created, so.createdBy, so.version, so.modified, so.modifiedBy, so.deleted, + so.type, so.completeDate, so.status, so.remarks + FROM stock_out so + INNER JOIN stock_out_line sol ON sol.stockOutId = so.id AND IFNULL(sol.deleted, 0) = 0 + INNER JOIN stock_ledger sl ON sl.stockOutLineId = sol.id + WHERE sl.deleted = 0 + AND sl.date >= :from AND sl.date < :toExclusive + AND sl.type = 'ADJ' + AND sl.createdBy = 'stock-ledger-fix' + ORDER BY so.id + """.trimIndent(), + args, + table = "stock_out", + columns = listOf( + "id", "created", "createdBy", "version", "modified", "modifiedBy", "deleted", + "type", "completeDate", "status", "remarks", + ), + onDup = "type=VALUES(type), completeDate=VALUES(completeDate), status=VALUES(status), " + + "remarks=VALUES(remarks), modified=VALUES(modified), modifiedBy=VALUES(modifiedBy), " + + "version=stock_out.version+1, deleted=0", + emptyComment = "-- (no ADJ stock_out headers in range)", + ) + writeExportRows( + writer, + jdbc, + """ + SELECT sol.id, sol.created, sol.createdBy, sol.version, sol.modified, sol.modifiedBy, sol.deleted, + sol.itemId, sol.qty, sol.stockOutId, sol.inventoryLotLineId, sol.status, sol.pickTime, sol.type + FROM stock_out_line sol + INNER JOIN stock_ledger sl ON sl.stockOutLineId = sol.id + WHERE sl.deleted = 0 + AND sl.date >= :from AND sl.date < :toExclusive + AND sl.type = 'ADJ' + AND sl.createdBy = 'stock-ledger-fix' + ORDER BY sol.id + """.trimIndent(), + args, + table = "stock_out_line", + columns = listOf( + "id", "created", "createdBy", "version", "modified", "modifiedBy", "deleted", + "itemId", "qty", "stockOutId", "inventoryLotLineId", "status", "pickTime", "type", + ), + onDup = "qty=VALUES(qty), inventoryLotLineId=VALUES(inventoryLotLineId), status=VALUES(status), " + + "pickTime=VALUES(pickTime), type=VALUES(type), modified=VALUES(modified), " + + "modifiedBy=VALUES(modifiedBy), version=stock_out_line.version+1, deleted=0", + emptyComment = "-- (no ADJ stock_out_line in range)", + ) + } + private fun writeExportAdjInsert( writer: BufferedWriter, jdbc: NamedParameterJdbcTemplate, @@ -2372,6 +2555,7 @@ open class StockLedgerFixService( writer.write( "\nON DUPLICATE KEY UPDATE " + "inventoryLotLineId=VALUES(inventoryLotLineId), inventoryId=VALUES(inventoryId), " + + "stockInLineId=VALUES(stockInLineId), stockOutLineId=VALUES(stockOutLineId), " + "inQty=VALUES(inQty), outQty=VALUES(outQty), balance=VALUES(balance), " + "lotQtyBefore=VALUES(lotQtyBefore), lotQtyAfter=VALUES(lotQtyAfter), " + "uomId=VALUES(uomId), modified=VALUES(modified), modifiedBy=VALUES(modifiedBy), " + @@ -2385,6 +2569,7 @@ open class StockLedgerFixService( writer.write( "\nON DUPLICATE KEY UPDATE " + "inventoryLotLineId=VALUES(inventoryLotLineId), inventoryId=VALUES(inventoryId), " + + "stockInLineId=VALUES(stockInLineId), stockOutLineId=VALUES(stockOutLineId), " + "inQty=VALUES(inQty), outQty=VALUES(outQty), balance=VALUES(balance), " + "lotQtyBefore=VALUES(lotQtyBefore), lotQtyAfter=VALUES(lotQtyAfter), " + "uomId=VALUES(uomId), modified=VALUES(modified), modifiedBy=VALUES(modifiedBy), " + @@ -2494,6 +2679,41 @@ open class StockLedgerFixService( return out } + private fun writeExportRows( + writer: BufferedWriter, + jdbc: NamedParameterJdbcTemplate, + sql: String, + args: Map, + table: String, + columns: List, + onDup: String, + emptyComment: String, + ) { + var batch = 0 + jdbc.query( + sql, + args, + RowCallbackHandler { rs -> + if (batch == 0) { + writer.write("INSERT INTO $table (\n ${columns.joinToString(", ")}\n) VALUES\n") + } else { + writer.write(",\n") + } + writer.write("(" + columns.joinToString(", ") { rsSql(rs, it) } + ")") + batch++ + if (batch >= 200) { + writer.write("\nON DUPLICATE KEY UPDATE $onDup;\n\n") + batch = 0 + } + }, + ) + if (batch > 0) { + writer.write("\nON DUPLICATE KEY UPDATE $onDup;\n\n") + } else { + writer.write("$emptyComment\n\n") + } + } + private fun rsSql(rs: ResultSet, col: String): String { val v = rs.getObject(col) if (v == null || rs.wasNull()) return "NULL" @@ -2514,7 +2734,7 @@ open class StockLedgerFixService( "prevDay" to day.minusDays(1), ) - /** Inclusive [from]..[to]; same keys as [dayArgs] so 2.1–2.5 SQL can reuse the day window. */ + /** Inclusive [from]..[to]; same keys as [dayArgs] (used by stillMiss over the whole range). */ private fun rangeArgs(from: LocalDate, to: LocalDate): Map = mapOf( "day" to from, @@ -2652,11 +2872,11 @@ open class StockLedgerFixService( LEFT JOIN items it ON it.id = il.itemId LEFT JOIN item_uom iu ON iu.id = ill.stockItemUomId LEFT JOIN ( - SELECT itemId, uomId, MIN(id) AS inventoryId + SELECT itemId, COALESCE(stockUomId, uomId) AS bucketUomId, MIN(id) AS inventoryId FROM inventory WHERE IFNULL(deleted, 0) = 0 - GROUP BY itemId, uomId - ) inv ON inv.itemId = il.itemId AND inv.uomId = iu.uomId + 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 @@ -2705,6 +2925,7 @@ open class StockLedgerFixService( 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 """.trimIndent(), ).map { row -> val lineIn = toDecimal(row["lineIn"]) @@ -2736,9 +2957,29 @@ open class StockLedgerFixService( } ?: emptyList() } - private fun insertAdjLedger(g: AdjGap, adjDate: LocalDate, inQty: BigDecimal?, outQty: BigDecimal?) { + private fun insertAdjLedger( + g: AdjGap, + adjDate: LocalDate, + inQty: BigDecimal?, + outQty: BigDecimal?, + remarks: String = ADJ_DOC_REMARKS, + stockInId: Long? = null, + stockOutId: Long? = null, + ) { + val silId = if (inQty != null) { + insertAdjStockInLine(g, adjDate, inQty, remarks, stockInId ?: error("stockInId required for ADJ in")) + } else { + null + } + val solId = if (outQty != null) { + insertAdjStockOutLine(g, adjDate, outQty, remarks, stockOutId ?: error("stockOutId required for ADJ out")) + } else { + null + } val args = hashMapOf( "adjDate" to adjDate, + "silId" to silId, + "solId" to solId, "lotLineId" to g.lotLineId, "inventoryId" to g.inventoryId, "itemId" to g.itemId, @@ -2756,7 +2997,7 @@ open class StockLedgerFixService( uomId, type ) VALUES ( NOW(), 'stock-ledger-fix', 0, NOW(), 'stock-ledger-fix', 0, - :adjDate, NULL, NULL, :lotLineId, :inventoryId, + :adjDate, :silId, :solId, :lotLineId, :inventoryId, :itemId, :itemCode, :inQty, :outQty, NULL, NULL, NULL, :uomId, 'ADJ' ) @@ -2765,6 +3006,206 @@ open class StockLedgerFixService( ) } + /** Sets lot inQty = outQty so remain = 0; trigger recomputes inventory. */ + private fun patchLotRemainToZero(lotLineId: Long): Boolean { + val n = 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" + + private fun adjStockOutRemarks(adjDate: LocalDate): String = "$ADJ_HEADER_REMARKS $adjDate" + + /** One stock_in per ADJ date (reused if Apply runs again). */ + private fun ensureAdjStockInHeader(adjDate: LocalDate): Long { + val code = adjStockInCode(adjDate) + val existing = jdbcDao.queryForMap( + """ + SELECT id FROM stock_in + WHERE code = :code AND IFNULL(deleted, 0) = 0 + LIMIT 1 + """.trimIndent(), + mapOf("code" to code), + ).orElse(null) + if (existing != null) return toLong(existing["id"]) + val at = adjDate.atTime(12, 0) + return insertReturningId( + """ + INSERT INTO stock_in ( + created, createdBy, version, modified, modifiedBy, deleted, + code, status, orderDate, completeDate + ) VALUES ( + NOW(), 'stock-ledger-fix', 0, NOW(), 'stock-ledger-fix', 0, + :code, 'completed', :at, :at + ) + """.trimIndent(), + mapOf("code" to code, "at" to at), + ) + } + + /** One stock_out per ADJ date (reused if Apply runs again). */ + private fun ensureAdjStockOutHeader(adjDate: LocalDate): Long { + val remarks = adjStockOutRemarks(adjDate) + val existing = jdbcDao.queryForMap( + """ + SELECT id FROM stock_out + WHERE createdBy = 'stock-ledger-fix' + AND type = 'ADJ' + AND remarks = :remarks + AND IFNULL(deleted, 0) = 0 + ORDER BY id + LIMIT 1 + """.trimIndent(), + mapOf("remarks" to remarks), + ).orElse(null) + if (existing != null) return toLong(existing["id"]) + val at = adjDate.atTime(12, 0) + return insertReturningId( + """ + INSERT INTO stock_out ( + created, createdBy, version, modified, modifiedBy, deleted, + type, completeDate, status, remarks + ) VALUES ( + NOW(), 'stock-ledger-fix', 0, NOW(), 'stock-ledger-fix', 0, + 'ADJ', :at, 'completed', :remarks + ) + """.trimIndent(), + mapOf("at" to at, "remarks" to remarks), + ) + } + + /** Line on shared stock_in; does not UPDATE inventory_lot_line or inventory. */ + private fun insertAdjStockInLine( + g: AdjGap, + adjDate: LocalDate, + qty: BigDecimal, + remarks: String, + stockInId: Long, + ): Long? { + val itemId = g.itemId + if (itemId == null) { + log.warn("stock-ledger-fix adj skip SIL: lotLineId={} has no itemId", g.lotLineId) + return null + } + val lot = loadAdjLotLink(g.lotLineId) ?: return null + val at = adjDate.atTime(12, 0) + return insertReturningId( + """ + INSERT INTO stock_in_line ( + created, createdBy, version, modified, modifiedBy, deleted, + itemId, itemNo, stockInId, acceptedQty, receiptDate, status, + inventoryLotId, inventoryLotLineId, lotNo, remarks, type + ) VALUES ( + NOW(), 'stock-ledger-fix', 0, NOW(), 'stock-ledger-fix', 0, + :itemId, :itemNo, :stockInId, :qty, :at, 'completed', + :inventoryLotId, :lotLineId, :lotNo, :remarks, 'ADJ' + ) + """.trimIndent(), + mapOf( + "itemId" to itemId, + "itemNo" to adjItemNo(g.itemCode), + "stockInId" to stockInId, + "qty" to qty, + "at" to at, + "inventoryLotId" to lot.inventoryLotId, + "lotLineId" to g.lotLineId, + "lotNo" to lot.lotNo, + "remarks" to remarks, + ), + ) + } + + /** Line on shared stock_out; does not UPDATE inventory_lot_line or inventory. */ + private fun insertAdjStockOutLine( + g: AdjGap, + adjDate: LocalDate, + qty: BigDecimal, + remarks: String, + stockOutId: Long, + ): Long? { + val itemId = g.itemId + if (itemId == null) { + log.warn("stock-ledger-fix adj skip SOL: lotLineId={} has no itemId", g.lotLineId) + return null + } + if (loadAdjLotLink(g.lotLineId) == null) return null + val at = adjDate.atTime(12, 0) + return insertReturningId( + """ + INSERT INTO stock_out_line ( + created, createdBy, version, modified, modifiedBy, deleted, + itemId, qty, stockOutId, inventoryLotLineId, status, pickTime, type + ) VALUES ( + NOW(), 'stock-ledger-fix', 0, NOW(), 'stock-ledger-fix', 0, + :itemId, :qty, :stockOutId, :lotLineId, 'completed', :at, 'ADJ' + ) + """.trimIndent(), + mapOf( + "itemId" to itemId, + "qty" to qty, + "stockOutId" to stockOutId, + "lotLineId" to g.lotLineId, + "at" to at, + ), + ) + } + + private fun loadAdjLotLink(lotLineId: Long): AdjLotLink? { + val row = jdbcDao.queryForMap( + """ + SELECT ill.inventoryLotId AS inventoryLotId, il.lotNo AS lotNo + FROM inventory_lot_line ill + LEFT JOIN inventory_lot il ON il.id = ill.inventoryLotId + WHERE ill.id = :lotLineId + AND IFNULL(ill.deleted, 0) = 0 + """.trimIndent(), + mapOf("lotLineId" to lotLineId), + ).orElse(null) + if (row == null) { + log.warn("stock-ledger-fix adj skip SIL/SOL: lotLineId={} not found", lotLineId) + return null + } + return AdjLotLink( + inventoryLotId = toLongOrNull(row["inventoryLotId"]), + lotNo = row["lotNo"]?.toString(), + ) + } + + private fun insertReturningId(sql: String, args: Map): Long { + val keyHolder = GeneratedKeyHolder() + namedJdbc.update(sql, MapSqlParameterSource(args), keyHolder, arrayOf("id")) + val key = keyHolder.key?.toLong() + ?: keyHolder.keyList.firstOrNull()?.values?.firstOrNull()?.let { (it as Number).toLong() } + return key ?: error("stock-ledger-fix: insert did not return id") + } + + private fun adjItemNo(itemCode: String?): String { + val raw = itemCode?.trim().orEmpty() + return if (raw.isEmpty()) "SLF" else raw.take(20) + } + + private data class AdjLotLink( + val inventoryLotId: Long?, + val lotNo: String?, + ) + private fun fillLotQtyForLotIds(lotIds: List, today: LocalDate): Int { if (lotIds.isEmpty()) return 0 return withTempTx { @@ -2920,6 +3361,16 @@ open class StockLedgerFixService( val missIn: BigDecimal, val missOut: BigDecimal, ) { + val overIssue: BigDecimal = + if (lineOut.compareTo(lineIn) > 0) lineOut.subtract(lineIn) else BigDecimal.ZERO + + fun hasOverIssue(): Boolean = overIssue.compareTo(BigDecimal.ZERO) > 0 + + fun hasMiss(): Boolean = + missIn.compareTo(BigDecimal.ZERO) != 0 || missOut.compareTo(BigDecimal.ZERO) != 0 + + fun hasWork(): Boolean = hasOverIssue() || hasMiss() + fun toRow() = StockLedgerFixAdjRow( lotLineId = lotLineId, inventoryId = inventoryId, @@ -2930,6 +3381,7 @@ open class StockLedgerFixService( ledgerOut = ledgerOut.stripTrailingZeros().toPlainString(), missIn = missIn.stripTrailingZeros().toPlainString(), missOut = missOut.stripTrailingZeros().toPlainString(), + overIssue = overIssue.stripTrailingZeros().toPlainString(), ) } @@ -2956,6 +3408,9 @@ open class StockLedgerFixService( private val DAY_STEPS = linkedSetOf("2.1", "2.2", "2.3", "2.4", "2.5", "2.6") /** Legacy default export: ledger calc fields + stock_lot_day (no inventory / inventoryId / ADJ). */ private val EXPORT_PARTS_DEFAULT = linkedSetOf("ledger", "2.6") + 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 const val ADJ_HEADER_REMARKS = "stock-ledger-fix 2.7 ADJ" private const val LOT_UOM_PAIR_COUNT_SQL = """ SELECT COUNT(*) AS c FROM ( @@ -2982,7 +3437,7 @@ open class StockLedgerFixService( ON iu.id = ill.stockItemUomId LEFT JOIN inventory inv ON inv.itemId = il.itemId - AND inv.uomId = iu.uomId + AND COALESCE(inv.stockUomId, inv.uomId) = iu.uomId AND IFNULL(inv.deleted, 0) = 0 WHERE IFNULL(ill.deleted, 0) = 0 AND il.itemId IS NOT NULL @@ -2992,14 +3447,90 @@ open class StockLedgerFixService( ) x """ + private const val NULL_STOCK_UOM_COUNT_SQL = """ + SELECT COUNT(*) AS c + FROM inventory + WHERE IFNULL(deleted, 0) = 0 + AND stockUomId IS NULL + """ + + /** + * Old row: uomId already equals a lot stock UOM (stock, or base when base=stock). + * Fill stockUomId from that lot stock; move uomId to item base. + */ + private const val BACKFILL_STOCK_UOM_WHEN_UOM_MATCHES_LOT_SQL = """ + UPDATE inventory i + INNER JOIN ( + SELECT DISTINCT il.itemId, iu.uomId AS stockUomId + FROM inventory_lot_line ill + INNER JOIN inventory_lot il + ON il.id = ill.inventoryLotId AND IFNULL(il.deleted, 0) = 0 + INNER JOIN item_uom iu + ON iu.id = ill.stockItemUomId + WHERE IFNULL(ill.deleted, 0) = 0 + AND il.itemId IS NOT NULL + AND iu.uomId IS NOT NULL + ) b ON b.itemId = i.itemId AND b.stockUomId = i.uomId + LEFT JOIN item_uom baseIu + ON baseIu.itemId = i.itemId + AND baseIu.baseUnit = 1 + AND IFNULL(baseIu.deleted, 0) = 0 + SET + i.stockUomId = b.stockUomId, + i.uomId = COALESCE(baseIu.uomId, i.uomId), + i.modified = NOW(), + i.modifiedBy = 'stock-ledger-fix' + WHERE IFNULL(i.deleted, 0) = 0 + AND i.stockUomId IS NULL + """ + + /** + * Remaining NULL stockUomId: item has exactly one lot stock UOM + * (uomId already base, stock may differ). Skip if that bucket row already exists. + */ + private const val BACKFILL_STOCK_UOM_SINGLE_LOT_SQL = """ + UPDATE inventory i + INNER JOIN ( + SELECT il.itemId, MIN(iu.uomId) AS stockUomId + FROM inventory_lot_line ill + INNER JOIN inventory_lot il + ON il.id = ill.inventoryLotId AND IFNULL(il.deleted, 0) = 0 + INNER JOIN item_uom iu + ON iu.id = ill.stockItemUomId + WHERE IFNULL(ill.deleted, 0) = 0 + AND il.itemId IS NOT NULL + AND iu.uomId IS NOT NULL + GROUP BY il.itemId + HAVING COUNT(DISTINCT iu.uomId) = 1 + ) b ON b.itemId = i.itemId + LEFT JOIN item_uom baseIu + ON baseIu.itemId = i.itemId + AND baseIu.baseUnit = 1 + AND IFNULL(baseIu.deleted, 0) = 0 + LEFT JOIN inventory taken + ON taken.itemId = i.itemId + AND taken.stockUomId = b.stockUomId + AND IFNULL(taken.deleted, 0) = 0 + AND taken.id <> i.id + SET + i.stockUomId = b.stockUomId, + i.uomId = COALESCE(baseIu.uomId, i.uomId), + i.modified = NOW(), + i.modifiedBy = 'stock-ledger-fix' + WHERE IFNULL(i.deleted, 0) = 0 + AND i.stockUomId IS NULL + AND taken.id IS NULL + """ + private const val INSERT_MISSING_INVENTORY_SQL = """ INSERT INTO inventory ( - itemId, uomId, onHandQty, onHoldQty, unavailableQty, + itemId, uomId, stockUomId, onHandQty, onHoldQty, unavailableQty, price, currencyId, cpu, cpuUnit, cpm, cpmUnit, status, created, createdBy, modified, modifiedBy, version, deleted ) SELECT src.itemId, + COALESCE(baseIu.uomId, src.uomId), src.uomId, 0, 0, 0, COALESCE(tpl.price, 0), @@ -3022,9 +3553,13 @@ open class StockLedgerFixService( AND iu.uomId IS NOT NULL GROUP BY il.itemId, iu.uomId ) src + LEFT JOIN item_uom baseIu + ON baseIu.itemId = src.itemId + AND baseIu.baseUnit = 1 + AND IFNULL(baseIu.deleted, 0) = 0 LEFT JOIN inventory existing ON existing.itemId = src.itemId - AND existing.uomId = src.uomId + AND COALESCE(existing.stockUomId, existing.uomId) = src.uomId AND IFNULL(existing.deleted, 0) = 0 LEFT JOIN inventory tpl ON tpl.id = ( @@ -3043,7 +3578,6 @@ open class StockLedgerFixService( il.itemId, iu.uomId, SUM(COALESCE(ill.inQty, 0) - COALESCE(ill.outQty, 0)) AS onHand, - SUM(COALESCE(ill.holdQty, 0)) AS onHold, SUM( CASE WHEN LOWER(ill.status) = 'unavailable' @@ -3060,13 +3594,12 @@ open class StockLedgerFixService( AND il.itemId IS NOT NULL AND iu.uomId IS NOT NULL GROUP BY il.itemId, iu.uomId - ) agg ON agg.itemId = i.itemId AND agg.uomId = i.uomId + ) agg ON agg.itemId = i.itemId AND agg.uomId = COALESCE(i.stockUomId, i.uomId) SET i.onHandQty = COALESCE(agg.onHand, 0), - i.onHoldQty = COALESCE(agg.onHold, 0), i.unavailableQty = COALESCE(agg.unavailable, 0), i.status = IF( - COALESCE(agg.onHand, 0) - COALESCE(agg.onHold, 0) - COALESCE(agg.unavailable, 0) > 0, + COALESCE(agg.onHand, 0) - COALESCE(agg.unavailable, 0) > 0, 'available', 'unavailable' ), diff --git a/src/main/java/com/ffii/fpsms/modules/master/web/models/StockLedgerFixModels.kt b/src/main/java/com/ffii/fpsms/modules/master/web/models/StockLedgerFixModels.kt index eb9f959..4086266 100644 --- a/src/main/java/com/ffii/fpsms/modules/master/web/models/StockLedgerFixModels.kt +++ b/src/main/java/com/ffii/fpsms/modules/master/web/models/StockLedgerFixModels.kt @@ -60,12 +60,15 @@ data class StockLedgerFixInventoryPreview( val inventoryRows: Int, val lotUomPairs: Int, val missingUomPairs: Int, + val nullStockUomId: Int, ) data class StockLedgerFixInventoryResponse( val inserted: Int, val updated: Int, val missingUomPairsAfter: Int, + val patchedStockUomId: Int, + val nullStockUomIdAfter: Int, ) data class StockLedgerFixSearchInventoryHit( @@ -99,6 +102,7 @@ data class StockLedgerFixAdjRow( val ledgerOut: String, val missIn: String, val missOut: String, + val overIssue: String, ) data class StockLedgerFixAdjPreview( @@ -110,6 +114,8 @@ data class StockLedgerFixAdjPreview( val sumMissIn: String, val sumMissOut: String, val skuNet: String, + val overIssueCount: Int, + val sumOverIssue: String, val rows: List, ) @@ -117,6 +123,7 @@ data class StockLedgerFixAdjResponse( val adjDate: String, val insertedIn: Int, val insertedOut: Int, + val overIssuePatched: Int, val filledLotQty: Int, val filledBalance: Int, val dayRowsWritten: Int, diff --git a/src/main/java/com/ffii/fpsms/modules/stock/service/StockLotDayCloseService.kt b/src/main/java/com/ffii/fpsms/modules/stock/service/StockLotDayCloseService.kt index d2031af..13ab82c 100644 --- a/src/main/java/com/ffii/fpsms/modules/stock/service/StockLotDayCloseService.kt +++ b/src/main/java/com/ffii/fpsms/modules/stock/service/StockLotDayCloseService.kt @@ -3,6 +3,8 @@ package com.ffii.fpsms.modules.stock.service import com.ffii.core.support.JdbcDao import org.slf4j.LoggerFactory import org.springframework.stereotype.Service +import org.springframework.transaction.PlatformTransactionManager +import org.springframework.transaction.support.TransactionTemplate import java.time.LocalDate import java.util.concurrent.ConcurrentHashMap @@ -12,10 +14,12 @@ import java.util.concurrent.ConcurrentHashMap * - carry: previous closing > 0 with no ledger today → opening = closing = prev.closing * * Never closes today or future dates. Idempotent via ON DUPLICATE KEY UPDATE. + * Unchanged rows are skipped so a second close of the same day does not rewrite. */ @Service open class StockLotDayCloseService( private val jdbcDao: JdbcDao, + private val transactionManager: PlatformTransactionManager, ) { private val log = LoggerFactory.getLogger(StockLotDayCloseService::class.java) private val inFlight = ConcurrentHashMap.newKeySet() @@ -129,13 +133,29 @@ open class StockLotDayCloseService( "actor" to actor, ) val t0 = System.nanoTime() - val moveRows = jdbcDao.executeUpdate(MOVES_SQL, args) - val carryRows = jdbcDao.executeUpdate(CARRY_SQL, args) + val result = TransactionTemplate(transactionManager).execute { + jdbcDao.executeUpdate("DROP TEMPORARY TABLE IF EXISTS tmp_sld_lots_today") + jdbcDao.executeUpdate( + """ + CREATE TEMPORARY TABLE tmp_sld_lots_today ( + inventoryLotLineId BIGINT NOT NULL PRIMARY KEY + ) + """.trimIndent(), + ) + try { + jdbcDao.executeUpdate(FILL_LOTS_TODAY_SQL, args) + val moveRows = jdbcDao.executeUpdate(MOVES_SQL, args) + val carryRows = jdbcDao.executeUpdate(CARRY_SQL, args) + CloseDayResult(date = day, moveRows = moveRows, carryRows = carryRows) + } finally { + jdbcDao.executeUpdate("DROP TEMPORARY TABLE IF EXISTS tmp_sld_lots_today") + } + } ?: CloseDayResult(date = day, moveRows = 0, carryRows = 0) log.info( "stock_lot_day close {} moves={} carry={} {}ms actor={}", - day, moveRows, carryRows, (System.nanoTime() - t0) / 1_000_000, actor, + day, result.moveRows, result.carryRows, (System.nanoTime() - t0) / 1_000_000, actor, ) - return CloseDayResult(date = day, moveRows = moveRows, carryRows = carryRows) + return result } private fun maxClosedDate(): LocalDate? { @@ -160,6 +180,31 @@ open class StockLotDayCloseService( const val ACTOR_FIX = "ledger-fix-api" private const val LOCK = "stock-lot-day-close" + private const val ON_DUP = """ + ON DUPLICATE KEY UPDATE + itemId = VALUES(itemId), + itemCode = VALUES(itemCode), + uomId = VALUES(uomId), + lotNo = VALUES(lotNo), + opening = VALUES(opening), + inQty = VALUES(inQty), + outQty = VALUES(outQty), + closing = VALUES(closing), + modified = NOW(), + modifiedBy = VALUES(modifiedBy), + version = stock_lot_day.version + 1, + deleted = 0 + """ + + private val FILL_LOTS_TODAY_SQL = """ + INSERT INTO tmp_sld_lots_today (inventoryLotLineId) + SELECT DISTINCT sl.inventoryLotLineId + FROM stock_ledger sl + WHERE sl.deleted = 0 + AND sl.date >= :day AND sl.date < :dayNext + AND sl.inventoryLotLineId IS NOT NULL + """ + private val MOVES_SQL = """ INSERT INTO stock_lot_day ( created, createdBy, version, modified, modifiedBy, deleted, @@ -199,20 +244,23 @@ open class StockLotDayCloseService( INNER JOIN stock_ledger lastSl ON lastSl.id = x.lastId LEFT JOIN inventory_lot_line ill ON ill.id = x.inventoryLotLineId LEFT JOIN inventory_lot il ON il.id = ill.inventoryLotId - ON DUPLICATE KEY UPDATE - itemId = VALUES(itemId), - itemCode = VALUES(itemCode), - uomId = VALUES(uomId), - lotNo = VALUES(lotNo), - opening = VALUES(opening), - inQty = VALUES(inQty), - outQty = VALUES(outQty), - closing = VALUES(closing), - modified = NOW(), - modifiedBy = VALUES(modifiedBy), - version = stock_lot_day.version + 1, - deleted = 0 - """.trimIndent() + LEFT JOIN stock_lot_day cur + ON cur.inventoryLotLineId = x.inventoryLotLineId + AND cur.date = :day + WHERE cur.inventoryLotLineId IS NULL + OR IFNULL(cur.deleted, 0) <> 0 + OR NOT ( + cur.opening <=> firstSl.lotQtyBefore + AND cur.inQty <=> x.inQty + AND cur.outQty <=> x.outQty + AND cur.closing <=> lastSl.lotQtyAfter + AND cur.itemId <=> x.itemId + AND cur.uomId <=> x.uomId + AND IFNULL(cur.itemCode, '') <=> IFNULL(x.itemCode, '') + AND IFNULL(cur.lotNo, '') <=> IFNULL(il.lotNo, '') + ) + $ON_DUP + """ private val CARRY_SQL = """ INSERT INTO stock_lot_day ( @@ -233,28 +281,30 @@ open class StockLotDayCloseService( 0, prev.closing FROM stock_lot_day prev + LEFT JOIN tmp_sld_lots_today t + ON t.inventoryLotLineId = prev.inventoryLotLineId + LEFT JOIN stock_lot_day cur + ON cur.inventoryLotLineId = prev.inventoryLotLineId + AND cur.date = :day WHERE prev.date = :prevDay AND IFNULL(prev.deleted, 0) = 0 AND prev.closing > 0 - AND NOT EXISTS ( - SELECT 1 FROM stock_ledger sl - WHERE sl.deleted = 0 - AND sl.date >= :day AND sl.date < :dayNext - AND sl.inventoryLotLineId = prev.inventoryLotLineId + AND t.inventoryLotLineId IS NULL + AND ( + cur.inventoryLotLineId IS NULL + OR IFNULL(cur.deleted, 0) <> 0 + OR NOT ( + cur.opening <=> prev.closing + AND cur.inQty <=> 0 + AND cur.outQty <=> 0 + AND cur.closing <=> prev.closing + AND cur.itemId <=> prev.itemId + AND cur.uomId <=> prev.uomId + AND IFNULL(cur.itemCode, '') <=> IFNULL(prev.itemCode, '') + AND IFNULL(cur.lotNo, '') <=> IFNULL(prev.lotNo, '') + ) ) - ON DUPLICATE KEY UPDATE - itemId = VALUES(itemId), - itemCode = VALUES(itemCode), - uomId = VALUES(uomId), - lotNo = VALUES(lotNo), - opening = VALUES(opening), - inQty = VALUES(inQty), - outQty = VALUES(outQty), - closing = VALUES(closing), - modified = NOW(), - modifiedBy = VALUES(modifiedBy), - version = stock_lot_day.version + 1, - deleted = 0 - """.trimIndent() + $ON_DUP + """ } } diff --git a/src/main/resources/db/changelog/changes/20260814_stock_lot_day/01_create_stock_lot_day.sql b/src/main/resources/db/changelog/changes/20260814_stock_lot_day/01_create_stock_lot_day.sql index 414eea0..2dcd762 100644 --- a/src/main/resources/db/changelog/changes/20260814_stock_lot_day/01_create_stock_lot_day.sql +++ b/src/main/resources/db/changelog/changes/20260814_stock_lot_day/01_create_stock_lot_day.sql @@ -2,7 +2,7 @@ --changeset fpsms:create_stock_lot_day --comment: Daily lot stock card (主錨). Month-end report aggregates this table. ---precondition onFail:MARK_RAN +--preconditions onFail:MARK_RAN --precondition-sql-check expectedResult:0 SELECT COUNT(*) FROM information_schema.TABLES WHERE TABLE_SCHEMA = DATABASE() AND TABLE_NAME = 'stock_lot_day' CREATE TABLE `stock_lot_day` (