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 1051ec2..a55c197 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 @@ -1,3669 +1,76 @@ package com.ffii.fpsms.modules.master.service -import com.ffii.core.support.JdbcDao +import com.ffii.fpsms.modules.master.service.ledgerfix.StockLedgerFixAdjService +import com.ffii.fpsms.modules.master.service.ledgerfix.StockLedgerFixBackfillService +import com.ffii.fpsms.modules.master.service.ledgerfix.StockLedgerFixExportService +import com.ffii.fpsms.modules.master.service.ledgerfix.StockLedgerFixQueryService import com.ffii.fpsms.modules.master.web.models.StockLedgerFixAdjPreview import com.ffii.fpsms.modules.master.web.models.StockLedgerFixAdjResponse -import com.ffii.fpsms.modules.master.web.models.StockLedgerFixAdjRow import com.ffii.fpsms.modules.master.web.models.StockLedgerFixCalendarResponse -import com.ffii.fpsms.modules.master.web.models.StockLedgerFixCheckPart import com.ffii.fpsms.modules.master.web.models.StockLedgerFixDayDetail -import com.ffii.fpsms.modules.master.web.models.StockLedgerFixDayStatus import com.ffii.fpsms.modules.master.web.models.StockLedgerFixInventoryPreview import com.ffii.fpsms.modules.master.web.models.StockLedgerFixInventoryResponse import com.ffii.fpsms.modules.master.web.models.StockLedgerFixRunResponse import com.ffii.fpsms.modules.master.web.models.StockLedgerFixScopeDetail import com.ffii.fpsms.modules.master.web.models.StockLedgerFixSearchInventoryHit 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 -import org.springframework.transaction.support.TransactionTemplate -import java.io.BufferedWriter import java.io.OutputStream -import java.io.OutputStreamWriter -import java.math.BigDecimal -import java.nio.charset.StandardCharsets -import java.sql.ResultSet import java.time.LocalDate -import java.time.format.DateTimeParseException -import java.util.concurrent.ConcurrentHashMap -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: (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. + * Facade for stock-ledger-fix. Work is split across query, backfill, ADJ, and SQL export. + * Inventory 1.0 then per-day stock_ledger backfill (2.1–2.6) and stock_lot_day. */ @Service open class StockLedgerFixService( - private val jdbcDao: JdbcDao, - private val transactionManager: PlatformTransactionManager, - private val dataSource: DataSource, - private val stockLotDayCloseService: StockLotDayCloseService, + private val query: StockLedgerFixQueryService, + private val backfill: StockLedgerFixBackfillService, + private val adj: StockLedgerFixAdjService, + private val export: StockLedgerFixExportService, ) { - 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" + open fun calendar(from: LocalDate, to: LocalDate): StockLedgerFixCalendarResponse = + query.calendar(from, to) - open fun calendar(from: LocalDate, to: LocalDate): StockLedgerFixCalendarResponse { - if (to.isBefore(from)) { - throw IllegalArgumentException("to must be on or after from") - } - if (from.plusDays(62).isBefore(to)) { - throw IllegalArgumentException("date range must be at most 62 days") - } - val args = mapOf("from" to from, "toNext" to to.plusDays(1)) - val rows = jdbcDao.queryForList( - """ - SELECT - DATE_FORMAT(sl.date, '%Y-%m-%d') AS d, - COUNT(*) AS cnt, - SUM(sl.inventoryLotLineId IS NULL) AS missLot, - SUM(sl.uomId IS NULL) AS missUom, - SUM(sl.inventoryId IS NULL) AS missInventoryId, - SUM(sl.lotQtyAfter IS NULL) AS missLotQty - FROM stock_ledger sl - WHERE sl.deleted = 0 - AND sl.date >= :from - AND sl.date < :toNext - GROUP BY DATE_FORMAT(sl.date, '%Y-%m-%d') - ORDER BY d - """.trimIndent(), - args, - ) - val dayLots = jdbcDao.queryForList( - """ - SELECT DATE_FORMAT(date, '%Y-%m-%d') AS d, COUNT(*) AS lots - FROM stock_lot_day - WHERE deleted = 0 - AND date >= :from - AND date < :toNext - GROUP BY DATE_FORMAT(date, '%Y-%m-%d') - """.trimIndent(), - args, - ).associate { row -> - row["d"].toString() to toInt(row["lots"]) - } - val days = rows.map { row -> - val d = row["d"].toString() - StockLedgerFixDayStatus( - date = d, - cnt = toInt(row["cnt"]), - missLot = toInt(row["missLot"]), - missUom = toInt(row["missUom"]), - missInventoryId = toInt(row["missInventoryId"]), - missLotQty = toInt(row["missLotQty"]), - dayTableLots = dayLots[d] ?: 0, - ) - } - return StockLedgerFixCalendarResponse( - from = from.toString(), - to = to.toString(), - days = days, - ) - } + open fun inventoryPreview(): StockLedgerFixInventoryPreview = query.inventoryPreview() - /** - * A: keep existing inventory ids; - * backfill stockUomId from lot stock UOM (and set uomId to base) — one row per - * (itemId, stockUomId) so uk_inventory_item_stock_uom is not violated; - * INSERT missing (itemId, stockUomId); - * soft-delete leftover NULL stockUomId rows on items that already have a bucket; - * 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( - jdbcDao.queryForList( - "SELECT COUNT(*) AS c FROM inventory WHERE IFNULL(deleted, 0) = 0", - ).firstOrNull()?.get("c"), - ) - val lotUomPairs = toInt( - jdbcDao.queryForList(LOT_UOM_PAIR_COUNT_SQL).firstOrNull()?.get("c"), - ) - 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, - ) - } + open fun fixInventory10(): StockLedgerFixInventoryResponse = backfill.fixInventory10() - @Transactional(timeout = 300) - open fun fixInventory10(): StockLedgerFixInventoryResponse { - if (!inFlight.add(inventoryLock)) { - 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 orphansDeleted = jdbcDao.executeUpdate(SOFT_DELETE_ORPHAN_NULL_STOCK_UOM_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 patchedStockUomId={} inserted={} orphansDeleted={} updated={} missingAfter={} nullStockUomIdAfter={}", - patchedStockUomId, - inserted, - orphansDeleted, - updated, - missingAfter, - nullStockUomIdAfter, - ) - return StockLedgerFixInventoryResponse( - inserted = inserted, - updated = updated, - missingUomPairsAfter = missingAfter, - patchedStockUomId = patchedStockUomId, - nullStockUomIdAfter = nullStockUomIdAfter, - orphansDeleted = orphansDeleted, - ) - } finally { - inFlight.remove(inventoryLock) - } - } + open fun dayDetail(day: LocalDate): StockLedgerFixDayDetail = query.dayDetail(day) - open fun dayDetail(day: LocalDate): StockLedgerFixDayDetail { - val args = dayArgs(day) - val ledger = jdbcDao.queryForList( - """ - SELECT - COUNT(*) AS cnt, - SUM(sl.inventoryLotLineId IS NULL) AS lotMiss, - SUM( - sl.inventoryLotLineId IS NOT NULL - AND COALESCE(sol.inventoryLotLineId, sil.inventoryLotLineId) IS NOT NULL - AND sl.inventoryLotLineId <> COALESCE(sol.inventoryLotLineId, sil.inventoryLotLineId) - ) AS lotIncorrect, - SUM(sl.uomId IS NULL) AS uomMiss, - SUM( - sl.inventoryLotLineId IS NOT NULL - AND sl.uomId IS NOT NULL - AND iu.uomId IS NOT NULL - AND sl.uomId <> iu.uomId - ) AS uomIncorrect, - SUM(sl.inventoryId IS NULL) AS invMiss, - SUM( - sl.inventoryId IS NOT NULL - AND sl.itemId IS NOT NULL - AND sl.uomId IS NOT NULL - AND NOT EXISTS ( - SELECT 1 FROM inventory inv - WHERE inv.id = sl.inventoryId - AND IFNULL(inv.deleted, 0) = 0 - AND inv.itemId = sl.itemId - AND COALESCE(inv.stockUomId, inv.uomId) = sl.uomId - ) - ) AS invIncorrect, - SUM(sl.lotQtyAfter IS NULL OR sl.lotQtyBefore IS NULL) AS lotQtyMiss, - SUM( - sl.inventoryLotLineId IS NOT NULL - AND (sl.lotQtyAfter IS NULL OR sl.lotQtyBefore IS NULL) - ) AS lotQtyMissWithLot, - SUM( - sl.lotQtyAfter IS NOT NULL - AND sl.lotQtyBefore IS NOT NULL - AND ( - sl.lotQtyAfter - sl.lotQtyBefore - <> CAST(COALESCE(sl.inQty, 0) AS DECIMAL(14,2)) - - CAST(COALESCE(sl.outQty, 0) AS DECIMAL(14,2)) - ) - ) AS lotQtyIncorrect, - SUM( - sl.lotQtyAfter IS NOT NULL - AND sl.lotQtyBefore IS NOT NULL - AND sl.lotQtyAfter - sl.lotQtyBefore - = CAST(COALESCE(sl.inQty, 0) AS DECIMAL(14,2)) - - CAST(COALESCE(sl.outQty, 0) AS DECIMAL(14,2)) - AND (sl.lotQtyAfter < 0 OR sl.lotQtyBefore < 0) - ) AS overIssue, - SUM(sl.balance IS NULL) AS balanceMiss, - SUM( - sl.inventoryLotLineId IS NULL - AND COALESCE(sol.inventoryLotLineId, sil.inventoryLotLineId) IS NOT NULL - ) AS fixable21a, - SUM( - sl.inventoryLotLineId IS NULL - AND COALESCE(sol.inventoryLotLineId, sil.inventoryLotLineId) IS NULL - AND sil.inventoryLotId IS NOT NULL - AND IFNULL(lc.lineCnt, 0) = 1 - ) AS fixable21b, - SUM( - sl.inventoryLotLineId IS NULL - AND COALESCE(sol.inventoryLotLineId, sil.inventoryLotLineId) IS NULL - AND sil.inventoryLotId IS NOT NULL - AND IFNULL(lc.lineCnt, 0) > 1 - ) AS cannotMultiLine, - SUM( - sl.inventoryLotLineId IS NULL - AND COALESCE(sol.inventoryLotLineId, sil.inventoryLotLineId) IS NULL - AND (sil.inventoryLotId IS NULL OR IFNULL(lc.lineCnt, 0) = 0) - ) AS cannotNoSource - FROM stock_ledger sl - LEFT JOIN stock_out_line sol ON sol.id = sl.stockOutLineId - LEFT JOIN stock_in_line sil ON sil.id = sl.stockInLineId - LEFT JOIN inventory_lot_line ill ON ill.id = sl.inventoryLotLineId - LEFT JOIN item_uom iu ON iu.id = ill.stockItemUomId - LEFT JOIN ( - SELECT inventoryLotId, COUNT(*) AS lineCnt - FROM inventory_lot_line - WHERE IFNULL(deleted, 0) = 0 - GROUP BY inventoryLotId - ) lc ON lc.inventoryLotId = sil.inventoryLotId - WHERE sl.deleted = 0 - AND sl.date >= :day AND sl.date < :dayNext - """.trimIndent(), - args, - ).firstOrNull() ?: emptyMap() + open fun searchInventory(qRaw: String): List = + query.searchInventory(qRaw) - val cnt = toInt(ledger["cnt"]) - val lotMiss = toInt(ledger["lotMiss"]) - val lotIncorrect = toInt(ledger["lotIncorrect"]) - val uomMiss = toInt(ledger["uomMiss"]) - val uomIncorrect = toInt(ledger["uomIncorrect"]) - val invMiss = toInt(ledger["invMiss"]) - val invIncorrect = toInt(ledger["invIncorrect"]) - val lotQtyMissWithLot = toInt(ledger["lotQtyMissWithLot"]) - val lotQtyIncorrect = toInt(ledger["lotQtyIncorrect"]) - val overIssue = toInt(ledger["overIssue"]) - val balanceMiss = toInt(ledger["balanceMiss"]) + open fun searchLot(qRaw: String): List = query.searchLot(qRaw) - val dayTable = jdbcDao.queryForList( - """ - SELECT - COUNT(*) AS expected, - SUM(d.id IS NULL) AS miss, - SUM( - d.id IS NOT NULL AND ( - (x.kind = 'move' AND ( - d.closing <> lastSl.lotQtyAfter - OR d.opening <> firstSl.lotQtyBefore - OR d.inQty <> x.inQty - OR d.outQty <> x.outQty - )) - OR (x.kind = 'carry' AND ( - d.inQty <> 0 - OR d.outQty <> 0 - OR d.opening <> x.prevClosing - OR d.closing <> x.prevClosing - )) - ) - ) AS incorrect - FROM ( - SELECT - sl.inventoryLotLineId, - 'move' AS kind, - MIN(sl.id) AS firstId, - MAX(sl.id) AS lastId, - CAST(SUM(COALESCE(sl.inQty, 0)) AS DECIMAL(14,2)) AS inQty, - CAST(SUM(COALESCE(sl.outQty, 0)) AS DECIMAL(14,2)) AS outQty, - CAST(NULL AS DECIMAL(14,2)) AS prevClosing - FROM stock_ledger sl - WHERE sl.deleted = 0 - AND sl.date >= :day AND sl.date < :dayNext - AND sl.inventoryLotLineId IS NOT NULL - GROUP BY sl.inventoryLotLineId + open fun inventoryScopeDetail(inventoryId: Long): StockLedgerFixScopeDetail = + query.inventoryScopeDetail(inventoryId) - UNION ALL + open fun lotScopeDetail(lotLineId: Long): StockLedgerFixScopeDetail = + query.lotScopeDetail(lotLineId) - SELECT - prev.inventoryLotLineId, - 'carry' AS kind, - NULL AS firstId, - NULL AS lastId, - CAST(0 AS DECIMAL(14,2)) AS inQty, - CAST(0 AS DECIMAL(14,2)) AS outQty, - prev.closing AS prevClosing - FROM stock_lot_day prev - 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 - ) - ) x - LEFT JOIN stock_ledger firstSl ON firstSl.id = x.firstId - LEFT JOIN stock_ledger lastSl ON lastSl.id = x.lastId - LEFT JOIN stock_lot_day d - ON d.inventoryLotLineId = x.inventoryLotLineId - AND d.date = :day - AND IFNULL(d.deleted, 0) = 0 - """.trimIndent(), - args, - ).firstOrNull() ?: emptyMap() + open fun fixInventoryScope(inventoryId: Long): StockLedgerFixRunResponse = + backfill.fixInventoryScope(inventoryId) - val dayLots = toInt(dayTable["expected"]) - val dayMiss = toInt(dayTable["miss"]) - val dayIncorrect = toInt(dayTable["incorrect"]) - // No lot ids yet → SQL finds 0 lots, 0/0/0 would look "correct". Ledger exists but day table cannot be built. - val dayTablePending = cnt > 0 && dayLots == 0 - val dayTableMiss = if (dayTablePending) 1 else dayMiss - val dayTableTotal = if (dayTablePending) 1 else dayLots + open fun fixLotScope(lotLineId: Long): StockLedgerFixRunResponse = + backfill.fixLotScope(lotLineId) - fun part(key: String, label: String, miss: Int, incorrect: Int, total: Int) = - StockLedgerFixCheckPart( - key = key, - label = label, - ok = (total - miss - incorrect).coerceAtLeast(0), - miss = miss, - incorrect = incorrect, - ) + open fun adjPreview(rowLimit: Int = 20, adjDateRaw: String? = null): StockLedgerFixAdjPreview = + adj.adjPreview(rowLimit, adjDateRaw) - return StockLedgerFixDayDetail( - date = day.toString(), - cnt = cnt, - parts = fieldAndReasonParts( - cnt = cnt, - lotMiss = lotMiss, - lotIncorrect = lotIncorrect, - uomMiss = uomMiss, - uomIncorrect = uomIncorrect, - invMiss = invMiss, - invIncorrect = invIncorrect, - lotQtyMissWithLot = lotQtyMissWithLot, - lotQtyIncorrect = lotQtyIncorrect, - overIssue = overIssue, - balanceMiss = balanceMiss, - fixable21a = toInt(ledger["fixable21a"]), - fixable21b = toInt(ledger["fixable21b"]), - cannotMultiLine = toInt(ledger["cannotMultiLine"]), - cannotNoSource = toInt(ledger["cannotNoSource"]), - earlyTkeOver = 0, - otherOver = 0, - extra = listOf( - part("dayTable", "stock_lot_day", dayTableMiss, dayIncorrect, dayTableTotal), - ), - ), - ) - } + open fun applyAdjAlign(adjDateRaw: String? = null): StockLedgerFixAdjResponse = + adj.applyAdjAlign(adjDateRaw) - open fun searchInventory(qRaw: String): List { - val q = qRaw.trim() - if (q.isEmpty()) throw IllegalArgumentException("q is required") - val args = mapOf("q" to q, "like" to "%$q%") - return jdbcDao.queryForList( - """ - SELECT - i.id AS inventoryId, - i.itemId AS itemId, - it.code AS itemCode, - i.uomId AS uomId, - ( - SELECT COUNT(*) FROM stock_ledger sl - WHERE sl.deleted = 0 AND sl.inventoryId = i.id - ) AS ledgerCnt - FROM inventory i - LEFT JOIN items it ON it.id = i.itemId - WHERE IFNULL(i.deleted, 0) = 0 - AND ( - CAST(i.id AS CHAR) = :q - OR CAST(i.itemId AS CHAR) = :q - OR it.code LIKE :like - ) - ORDER BY ledgerCnt DESC, i.id - LIMIT 20 - """.trimIndent(), - args, - ).map { row -> - StockLedgerFixSearchInventoryHit( - inventoryId = toLong(row["inventoryId"]), - itemId = toLongOrNull(row["itemId"]), - itemCode = row["itemCode"]?.toString(), - uomId = toLongOrNull(row["uomId"]), - ledgerCnt = toInt(row["ledgerCnt"]), - ) - } - } + open fun fixDay(dateRaw: String, stepsRaw: List? = null): StockLedgerFixRunResponse = + backfill.fixDay(dateRaw, stepsRaw) - open fun searchLot(qRaw: String): List { - val q = qRaw.trim() - if (q.isEmpty()) throw IllegalArgumentException("q is required") - val args = mapOf("q" to q, "like" to "%$q%") - return jdbcDao.queryForList( - """ - SELECT - ill.id AS inventoryLotLineId, - il.lotNo AS lotNo, - it.code AS itemCode, - ( - SELECT sl.inventoryId FROM stock_ledger sl - WHERE sl.deleted = 0 AND sl.inventoryLotLineId = ill.id - AND sl.inventoryId IS NOT NULL - ORDER BY sl.id DESC LIMIT 1 - ) AS inventoryId, - ( - SELECT COUNT(*) FROM stock_ledger sl - WHERE sl.deleted = 0 AND sl.inventoryLotLineId = ill.id - ) AS ledgerCnt - FROM inventory_lot_line ill - INNER JOIN inventory_lot il ON il.id = ill.inventoryLotId - LEFT JOIN items it ON it.id = il.itemId - WHERE IFNULL(ill.deleted, 0) = 0 - AND ( - CAST(ill.id AS CHAR) = :q - OR il.lotNo LIKE :like - ) - ORDER BY ledgerCnt DESC, ill.id - LIMIT 20 - """.trimIndent(), - args, - ).map { row -> - StockLedgerFixSearchLotHit( - inventoryLotLineId = toLong(row["inventoryLotLineId"]), - lotNo = row["lotNo"]?.toString(), - itemCode = row["itemCode"]?.toString(), - inventoryId = toLongOrNull(row["inventoryId"]), - ledgerCnt = toInt(row["ledgerCnt"]), - ) - } - } + open fun fixRange(fromRaw: String, toRaw: String, stepsRaw: List? = null): StockLedgerFixRunResponse = + backfill.fixRange(fromRaw, toRaw, stepsRaw) - open fun inventoryScopeDetail(inventoryId: Long): StockLedgerFixScopeDetail { - val args = mapOf("inventoryId" to inventoryId, "today" to inventoryScopeToExclusive()) - val head = jdbcDao.queryForList( - """ - SELECT - i.id AS inventoryId, - i.itemId AS itemId, - it.code AS itemCode, - i.uomId AS uomId - FROM inventory i - LEFT JOIN items it ON it.id = i.itemId - WHERE i.id = :inventoryId - """.trimIndent(), - args, - ).firstOrNull() ?: throw IllegalArgumentException("inventory not found: $inventoryId") - val stats = ledgerScopeStats( - "sl.inventoryId = :inventoryId", - args, - itemId = toLongOrNull(head["itemId"]), - ) - return StockLedgerFixScopeDetail( - kind = "inventory", - id = inventoryId, - itemCode = head["itemCode"]?.toString(), - lotNo = null, - inventoryId = inventoryId, - uomId = toLongOrNull(head["uomId"]), - firstDate = stats.firstDate, - lastDate = stats.lastDate, - cnt = stats.cnt, - lastBalance = stats.lastBalance, - lastLotQtyAfter = stats.lastLotQtyAfter, - parts = stats.parts, - ) - } - - open fun lotScopeDetail(lotLineId: Long): StockLedgerFixScopeDetail { - val today = LocalDate.now() - val args = mapOf("lotId" to lotLineId, "today" to today) - val head = jdbcDao.queryForList( - """ - SELECT - ill.id AS inventoryLotLineId, - il.lotNo AS lotNo, - il.itemId AS itemId, - it.code AS itemCode - FROM inventory_lot_line ill - INNER JOIN inventory_lot il ON il.id = ill.inventoryLotId - LEFT JOIN items it ON it.id = il.itemId - WHERE ill.id = :lotId - """.trimIndent(), - args, - ).firstOrNull() ?: throw IllegalArgumentException("lot line not found: $lotLineId") - val stats = ledgerScopeStats( - "sl.inventoryLotLineId = :lotId", - args, - itemId = toLongOrNull(head["itemId"]), - ) - return StockLedgerFixScopeDetail( - kind = "lot", - id = lotLineId, - itemCode = head["itemCode"]?.toString(), - lotNo = head["lotNo"]?.toString(), - inventoryId = toLongOrNull(stats.lastInventoryId), - uomId = null, - firstDate = stats.firstDate, - lastDate = stats.lastDate, - cnt = stats.cnt, - lastBalance = stats.lastBalance, - lastLotQtyAfter = stats.lastLotQtyAfter, - parts = stats.parts, - ) - } - - /** - * 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 toExclusive = inventoryScopeToExclusive() - val t0 = System.nanoTime() - try { - val inv = jdbcDao.queryForList( - """ - SELECT id, itemId, uomId FROM inventory WHERE id = :inventoryId - """.trimIndent(), - mapOf("inventoryId" to inventoryId), - ).firstOrNull() ?: throw IllegalArgumentException("inventory not found: $inventoryId") - val args = mapOf( - "inventoryId" to inventoryId, - "itemId" to (inv["itemId"] ?: 0), - "uomId" to (inv["uomId"] ?: 0), - "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, 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 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() ?: LocalDate.now().toString(), - filledLotLineId = filledLotLineId, - filledUomId = filledUomId, - filledInventoryId = filledInventoryId, - filledLotQty = filledLotQty, - filledBalance = filledBalance, - dayRowsWritten = dayRows, - stillMissLot = still.missLot, - stillMissUom = still.missUom, - stillMissInventoryId = still.missInventoryId, - stillMissLotQty = still.missLotQty, - ) - } catch (e: Exception) { - log.warn("stock-ledger-fix inventory {} FAILED after {}ms: {}", inventoryId, (System.nanoTime() - t0) / 1_000_000, e.message) - throw e - } finally { - inFlight.remove(runLock) - } - } - - open fun fixLotScope(lotLineId: Long): StockLedgerFixRunResponse { - if (lotLineId <= 0) throw IllegalArgumentException("lotLineId is required") - if (!inFlight.add(runLock)) throw IllegalStateException("fix already running") - val today = LocalDate.now() - val t0 = System.nanoTime() - try { - val exists = toInt( - jdbcDao.queryForList( - "SELECT COUNT(*) AS c FROM inventory_lot_line WHERE id = :lotId", - mapOf("lotId" to lotLineId), - ).firstOrNull()?.get("c"), - ) - if (exists == 0) throw IllegalArgumentException("lot line not found: $lotLineId") - val args = mapOf("lotId" to lotLineId, "today" to today) - val filledLotLineId = timed("lot 2.1") { fillLotLineIdScope(args, byInventory = false) } - val filledUomId = timed("lot 2.2") { fillUomIdScope(args, byInventory = false) } - val filledInventoryId = timed("lot 2.3") { fillInventoryIdScope(args, byInventory = false) } - val filledLotQty = timed("lot 2.4") { fillLotQtyHistory(args, byInventory = false) } - val range = dateRangeForFilter("inventoryLotLineId = :lotId", args) - val dayRows = if (range != null) { - timed("lot 2.6") { upsertStockLotDayRange(listOf(lotLineId), range.first, range.second) } - } else 0 - val still = stillMissScope("inventoryLotLineId = :lotId", args) - log.info( - "stock-ledger-fix lot {} lotLine={} uom={} inv={} lotQty={} dayRows={} total={}ms", - lotLineId, filledLotLineId, filledUomId, filledInventoryId, filledLotQty, dayRows, - (System.nanoTime() - t0) / 1_000_000, - ) - return StockLedgerFixRunResponse( - date = range?.first?.toString() ?: today.toString(), - filledLotLineId = filledLotLineId, - filledUomId = filledUomId, - filledInventoryId = filledInventoryId, - filledLotQty = filledLotQty, - filledBalance = 0, - dayRowsWritten = dayRows, - stillMissLot = still.missLot, - stillMissUom = still.missUom, - stillMissInventoryId = still.missInventoryId, - stillMissLotQty = still.missLotQty, - ) - } catch (e: Exception) { - log.warn("stock-ledger-fix lot {} FAILED after {}ms: {}", lotLineId, (System.nanoTime() - t0) / 1_000_000, e.message) - throw e - } finally { - inFlight.remove(runLock) - } - } - - /** - * 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. - * 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 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={} 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 = 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(), - overIssueCount = over.size, - sumOverIssue = sumOver.stripTrailingZeros().toPlainString(), - rows = rows.map { it.toRow() }, - ) - } - - open fun applyAdjAlign(adjDateRaw: String? = null): StockLedgerFixAdjResponse { - if (!inFlight.add(runLock)) { - throw IllegalStateException("fix already running") - } - 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() } - var insertedIn = 0 - var insertedOut = 0 - var overIssuePatched = 0 - if (work.isNotEmpty()) { - val insTx = TransactionTemplate(transactionManager) - insTx.timeout = 300 - insTx.execute { - 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, 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++ - } - } - } - } - val lotIds = linkedSetOf() - fun addLot(lotId: Long, invId: Long?) { - if (lotId > 0) lotIds.add(lotId) - } - work.forEach { addLot(it.lotLineId, it.inventoryId) } - jdbcDao.queryForList( - """ - SELECT DISTINCT inventoryLotLineId AS lotId, inventoryId AS invId - FROM stock_ledger - WHERE deleted = 0 - AND date >= :adjDate AND date < :asOf - AND type = 'ADJ' - AND createdBy = 'stock-ledger-fix' - """.trimIndent(), - mapOf("adjDate" to adjDate, "asOf" to asOfExclusive), - ).forEach { addLot(toLong(it["lotId"]), toLongOrNull(it["invId"])) } - val filledLotQty = timed("adj 2.4") { fillLotQtyForLotIds(lotIds.toList(), asOfExclusive) } - val filledBalance = timed("adj 2.5") { fillBalanceOnNewAdjRows(adjDate, asOfExclusive) } - val range = if (lotIds.isEmpty()) null else dateRangeForFilter( - "inventoryLotLineId IN (:lotIds)", - mapOf("today" to asOfExclusive, "lotIds" to lotIds.toList()), - ) - val dayRows = if (range != null && lotIds.isNotEmpty()) { - timed("adj 2.6") { 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, - (System.nanoTime() - t0) / 1_000_000, - ) - return StockLedgerFixAdjResponse( - adjDate = adjDate.toString(), - insertedIn = insertedIn, - insertedOut = insertedOut, - overIssuePatched = overIssuePatched, - filledLotQty = filledLotQty, - filledBalance = filledBalance, - dayRowsWritten = dayRows, - ) - } catch (e: Exception) { - log.warn("stock-ledger-fix adj FAILED after {}ms: {}", (System.nanoTime() - t0) / 1_000_000, e.message) - throw e - } finally { - inFlight.remove(runLock) - } - } - - /** - * No wrapping transaction: each step auto-commits so a later timeout - * (typically 2.5) does not roll back 2.1–2.4 lot fills. - * [stepsRaw] empty / all = 2.1–2.6; otherwise only those keys (e.g. 2.3). - */ - open fun fixDay(dateRaw: String, stepsRaw: List? = null): StockLedgerFixRunResponse { - val day = parseDay(dateRaw) - // Allow today (freeze-night dump fix); reject future only. - if (day.isAfter(LocalDate.now())) { - throw IllegalArgumentException("cannot fix future dates") - } - val key = day.toString() - val steps = parseDaySteps(stepsRaw) - if (!inFlight.add(runLock)) { - throw IllegalStateException("fix already running") - } - val t0 = System.nanoTime() - try { - val args = dayArgs(day) - val filledLotLineId = if ("2.1" in steps) timed("2.1 fillLotLineId") { fillLotLineId(args) } else 0 - val filledUomId = if ("2.2" in steps) timed("2.2 fillUomId") { fillUomId(args) } else 0 - val filledInventoryId = if ("2.3" in steps) timed("2.3 fillInventoryId") { fillInventoryId(args) } else 0 - val filledLotQty = if ("2.4" in steps) timed("2.4 fillLotQty") { fillLotQty(args) } else 0 - val filledBalance = if ("2.5" in steps) timed("2.5 fillBalance") { fillBalance(args) } else 0 - val dayRowsWritten = if ("2.6" in steps) { - timed("2.6 upsertStockLotDay") { - stockLotDayCloseService.upsertDayForFix(day).rowsWritten - } - } else { - 0 - } - val still = stillMiss(args) - log.info( - "stock-ledger-fix {} steps={} lotLine={} uom={} inv={} lotQty={} bal={} dayRows={} total={}ms", - key, steps.joinToString(","), filledLotLineId, filledUomId, filledInventoryId, - filledLotQty, filledBalance, dayRowsWritten, - (System.nanoTime() - t0) / 1_000_000, - ) - return StockLedgerFixRunResponse( - date = key, - filledLotLineId = filledLotLineId, - filledUomId = filledUomId, - filledInventoryId = filledInventoryId, - filledLotQty = filledLotQty, - filledBalance = filledBalance, - dayRowsWritten = dayRowsWritten, - stillMissLot = still.missLot, - stillMissUom = still.missUom, - stillMissInventoryId = still.missInventoryId, - stillMissLotQty = still.missLotQty, - ) - } catch (e: Exception) { - log.warn( - "stock-ledger-fix {} FAILED after {}ms: {}", - key, (System.nanoTime() - t0) / 1_000_000, e.message, - ) - throw e - } finally { - inFlight.remove(runLock) - } - } - - /** - * 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) - val to = parseDay(toRaw) - if (to.isBefore(from)) { - throw IllegalArgumentException("to must be on or after from") - } - if (from.plusDays(400).isBefore(to)) { - throw IllegalArgumentException("date range must be at most 400 days") - } - if (to.isAfter(LocalDate.now())) { - throw IllegalArgumentException("cannot fix future dates") - } - val key = "$from..$to" - val steps = parseDaySteps(stepsRaw) - if (!inFlight.add(runLock)) { - throw IllegalStateException("fix already running") - } - val t0 = System.nanoTime() - try { - 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 - } - } - d = d.plusDays(1) - } - 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, - filledLotQty, filledBalance, dayRowsWritten, - (System.nanoTime() - t0) / 1_000_000, - ) - return StockLedgerFixRunResponse( - date = key, - filledLotLineId = filledLotLineId, - filledUomId = filledUomId, - filledInventoryId = filledInventoryId, - filledLotQty = filledLotQty, - filledBalance = filledBalance, - dayRowsWritten = dayRowsWritten, - stillMissLot = still.missLot, - stillMissUom = still.missUom, - stillMissInventoryId = still.missInventoryId, - stillMissLotQty = still.missLotQty, - ) - } catch (e: Exception) { - log.warn( - "stock-ledger-fix {} FAILED after {}ms: {}", - key, (System.nanoTime() - t0) / 1_000_000, e.message, - ) - throw e - } finally { - inFlight.remove(runLock) - } - } - - /** 2.1 SOL / SIL lot line; one-line fallback when SIL has lot header only. */ - private fun fillLotLineId(args: Map): Int { - val fromSolSil = timed("2.1a solSil") { - jdbcDao.executeUpdate( - """ - UPDATE stock_ledger sl - LEFT JOIN stock_out_line sol - ON sol.id = sl.stockOutLineId AND IFNULL(sol.deleted, 0) = 0 - LEFT JOIN stock_in_line sil - ON sil.id = sl.stockInLineId AND IFNULL(sil.deleted, 0) = 0 - SET sl.inventoryLotLineId = COALESCE(sol.inventoryLotLineId, sil.inventoryLotLineId) - WHERE sl.deleted = 0 - AND sl.date >= :day AND sl.date < :dayNext - AND sl.inventoryLotLineId IS NULL - AND COALESCE(sol.inventoryLotLineId, sil.inventoryLotLineId) IS NOT NULL - """.trimIndent(), - args, - ) - } - val fromOneLine = timed("2.1b oneLine") { - jdbcDao.executeUpdate( - """ - UPDATE stock_ledger sl - INNER JOIN stock_in_line sil - ON sil.id = sl.stockInLineId AND IFNULL(sil.deleted, 0) = 0 - INNER JOIN ( - SELECT ill.inventoryLotId, MIN(ill.id) AS onlyLineId - FROM inventory_lot_line ill - INNER JOIN ( - SELECT DISTINCT sil2.inventoryLotId - FROM stock_ledger slx - INNER JOIN stock_in_line sil2 - ON sil2.id = slx.stockInLineId AND IFNULL(sil2.deleted, 0) = 0 - WHERE slx.deleted = 0 - AND slx.date >= :day AND slx.date < :dayNext - AND slx.inventoryLotLineId IS NULL - AND sil2.inventoryLotLineId IS NULL - AND sil2.inventoryLotId IS NOT NULL - ) hdr ON hdr.inventoryLotId = ill.inventoryLotId - WHERE IFNULL(ill.deleted, 0) = 0 - GROUP BY ill.inventoryLotId - HAVING COUNT(*) = 1 - ) one ON one.inventoryLotId = sil.inventoryLotId - SET sl.inventoryLotLineId = one.onlyLineId - WHERE sl.deleted = 0 - AND sl.date >= :day AND sl.date < :dayNext - AND sl.inventoryLotLineId IS NULL - AND sil.inventoryLotLineId IS NULL - AND sil.inventoryLotId IS NOT NULL - """.trimIndent(), - args, - ) - } - return fromSolSil + fromOneLine - } - - /** 2.2 lot line stock UOM → uom_conversion id. Include deleted item_uom (historical lots still point there). */ - private fun fillUomId(args: Map): Int { - return jdbcDao.executeUpdate( - """ - UPDATE stock_ledger sl - INNER JOIN inventory_lot_line ill - ON ill.id = sl.inventoryLotLineId AND IFNULL(ill.deleted, 0) = 0 - INNER JOIN item_uom iu - ON iu.id = ill.stockItemUomId - SET sl.uomId = iu.uomId - WHERE sl.deleted = 0 - AND sl.date >= :day AND sl.date < :dayNext - AND sl.inventoryLotLineId IS NOT NULL - """.trimIndent(), - args, - ) - } - - /** - * 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 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 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.itemId IS NOT NULL - AND sl.uomId IS NOT NULL - AND sl.inventoryId IS NULL - AND ($extraWhere) - """.trimIndent(), - args, - ) - return byStockUom + byLegacyUom - } - - /** - * 2.4 Opening = last lotQtyAfter before the window start (yesterday first, then lookback). - * Idle days must not reset to 0. Window is :day .. :dayNext (one day or a range). - */ - private fun fillLotQty(args: Map): Int { - return withTempTx { - jdbcDao.executeUpdate("DROP TEMPORARY TABLE IF EXISTS tmp_sl_fix_lot_today") - jdbcDao.executeUpdate("DROP TEMPORARY TABLE IF EXISTS tmp_sl_fix_lot_prev") - jdbcDao.executeUpdate("DROP TEMPORARY TABLE IF EXISTS tmp_sl_fix_lot_miss") - jdbcDao.executeUpdate("DROP TEMPORARY TABLE IF EXISTS tmp_sl_fix_lot_qty") - jdbcDao.executeUpdate( - """ - CREATE TEMPORARY TABLE tmp_sl_fix_lot_today ( - inventoryLotLineId BIGINT NOT NULL PRIMARY KEY - ) - """.trimIndent(), - ) - jdbcDao.executeUpdate( - """ - CREATE TEMPORARY TABLE tmp_sl_fix_lot_miss ( - inventoryLotLineId BIGINT NOT NULL PRIMARY KEY - ) - """.trimIndent(), - ) - jdbcDao.executeUpdate( - """ - CREATE TEMPORARY TABLE tmp_sl_fix_lot_prev ( - inventoryLotLineId BIGINT NOT NULL PRIMARY KEY, - prevAfter DECIMAL(14,2) NOT NULL - ) - """.trimIndent(), - ) - jdbcDao.executeUpdate( - """ - CREATE TEMPORARY TABLE tmp_sl_fix_lot_qty ( - id INT NOT NULL PRIMARY KEY, - lotAfter DECIMAL(14,2) NOT NULL, - delta DECIMAL(14,2) NOT NULL - ) - """.trimIndent(), - ) - try { - jdbcDao.executeUpdate( - """ - INSERT INTO tmp_sl_fix_lot_today (inventoryLotLineId) - SELECT DISTINCT inventoryLotLineId - FROM stock_ledger - WHERE deleted = 0 - AND date >= :day AND date < :dayNext - AND inventoryLotLineId IS NOT NULL - """.trimIndent(), - args, - ) - timed("2.4a1 yesterday") { - jdbcDao.executeUpdate( - """ - INSERT INTO tmp_sl_fix_lot_prev (inventoryLotLineId, prevAfter) - SELECT slp.inventoryLotLineId, slp.lotQtyAfter - FROM stock_ledger slp - INNER JOIN ( - SELECT sl.inventoryLotLineId, MAX(sl.id) AS maxId - FROM stock_ledger sl - INNER JOIN tmp_sl_fix_lot_today t - ON t.inventoryLotLineId = sl.inventoryLotLineId - WHERE sl.deleted = 0 - AND sl.date >= :prevDay AND sl.date < :day - AND sl.lotQtyAfter IS NOT NULL - GROUP BY sl.inventoryLotLineId - ) m ON m.maxId = slp.id - """.trimIndent(), - args, - ) - } - timed("2.4a2 lookback") { - jdbcDao.executeUpdate( - """ - INSERT INTO tmp_sl_fix_lot_miss (inventoryLotLineId) - SELECT t.inventoryLotLineId - FROM tmp_sl_fix_lot_today t - LEFT JOIN tmp_sl_fix_lot_prev p - ON p.inventoryLotLineId = t.inventoryLotLineId - WHERE p.inventoryLotLineId IS NULL - """.trimIndent(), - ) - jdbcDao.executeUpdate( - """ - INSERT INTO tmp_sl_fix_lot_prev (inventoryLotLineId, prevAfter) - SELECT slp.inventoryLotLineId, slp.lotQtyAfter - FROM stock_ledger slp - INNER JOIN ( - SELECT sl.inventoryLotLineId, MAX(sl.id) AS maxId - FROM stock_ledger sl - WHERE sl.deleted = 0 - AND sl.date < :prevDay - AND sl.lotQtyAfter IS NOT NULL - AND sl.inventoryLotLineId IN ( - SELECT inventoryLotLineId FROM tmp_sl_fix_lot_miss - ) - GROUP BY sl.inventoryLotLineId - ) x ON x.maxId = slp.id - """.trimIndent(), - args, - ) - } - timed("2.4b window") { - jdbcDao.executeUpdate( - """ - INSERT INTO tmp_sl_fix_lot_qty (id, lotAfter, delta) - SELECT - sl2.id, - CAST( - COALESCE(p.prevAfter, 0) - + SUM( - CAST(COALESCE(sl2.inQty, 0) AS DECIMAL(14,2)) - - CAST(COALESCE(sl2.outQty, 0) AS DECIMAL(14,2)) - ) OVER ( - PARTITION BY sl2.inventoryLotLineId - ORDER BY sl2.date, sl2.id - ROWS BETWEEN UNBOUNDED PRECEDING AND CURRENT ROW - ) - AS DECIMAL(14,2)) AS lotAfter, - CAST(COALESCE(sl2.inQty, 0) AS DECIMAL(14,2)) - - CAST(COALESCE(sl2.outQty, 0) AS DECIMAL(14,2)) AS delta - FROM stock_ledger sl2 - LEFT JOIN tmp_sl_fix_lot_prev p - ON p.inventoryLotLineId = sl2.inventoryLotLineId - WHERE sl2.deleted = 0 - AND sl2.date >= :day AND sl2.date < :dayNext - AND sl2.inventoryLotLineId IS NOT NULL - """.trimIndent(), - args, - ) - } - timed("2.4c apply") { - jdbcDao.executeUpdate( - """ - UPDATE stock_ledger sl - INNER JOIN tmp_sl_fix_lot_qty t ON t.id = sl.id - SET sl.lotQtyAfter = t.lotAfter, - sl.lotQtyBefore = t.lotAfter - t.delta - """.trimIndent(), - ) - } - } finally { - jdbcDao.executeUpdate("DROP TEMPORARY TABLE IF EXISTS tmp_sl_fix_lot_qty") - jdbcDao.executeUpdate("DROP TEMPORARY TABLE IF EXISTS tmp_sl_fix_lot_prev") - jdbcDao.executeUpdate("DROP TEMPORARY TABLE IF EXISTS tmp_sl_fix_lot_today") - jdbcDao.executeUpdate("DROP TEMPORARY TABLE IF EXISTS tmp_sl_fix_lot_miss") - } - } - } - - /** - * 2.5 Running balance per inventoryId. - * Opening = last balance before the window. Fast path: day before :day. Lookback only for - * inventoryIds that had no row that day (idle days must not reset to 0). - * Window is :day .. :dayNext (one day or a range). - */ - private fun fillBalance(args: Map): Int { - val tx = TransactionTemplate(transactionManager) - return tx.execute { - jdbcDao.executeUpdate("DROP TEMPORARY TABLE IF EXISTS tmp_stock_ledger_fix_bal") - jdbcDao.executeUpdate("DROP TEMPORARY TABLE IF EXISTS tmp_stock_ledger_fix_prev") - jdbcDao.executeUpdate("DROP TEMPORARY TABLE IF EXISTS tmp_stock_ledger_fix_today") - jdbcDao.executeUpdate("DROP TEMPORARY TABLE IF EXISTS tmp_stock_ledger_fix_miss") - jdbcDao.executeUpdate( - """ - CREATE TEMPORARY TABLE tmp_stock_ledger_fix_today ( - inventoryId INT NOT NULL PRIMARY KEY - ) - """.trimIndent(), - ) - jdbcDao.executeUpdate( - """ - CREATE TEMPORARY TABLE tmp_stock_ledger_fix_miss ( - inventoryId INT NOT NULL PRIMARY KEY - ) - """.trimIndent(), - ) - jdbcDao.executeUpdate( - """ - CREATE TEMPORARY TABLE tmp_stock_ledger_fix_prev ( - inventoryId INT NOT NULL PRIMARY KEY, - prevBalance DECIMAL(14,2) NOT NULL - ) - """.trimIndent(), - ) - jdbcDao.executeUpdate( - """ - CREATE TEMPORARY TABLE tmp_stock_ledger_fix_bal ( - id INT NOT NULL PRIMARY KEY, - newBalance DECIMAL(14,2) NOT NULL - ) - """.trimIndent(), - ) - try { - jdbcDao.executeUpdate( - """ - INSERT INTO tmp_stock_ledger_fix_today (inventoryId) - SELECT DISTINCT inventoryId - FROM stock_ledger - WHERE deleted = 0 - AND date >= :day AND date < :dayNext - AND inventoryId IS NOT NULL - """.trimIndent(), - args, - ) - timed("2.5a1 yesterday") { - jdbcDao.executeUpdate( - """ - INSERT INTO tmp_stock_ledger_fix_prev (inventoryId, prevBalance) - SELECT slp.inventoryId, slp.balance - FROM stock_ledger slp - INNER JOIN ( - SELECT sl.inventoryId, MAX(sl.id) AS maxId - FROM stock_ledger sl - INNER JOIN tmp_stock_ledger_fix_today t - ON t.inventoryId = sl.inventoryId - WHERE sl.deleted = 0 - AND sl.date >= :prevDay AND sl.date < :day - AND sl.balance IS NOT NULL - GROUP BY sl.inventoryId - ) m ON m.maxId = slp.id - """.trimIndent(), - args, - ) - } - timed("2.5a2 lookback") { - jdbcDao.executeUpdate( - """ - INSERT INTO tmp_stock_ledger_fix_miss (inventoryId) - SELECT t.inventoryId - FROM tmp_stock_ledger_fix_today t - LEFT JOIN tmp_stock_ledger_fix_prev p - ON p.inventoryId = t.inventoryId - WHERE p.inventoryId IS NULL - """.trimIndent(), - ) - jdbcDao.executeUpdate( - """ - INSERT INTO tmp_stock_ledger_fix_prev (inventoryId, prevBalance) - SELECT slp.inventoryId, slp.balance - FROM stock_ledger slp - INNER JOIN ( - SELECT sl.inventoryId, MAX(sl.id) AS maxId - FROM stock_ledger sl - WHERE sl.deleted = 0 - AND sl.date < :prevDay - AND sl.balance IS NOT NULL - AND sl.inventoryId IN ( - SELECT inventoryId FROM tmp_stock_ledger_fix_miss - ) - GROUP BY sl.inventoryId - ) x ON x.maxId = slp.id - """.trimIndent(), - args, - ) - } - timed("2.5b window") { - jdbcDao.executeUpdate( - """ - INSERT INTO tmp_stock_ledger_fix_bal (id, newBalance) - SELECT - sl2.id, - CAST( - COALESCE(p.prevBalance, 0) - + 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 - LEFT JOIN tmp_stock_ledger_fix_prev p - ON p.inventoryId = sl2.inventoryId - WHERE sl2.deleted = 0 - AND sl2.date >= :day AND sl2.date < :dayNext - AND sl2.inventoryId IS NOT NULL - """.trimIndent(), - args, - ) - } - timed("2.5c apply") { - jdbcDao.executeUpdate( - """ - UPDATE stock_ledger sl - INNER JOIN tmp_stock_ledger_fix_bal t ON t.id = sl.id - SET sl.balance = t.newBalance - """.trimIndent(), - ) - } - } finally { - jdbcDao.executeUpdate("DROP TEMPORARY TABLE IF EXISTS tmp_stock_ledger_fix_bal") - jdbcDao.executeUpdate("DROP TEMPORARY TABLE IF EXISTS tmp_stock_ledger_fix_prev") - jdbcDao.executeUpdate("DROP TEMPORARY TABLE IF EXISTS tmp_stock_ledger_fix_today") - jdbcDao.executeUpdate("DROP TEMPORARY TABLE IF EXISTS tmp_stock_ledger_fix_miss") - } - } ?: 0 - } - - private fun fillLotLineIdScope(args: Map, byInventory: Boolean): Int { - if (byInventory) return fillLotLineIdScopeByInventory(args) - val lotFilter = """ - AND ( - COALESCE(sol.inventoryLotLineId, sil.inventoryLotLineId) = :lotId - OR sil.inventoryLotId IN (SELECT inventoryLotId FROM inventory_lot_line WHERE id = :lotId) - ) - """.trimIndent() - val fromSolSil = jdbcDao.executeUpdate( - """ - UPDATE stock_ledger sl - LEFT JOIN stock_out_line sol - ON sol.id = sl.stockOutLineId AND IFNULL(sol.deleted, 0) = 0 - LEFT JOIN stock_in_line sil - ON sil.id = sl.stockInLineId AND IFNULL(sil.deleted, 0) = 0 - SET sl.inventoryLotLineId = COALESCE(sol.inventoryLotLineId, sil.inventoryLotLineId) - WHERE sl.deleted = 0 - AND sl.date < :today - AND sl.inventoryLotLineId IS NULL - AND COALESCE(sol.inventoryLotLineId, sil.inventoryLotLineId) IS NOT NULL - $lotFilter - """.trimIndent(), - args, - ) - val fromOneLine = timed("lot 2.1b oneLine") { - jdbcDao.executeUpdate( - """ - UPDATE stock_ledger sl - INNER JOIN stock_in_line sil - ON sil.id = sl.stockInLineId AND IFNULL(sil.deleted, 0) = 0 - INNER JOIN ( - SELECT ill.inventoryLotId, MIN(ill.id) AS onlyLineId - FROM inventory_lot_line ill - INNER JOIN ( - SELECT DISTINCT sil2.inventoryLotId - FROM stock_ledger slx - INNER JOIN stock_in_line sil2 - ON sil2.id = slx.stockInLineId AND IFNULL(sil2.deleted, 0) = 0 - WHERE slx.deleted = 0 - AND slx.date < :today - AND slx.inventoryLotLineId IS NULL - AND sil2.inventoryLotLineId IS NULL - AND sil2.inventoryLotId IS NOT NULL - AND sil2.inventoryLotId IN (SELECT inventoryLotId FROM inventory_lot_line WHERE id = :lotId) - ) hdr ON hdr.inventoryLotId = ill.inventoryLotId - WHERE IFNULL(ill.deleted, 0) = 0 - GROUP BY ill.inventoryLotId - HAVING COUNT(*) = 1 - ) one ON one.inventoryLotId = sil.inventoryLotId - SET sl.inventoryLotLineId = one.onlyLineId - WHERE sl.deleted = 0 - AND sl.date < :today - AND sl.inventoryLotLineId IS NULL - AND sil.inventoryLotLineId IS NULL - AND sil.inventoryLotId IS NOT NULL - AND sil.inventoryLotId IN (SELECT inventoryLotId FROM inventory_lot_line WHERE id = :lotId) - """.trimIndent(), - args, - ) - } - return fromSolSil + fromOneLine - } - - /** Collect candidate ledger ids first — do not UPDATE with date < today OR …. */ - private fun fillLotLineIdScopeByInventory(args: Map): Int { - return withTempTx { - jdbcDao.executeUpdate("DROP TEMPORARY TABLE IF EXISTS tmp_sl_fix_lot_ids") - jdbcDao.executeUpdate("DROP TEMPORARY TABLE IF EXISTS tmp_sl_fix_sil_lots") - jdbcDao.executeUpdate("DROP TEMPORARY TABLE IF EXISTS tmp_sl_fix_one_line") - jdbcDao.executeUpdate( - """ - CREATE TEMPORARY TABLE tmp_sl_fix_lot_ids ( - id INT NOT NULL PRIMARY KEY - ) - """.trimIndent(), - ) - jdbcDao.executeUpdate( - """ - CREATE TEMPORARY TABLE tmp_sl_fix_sil_lots ( - inventoryLotId BIGINT NOT NULL PRIMARY KEY - ) - """.trimIndent(), - ) - jdbcDao.executeUpdate( - """ - CREATE TEMPORARY TABLE tmp_sl_fix_one_line ( - inventoryLotId BIGINT NOT NULL PRIMARY KEY, - onlyLineId BIGINT NOT NULL - ) - """.trimIndent(), - ) - try { - jdbcDao.executeUpdate( - """ - INSERT INTO tmp_sl_fix_lot_ids (id) - SELECT sl.id - FROM stock_ledger sl - WHERE sl.deleted = 0 - AND sl.date < :today - AND sl.inventoryLotLineId IS NULL - AND sl.inventoryId = :inventoryId - """.trimIndent(), - args, - ) - jdbcDao.executeUpdate( - """ - INSERT IGNORE INTO tmp_sl_fix_lot_ids (id) - SELECT sl.id - FROM stock_ledger sl - WHERE sl.deleted = 0 - AND sl.date < :today - AND sl.inventoryLotLineId IS NULL - AND sl.itemId = :itemId - AND (sl.uomId = :uomId OR sl.uomId IS NULL) - """.trimIndent(), - args, - ) - val fromSolSil = jdbcDao.executeUpdate( - """ - UPDATE stock_ledger sl - INNER JOIN tmp_sl_fix_lot_ids t ON t.id = sl.id - LEFT JOIN stock_out_line sol - ON sol.id = sl.stockOutLineId AND IFNULL(sol.deleted, 0) = 0 - LEFT JOIN stock_in_line sil - ON sil.id = sl.stockInLineId AND IFNULL(sil.deleted, 0) = 0 - SET sl.inventoryLotLineId = COALESCE(sol.inventoryLotLineId, sil.inventoryLotLineId) - WHERE sl.inventoryLotLineId IS NULL - AND COALESCE(sol.inventoryLotLineId, sil.inventoryLotLineId) IS NOT NULL - """.trimIndent(), - ) - jdbcDao.executeUpdate( - """ - INSERT INTO tmp_sl_fix_sil_lots (inventoryLotId) - SELECT DISTINCT sil.inventoryLotId - FROM tmp_sl_fix_lot_ids t - INNER JOIN stock_ledger sl ON sl.id = t.id - INNER JOIN stock_in_line sil - ON sil.id = sl.stockInLineId AND IFNULL(sil.deleted, 0) = 0 - WHERE sl.inventoryLotLineId IS NULL - AND sil.inventoryLotLineId IS NULL - AND sil.inventoryLotId IS NOT NULL - """.trimIndent(), - ) - jdbcDao.executeUpdate( - """ - INSERT INTO tmp_sl_fix_one_line (inventoryLotId, onlyLineId) - SELECT ill.inventoryLotId, MIN(ill.id) - FROM inventory_lot_line ill - INNER JOIN tmp_sl_fix_sil_lots hdr ON hdr.inventoryLotId = ill.inventoryLotId - WHERE IFNULL(ill.deleted, 0) = 0 - GROUP BY ill.inventoryLotId - HAVING COUNT(*) = 1 - """.trimIndent(), - ) - val fromOneLine = timed("inv 2.1b oneLine") { - jdbcDao.executeUpdate( - """ - UPDATE stock_ledger sl - INNER JOIN tmp_sl_fix_lot_ids t ON t.id = sl.id - INNER JOIN stock_in_line sil - ON sil.id = sl.stockInLineId AND IFNULL(sil.deleted, 0) = 0 - INNER JOIN tmp_sl_fix_one_line one ON one.inventoryLotId = sil.inventoryLotId - SET sl.inventoryLotLineId = one.onlyLineId - WHERE sl.inventoryLotLineId IS NULL - AND sil.inventoryLotLineId IS NULL - AND sil.inventoryLotId IS NOT NULL - """.trimIndent(), - ) - } - fromSolSil + fromOneLine - } finally { - jdbcDao.executeUpdate("DROP TEMPORARY TABLE IF EXISTS tmp_sl_fix_one_line") - jdbcDao.executeUpdate("DROP TEMPORARY TABLE IF EXISTS tmp_sl_fix_sil_lots") - jdbcDao.executeUpdate("DROP TEMPORARY TABLE IF EXISTS tmp_sl_fix_lot_ids") - } - } - } - - private fun fillUomIdScope(args: Map, byInventory: Boolean): Int { - if (!byInventory) { - return jdbcDao.executeUpdate( - """ - UPDATE stock_ledger sl - INNER JOIN inventory_lot_line ill - ON ill.id = sl.inventoryLotLineId AND IFNULL(ill.deleted, 0) = 0 - INNER JOIN item_uom iu - ON iu.id = ill.stockItemUomId - SET sl.uomId = iu.uomId - WHERE sl.deleted = 0 - AND sl.date < :today - AND sl.inventoryLotLineId = :lotId - """.trimIndent(), - args, - ) - } - return withTempTx { - jdbcDao.executeUpdate("DROP TEMPORARY TABLE IF EXISTS tmp_sl_fix_uom_lots") - jdbcDao.executeUpdate("DROP TEMPORARY TABLE IF EXISTS tmp_sl_fix_uom_ids") - jdbcDao.executeUpdate( - """ - CREATE TEMPORARY TABLE tmp_sl_fix_uom_lots ( - id BIGINT NOT NULL PRIMARY KEY - ) - """.trimIndent(), - ) - jdbcDao.executeUpdate( - """ - CREATE TEMPORARY TABLE tmp_sl_fix_uom_ids ( - id INT NOT NULL PRIMARY KEY - ) - """.trimIndent(), - ) - try { - jdbcDao.executeUpdate( - """ - INSERT INTO tmp_sl_fix_uom_lots (id) - SELECT DISTINCT inventoryLotLineId - FROM stock_ledger - WHERE deleted = 0 - AND date < :today - AND inventoryId = :inventoryId - AND inventoryLotLineId IS NOT NULL - """.trimIndent(), - args, - ) - jdbcDao.executeUpdate( - """ - INSERT INTO tmp_sl_fix_uom_ids (id) - SELECT sl.id - FROM stock_ledger sl - INNER JOIN tmp_sl_fix_uom_lots t ON t.id = sl.inventoryLotLineId - WHERE sl.deleted = 0 - AND sl.date < :today - """.trimIndent(), - args, - ) - jdbcDao.executeUpdate( - """ - UPDATE stock_ledger sl - INNER JOIN tmp_sl_fix_uom_ids t ON t.id = sl.id - INNER JOIN inventory_lot_line ill - ON ill.id = sl.inventoryLotLineId AND IFNULL(ill.deleted, 0) = 0 - INNER JOIN item_uom iu - ON iu.id = ill.stockItemUomId - SET sl.uomId = iu.uomId - """.trimIndent(), - ) - } finally { - jdbcDao.executeUpdate("DROP TEMPORARY TABLE IF EXISTS tmp_sl_fix_uom_ids") - jdbcDao.executeUpdate("DROP TEMPORARY TABLE IF EXISTS tmp_sl_fix_uom_lots") - } - } - } - - private fun fillInventoryIdScope(args: Map, byInventory: Boolean): Int { - if (!byInventory) { - return applyInventoryIdUpdates( - args, - extraJoin = "", - extraWhere = "sl.date < :today AND sl.inventoryLotLineId = :lotId", - ) - } - return withTempTx { - jdbcDao.executeUpdate("DROP TEMPORARY TABLE IF EXISTS tmp_sl_fix_inv_ids") - jdbcDao.executeUpdate( - """ - CREATE TEMPORARY TABLE tmp_sl_fix_inv_ids ( - id INT NOT NULL PRIMARY KEY - ) - """.trimIndent(), - ) - try { - jdbcDao.executeUpdate( - """ - INSERT INTO tmp_sl_fix_inv_ids (id) - SELECT sl.id - FROM stock_ledger sl - WHERE sl.deleted = 0 - AND sl.date < :today - AND sl.inventoryId = :inventoryId - AND sl.itemId IS NOT NULL - AND sl.uomId IS NOT NULL - """.trimIndent(), - args, - ) - jdbcDao.executeUpdate( - """ - INSERT IGNORE INTO tmp_sl_fix_inv_ids (id) - SELECT sl.id - FROM stock_ledger sl - WHERE sl.deleted = 0 - AND sl.date < :today - AND sl.itemId = :itemId - AND sl.itemId IS NOT NULL - AND sl.uomId IS NOT NULL - """.trimIndent(), - args, - ) - 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") - } - } - } - - private fun fillLotQtyHistory(args: Map, byInventory: Boolean): Int { - val extra = if (byInventory) "AND sl2.inventoryId = :inventoryId" else "AND sl2.inventoryLotLineId = :lotId" - val extraUpd = if (byInventory) "AND sl.inventoryId = :inventoryId" else "AND sl.inventoryLotLineId = :lotId" - return 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.inventoryLotLineId - ORDER BY sl2.id - ROWS BETWEEN UNBOUNDED PRECEDING AND CURRENT ROW - ) - AS DECIMAL(14,2)) AS lotAfter, - CAST(COALESCE(sl2.inQty, 0) AS DECIMAL(14,2)) - - CAST(COALESCE(sl2.outQty, 0) AS DECIMAL(14,2)) AS delta - FROM stock_ledger sl2 - WHERE sl2.deleted = 0 - AND sl2.date < :today - AND sl2.inventoryLotLineId IS NOT NULL - $extra - ) calc ON calc.id = sl.id - SET sl.lotQtyAfter = calc.lotAfter, - sl.lotQtyBefore = calc.lotAfter - calc.delta - WHERE sl.deleted = 0 - AND sl.date < :today - $extraUpd - """.trimIndent(), - args, - ) - } - - /** Exclusive bound for inventory-scope inspect/fix: include calendar today. */ - private fun inventoryScopeToExclusive(): LocalDate = LocalDate.now().plusDays(1) - - private fun fillBalanceHistory(args: Map): Int { - return 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.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(), - args, - ) - } - - private fun lotIdsForInventory(inventoryId: Long, today: LocalDate): List { - return jdbcDao.queryForList( - """ - SELECT DISTINCT inventoryLotLineId AS id - FROM stock_ledger - WHERE deleted = 0 - AND inventoryId = :inventoryId - AND inventoryLotLineId IS NOT NULL - AND date < :today - """.trimIndent(), - mapOf("inventoryId" to inventoryId, "today" to today), - ).map { toLong(it["id"]) }.filter { it > 0 } - } - - private fun dateRangeForFilter(filter: String, args: Map): Pair? { - val row = jdbcDao.queryForList( - """ - SELECT MIN(date) AS firstDate, MAX(date) AS lastDate - FROM stock_ledger - WHERE deleted = 0 - AND date < :today - AND $filter - """.trimIndent(), - args, - ).firstOrNull() ?: return null - val first = toLocalDate(row["firstDate"]) ?: return null - val last = toLocalDate(row["lastDate"]) ?: return null - val today = args["today"] as LocalDate - val toExclusive = last.plusDays(1).let { if (it.isAfter(today)) today else it } - return first to toExclusive - } - - private fun upsertStockLotDayRange(lotIds: List, from: LocalDate, toExclusive: LocalDate): Int { - if (lotIds.isEmpty()) return 0 - return jdbcDao.executeUpdate( - """ - INSERT INTO stock_lot_day ( - created, createdBy, version, modified, modifiedBy, deleted, - inventoryLotLineId, itemId, itemCode, uomId, lotNo, date, - opening, inQty, outQty, closing - ) - SELECT - NOW(), 'ledger-fix-api', 0, NOW(), 'ledger-fix-api', 0, - x.inventoryLotLineId, - x.itemId, - x.itemCode, - x.uomId, - il.lotNo, - x.d, - firstSl.lotQtyBefore, - x.inQty, - x.outQty, - lastSl.lotQtyAfter - FROM ( - SELECT - sl.inventoryLotLineId, - CAST(sl.date AS DATE) AS d, - MIN(sl.itemId) AS itemId, - MIN(sl.itemCode) AS itemCode, - MIN(sl.uomId) AS uomId, - MIN(sl.id) AS firstId, - MAX(sl.id) AS lastId, - CAST(SUM(COALESCE(sl.inQty, 0)) AS DECIMAL(14,2)) AS inQty, - CAST(SUM(COALESCE(sl.outQty, 0)) AS DECIMAL(14,2)) AS outQty - FROM stock_ledger sl - WHERE sl.deleted = 0 - AND sl.date >= :from AND sl.date < :toExclusive - AND sl.inventoryLotLineId IN (:lotIds) - AND sl.lotQtyAfter IS NOT NULL - GROUP BY sl.inventoryLotLineId, CAST(sl.date AS DATE) - ) x - INNER JOIN stock_ledger firstSl ON firstSl.id = x.firstId - 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 = 'ledger-fix-api', - version = stock_lot_day.version + 1, - deleted = 0 - """.trimIndent(), - mapOf( - "lotIds" to lotIds, - "from" to from, - "toExclusive" to toExclusive, - ), - ) - } - - private data class ScopeStats( - val cnt: Int, - val firstDate: String?, - val lastDate: String?, - val lastBalance: String?, - val lastLotQtyAfter: String?, - val lastInventoryId: Any?, - val parts: List, - ) - - private fun ledgerScopeStats( - filter: String, - args: Map, - itemId: Long? = null, - ): ScopeStats { - val ledger = jdbcDao.queryForList( - """ - SELECT - COUNT(*) AS cnt, - DATE_FORMAT(MIN(sl.date), '%Y-%m-%d') AS firstDate, - DATE_FORMAT(MAX(sl.date), '%Y-%m-%d') AS lastDate, - SUM(sl.inventoryLotLineId IS NULL) AS lotMiss, - SUM(sl.uomId IS NULL) AS uomMiss, - SUM(sl.inventoryId IS NULL) AS invMiss, - SUM( - sl.inventoryLotLineId IS NOT NULL - AND (sl.lotQtyAfter IS NULL OR sl.lotQtyBefore IS NULL) - ) AS lotQtyMissWithLot, - SUM( - sl.lotQtyAfter IS NOT NULL - AND sl.lotQtyBefore IS NOT NULL - AND sl.lotQtyAfter - sl.lotQtyBefore - <> CAST(COALESCE(sl.inQty, 0) AS DECIMAL(14,2)) - - CAST(COALESCE(sl.outQty, 0) AS DECIMAL(14,2)) - ) AS lotQtyIncorrect, - SUM( - sl.lotQtyAfter IS NOT NULL - AND sl.lotQtyBefore IS NOT NULL - AND sl.lotQtyAfter - sl.lotQtyBefore - = CAST(COALESCE(sl.inQty, 0) AS DECIMAL(14,2)) - - CAST(COALESCE(sl.outQty, 0) AS DECIMAL(14,2)) - AND (sl.lotQtyAfter < 0 OR sl.lotQtyBefore < 0) - ) AS overIssue, - SUM(sl.balance IS NULL) AS balanceMiss, - SUM( - sl.inventoryLotLineId IS NULL - AND COALESCE(sol.inventoryLotLineId, sil.inventoryLotLineId) IS NOT NULL - ) AS fixable21a, - SUM( - sl.inventoryLotLineId IS NULL - AND COALESCE(sol.inventoryLotLineId, sil.inventoryLotLineId) IS NULL - AND sil.inventoryLotId IS NOT NULL - AND IFNULL(lc.lineCnt, 0) = 1 - ) AS fixable21b, - SUM( - sl.inventoryLotLineId IS NULL - AND COALESCE(sol.inventoryLotLineId, sil.inventoryLotLineId) IS NULL - AND sil.inventoryLotId IS NOT NULL - AND IFNULL(lc.lineCnt, 0) > 1 - ) AS cannotMultiLine, - SUM( - sl.inventoryLotLineId IS NULL - AND COALESCE(sol.inventoryLotLineId, sil.inventoryLotLineId) IS NULL - AND (sil.inventoryLotId IS NULL OR IFNULL(lc.lineCnt, 0) = 0) - ) AS cannotNoSource - FROM stock_ledger sl - LEFT JOIN stock_out_line sol - ON sol.id = sl.stockOutLineId AND IFNULL(sol.deleted, 0) = 0 - LEFT JOIN stock_in_line sil - ON sil.id = sl.stockInLineId AND IFNULL(sil.deleted, 0) = 0 - LEFT JOIN ( - SELECT inventoryLotId, COUNT(*) AS lineCnt - FROM inventory_lot_line - WHERE IFNULL(deleted, 0) = 0 - GROUP BY inventoryLotId - ) lc ON lc.inventoryLotId = sil.inventoryLotId - WHERE sl.deleted = 0 - AND sl.date < :today - AND $filter - """.trimIndent(), - args, - ).firstOrNull() ?: emptyMap() - val last = jdbcDao.queryForList( - """ - SELECT sl.balance, sl.lotQtyAfter, sl.inventoryId - FROM stock_ledger sl - WHERE sl.deleted = 0 - AND sl.date < :today - AND $filter - ORDER BY sl.id DESC - LIMIT 1 - """.trimIndent(), - args, - ).firstOrNull() ?: emptyMap() - val cnt = toInt(ledger["cnt"]) - val overIssue = toInt(ledger["overIssue"]) - val earlyTke = if (overIssue > 0) overIssueEarlyTkeRows(filter, args, itemId) else 0 - return ScopeStats( - cnt = cnt, - firstDate = ledger["firstDate"]?.toString(), - lastDate = ledger["lastDate"]?.toString(), - lastBalance = last["balance"]?.toString(), - lastLotQtyAfter = last["lotQtyAfter"]?.toString(), - lastInventoryId = last["inventoryId"], - parts = fieldAndReasonParts( - cnt = cnt, - lotMiss = toInt(ledger["lotMiss"]), - lotIncorrect = 0, - uomMiss = toInt(ledger["uomMiss"]), - uomIncorrect = 0, - invMiss = toInt(ledger["invMiss"]), - invIncorrect = 0, - lotQtyMissWithLot = toInt(ledger["lotQtyMissWithLot"]), - lotQtyIncorrect = toInt(ledger["lotQtyIncorrect"]), - overIssue = overIssue, - balanceMiss = toInt(ledger["balanceMiss"]), - fixable21a = toInt(ledger["fixable21a"]), - fixable21b = toInt(ledger["fixable21b"]), - cannotMultiLine = toInt(ledger["cannotMultiLine"]), - cannotNoSource = toInt(ledger["cannotNoSource"]), - earlyTkeOver = earlyTke, - otherOver = (overIssue - earlyTke).coerceAtLeast(0), - ), - ) - } - - private fun stillMissScope(filter: String, args: Map): MissCounts { - val row = jdbcDao.queryForList( - """ - SELECT - SUM(inventoryLotLineId IS NULL) AS missLot, - SUM(uomId IS NULL) AS missUom, - SUM(inventoryId IS NULL) AS missInventoryId, - SUM(lotQtyAfter IS NULL) AS missLotQty - FROM stock_ledger - WHERE deleted = 0 - AND date < :today - AND $filter - """.trimIndent(), - args, - ).firstOrNull() ?: emptyMap() - return MissCounts( - missLot = toInt(row["missLot"]), - missUom = toInt(row["missUom"]), - missInventoryId = toInt(row["missInventoryId"]), - missLotQty = toInt(row["missLotQty"]), - ) - } - - private fun stillMiss(args: Map): MissCounts { - val row = jdbcDao.queryForList( - """ - SELECT - SUM(inventoryLotLineId IS NULL) AS missLot, - SUM(uomId IS NULL) AS missUom, - SUM(inventoryId IS NULL) AS missInventoryId, - SUM(lotQtyAfter IS NULL) AS missLotQty - FROM stock_ledger - WHERE deleted = 0 - AND date >= :day AND date < :dayNext - """.trimIndent(), - args, - ).firstOrNull() ?: emptyMap() - return MissCounts( - missLot = toInt(row["missLot"]), - missUom = toInt(row["missUom"]), - missInventoryId = toInt(row["missInventoryId"]), - missLotQty = toInt(row["missLotQty"]), - ) - } - - private fun fieldAndReasonParts( - cnt: Int, - lotMiss: Int, - lotIncorrect: Int, - uomMiss: Int, - uomIncorrect: Int, - invMiss: Int, - invIncorrect: Int, - lotQtyMissWithLot: Int, - lotQtyIncorrect: Int, - overIssue: Int, - balanceMiss: Int, - fixable21a: Int, - fixable21b: Int, - cannotMultiLine: Int, - cannotNoSource: Int, - earlyTkeOver: Int, - otherOver: Int, - extra: List = emptyList(), - ): List { - fun field(key: String, label: String, miss: Int, incorrect: Int) = - StockLedgerFixCheckPart( - key = key, - label = label, - ok = (cnt - miss - incorrect).coerceAtLeast(0), - miss = miss, - incorrect = incorrect, - group = "field", - ) - fun canFix(key: String, label: String, n: Int) = - StockLedgerFixCheckPart(key, label, ok = 0, miss = n, incorrect = 0, group = "canFix") - fun cannotFix(key: String, label: String, n: Int) = - StockLedgerFixCheckPart(key, label, ok = 0, miss = 0, incorrect = n, group = "cannotFix") - return buildList { - add(field("lot", "inventoryLotLineId", lotMiss, lotIncorrect)) - add(field("uom", "uomId", uomMiss, uomIncorrect)) - add(field("inventory", "inventoryId", invMiss, invIncorrect)) - add(field("lotQty", "lotQtyBefore/After(lot 已有)", lotQtyMissWithLot, lotQtyIncorrect)) - add(field("overIssue", "over-issue (lotQty < 0)", 0, overIssue)) - add(field("balance", "balance", balanceMiss, 0)) - addAll(extra) - add(canFix("fixable21a", "可修 2.1a:SIL/SOL 已有 lot line", fixable21a)) - add(canFix("fixable21b", "可修 2.1b:inventoryLotId 僅一條 line", fixable21b)) - add(canFix("fixableLotQty", "可修:lot 已有但缺 lotQty", lotQtyMissWithLot)) - add(cannotFix("cannotNoSource", "不能修:沒有 SIL/SOL lot 來源", cannotNoSource)) - add(cannotFix("cannotMultiLine", "不能修:同一 lot 有多條 warehouse line", cannotMultiLine)) - add(cannotFix("earlyTke", "不能修:舊 TKE 開新批(sibling 盤盈)", earlyTkeOver)) - add(cannotFix("realOver", "不能修:over-issue 非舊 TKE(真超發/帳不一致)", otherOver)) - } - } - - private fun overIssueEarlyTkeRows(filter: String, args: Map, itemId: Long?): Int { - if (itemId == null || itemId <= 0) return 0 - val q = args.toMutableMap() - q["itemId"] = itemId - return toInt( - jdbcDao.queryForList( - """ - SELECT SUM(ok) AS c FROM ( - SELECT EXISTS ( - SELECT 1 - FROM inventory_lot_line ill2 - INNER JOIN inventory_lot il2 - ON il2.id = ill2.inventoryLotId AND il2.itemId = :itemId - INNER JOIN ( - SELECT sl3.inventoryLotLineId - FROM stock_ledger sl3 - INNER JOIN inventory_lot_line ill3 ON ill3.id = sl3.inventoryLotLineId - INNER JOIN inventory_lot il3 ON il3.id = ill3.inventoryLotId AND il3.itemId = :itemId - WHERE sl3.deleted = 0 - AND sl3.date < :today - GROUP BY sl3.inventoryLotLineId - HAVING SUM(COALESCE(sl3.inQty, 0)) > 0 - AND SUM(CASE WHEN sl3.type = 'TKE' AND sl3.inQty > 0 THEN sl3.inQty ELSE 0 END) - >= SUM(COALESCE(sl3.inQty, 0)) * 0.99 - ) tke ON tke.inventoryLotLineId = ill2.id - WHERE IFNULL(ill2.deleted, 0) = 0 - AND ill2.warehouseId = ill.warehouseId - AND ill2.id <> sl.inventoryLotLineId - ) AS ok - FROM stock_ledger sl - INNER JOIN inventory_lot_line ill ON ill.id = sl.inventoryLotLineId - WHERE sl.deleted = 0 - AND sl.date < :today - AND $filter - AND sl.lotQtyAfter IS NOT NULL - AND sl.lotQtyBefore IS NOT NULL - AND sl.lotQtyAfter - sl.lotQtyBefore - = CAST(COALESCE(sl.inQty, 0) AS DECIMAL(14,2)) - - CAST(COALESCE(sl.outQty, 0) AS DECIMAL(14,2)) - AND (sl.lotQtyAfter < 0 OR sl.lotQtyBefore < 0) - ) x - """.trimIndent(), - q, - ).firstOrNull()?.get("c"), - ) - } - - /** - * 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 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, to: LocalDate, out: OutputStream, partsRaw: List? = null, - ) { - if (to.isBefore(from)) throw IllegalArgumentException("to must be on or after from") - if (from.plusDays(400).isBefore(to)) { - throw IllegalArgumentException("date range must be at most 400 days") - } - val today = LocalDate.now() - if (to.isAfter(today)) { - throw IllegalArgumentException("cannot export future dates") - } - val toExclusive = to.plusDays(1) - if (!from.isBefore(toExclusive)) { - throw IllegalArgumentException("from must be before toExclusive") - } - val parts = parseExportParts(partsRaw) - val want10 = "1.0" in parts - val want23 = "2.3" in parts - val wantLedger = "ledger" in parts - val want26 = "2.6" in parts - val want27 = "2.7" in parts - if (!want10 && !want23 && !wantLedger && !want26 && !want27) { - throw IllegalArgumentException("parts must include at least one of: 1.0, 2.3, ledger, 2.6, 2.7") - } - val args = mapOf("from" to from, "toExclusive" to toExclusive) - val ledgerCnt = if (wantLedger || want23 || want27) { - toInt( - jdbcDao.queryForList( - """ - SELECT COUNT(*) AS c FROM stock_ledger - WHERE deleted = 0 AND date >= :from AND date < :toExclusive - """.trimIndent(), - args, - ).firstOrNull()?.get("c"), - ) - } else { - 0 - } - val dayCnt = if (want26) { - toInt( - jdbcDao.queryForList( - """ - SELECT COUNT(*) AS c FROM stock_lot_day - WHERE IFNULL(deleted, 0) = 0 AND date >= :from AND date < :toExclusive - """.trimIndent(), - args, - ).firstOrNull()?.get("c"), - ) - } else { - 0 - } - val adjCnt = if (want27) { - toInt( - jdbcDao.queryForList( - """ - SELECT COUNT(*) AS c FROM stock_ledger - WHERE deleted = 0 - AND date >= :from AND date < :toExclusive - AND type = 'ADJ' - AND createdBy = 'stock-ledger-fix' - """.trimIndent(), - args, - ).firstOrNull()?.get("c"), - ) - } else { - 0 - } - val invCnt = if (want10) { - toInt( - jdbcDao.queryForList( - "SELECT COUNT(*) AS c FROM inventory WHERE IFNULL(deleted, 0) = 0", - ).firstOrNull()?.get("c"), - ) - } else { - 0 - } - val writer = BufferedWriter(OutputStreamWriter(out, StandardCharsets.UTF_8), 65536) - val jdbc = NamedParameterJdbcTemplate(dataSource) - jdbc.jdbcTemplate.fetchSize = 2000 - try { - 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 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") - } - writer.write("-- please run in the target DB/schema, e.g. USE fpsmsdb;\n") - writer.write( - "-- expected inventoryRows=$invCnt ledgerRows=$ledgerCnt adjRows=$adjCnt stock_lot_dayRows=$dayCnt\n", - ) - writer.write("-- SET NAMES utf8mb4;\n\n") - - if (want10) { - writeExportInventory10(writer, jdbc) - } - if (wantLedger || want23) { - writeExportLedgerPatch(writer, jdbc, args, wantLedger, want23) - } - if (want27) { - writeExportAdjDocs(writer, jdbc, args) - writeExportAdjInsert(writer, jdbc, args) - } - if (want26) { - writeExportStockLotDay(writer, jdbc, args, from, toExclusive) - } - - writer.write("\n-- done\n") - writer.flush() - log.info( - "stock-ledger-fix export {}..{} parts={} inv={} ledger={} adj={} day={}", - from, toExclusive.minusDays(1), parts.joinToString(","), invCnt, ledgerCnt, adjCnt, dayCnt, - ) - } finally { - writer.flush() - } - } - - private fun writeExportInventory10(writer: BufferedWriter, jdbc: NamedParameterJdbcTemplate) { - writer.write("-- === 1.0 inventory (INSERT with fixed id + UPDATE qty/status) ===\n") - var batch = 0 - jdbc.query( - """ - SELECT id, created, createdBy, version, modified, modifiedBy, deleted, - itemId, uomId, stockUomId, onHandQty, onHoldQty, unavailableQty, - price, currencyId, cpu, cpuUnit, cpm, cpmUnit, status - FROM inventory - WHERE IFNULL(deleted, 0) = 0 - ORDER BY id - """.trimIndent(), - emptyMap(), - RowCallbackHandler { rs -> - if (batch == 0) { - writer.write( - "INSERT INTO inventory (\n" + - " id, created, createdBy, version, modified, modifiedBy, deleted,\n" + - " itemId, uomId, stockUomId, onHandQty, onHoldQty, unavailableQty,\n" + - " price, currencyId, cpu, cpuUnit, cpm, cpmUnit, status\n" + - ") VALUES\n", - ) - } else { - writer.write(",\n") - } - 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, "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")})", - ) - batch++ - if (batch >= 200) { - writer.write( - "\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), " + - "version=inventory.version+1, deleted=0;\n\n", - ) - batch = 0 - } - }, - ) - if (batch > 0) { - writer.write( - "\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), " + - "version=inventory.version+1, deleted=0;\n\n", - ) - } else { - writer.write("-- (no inventory rows)\n\n") - } - } - - private fun writeExportLedgerPatch( - writer: BufferedWriter, - jdbc: NamedParameterJdbcTemplate, - args: Map, - wantLedger: Boolean, - want23: Boolean, - ) { - writer.write("-- === ledger UPDATE (JOIN id)") - if (wantLedger) writer.write(" lot/uom/lotQty/balance") - if (want23) writer.write(" inventoryId") - writer.write(" ===\n") - val cols = mutableListOf("id") - if (wantLedger) { - cols += listOf("inventoryLotLineId", "uomId", "lotQtyBefore", "lotQtyAfter", "balance") - } - if (want23) cols += "inventoryId" - writer.write("CREATE TEMPORARY TABLE tmp_sl_fix_patch (\n") - writer.write(" id INT NOT NULL PRIMARY KEY") - if (wantLedger) { - writer.write(",\n inventoryLotLineId BIGINT NULL,\n uomId INT NULL,\n") - writer.write(" lotQtyBefore DECIMAL(14,2) NULL,\n lotQtyAfter DECIMAL(14,2) NULL,\n") - writer.write(" balance DECIMAL(14,2) NULL") - } - if (want23) writer.write(",\n inventoryId INT NULL") - writer.write("\n);\n\n") - var batch = 0 - val selectCols = cols.joinToString(", ") - jdbc.query( - """ - SELECT $selectCols - FROM stock_ledger - WHERE deleted = 0 AND date >= :from AND date < :toExclusive - """.trimIndent(), - args, - RowCallbackHandler { rs -> - if (batch == 0) { - writer.write("INSERT INTO tmp_sl_fix_patch (${cols.joinToString(", ")}) VALUES\n") - } else { - writer.write(",\n") - } - writer.write("(" + cols.joinToString(", ") { rsSql(rs, it) } + ")") - batch++ - if (batch >= 500) { - writer.write(";\n\n") - batch = 0 - } - }, - ) - if (batch > 0) writer.write(";\n\n") - val setParts = mutableListOf() - if (wantLedger) { - setParts += listOf( - "sl.inventoryLotLineId = t.inventoryLotLineId", - "sl.uomId = t.uomId", - "sl.lotQtyBefore = t.lotQtyBefore", - "sl.lotQtyAfter = t.lotQtyAfter", - "sl.balance = t.balance", - ) - } - if (want23) setParts += "sl.inventoryId = t.inventoryId" - writer.write( - """ - UPDATE stock_ledger sl - INNER JOIN tmp_sl_fix_patch t ON t.id = sl.id - SET - ${setParts.joinToString(",\n ")} - WHERE sl.deleted = 0; - - DROP TEMPORARY TABLE IF EXISTS tmp_sl_fix_patch; - - """.trimIndent(), - ) - 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, - args: Map, - ) { - writer.write("-- === 2.7 ADJ INSERT (fixed id; rows missing on target are created) ===\n") - var batch = 0 - jdbc.query( - """ - SELECT id, created, createdBy, version, modified, modifiedBy, deleted, - date, stockInLineId, stockOutLineId, inventoryLotLineId, inventoryId, - itemId, itemCode, inQty, outQty, balance, lotQtyBefore, lotQtyAfter, - uomId, type - FROM stock_ledger - WHERE deleted = 0 - AND date >= :from AND date < :toExclusive - AND type = 'ADJ' - AND createdBy = 'stock-ledger-fix' - ORDER BY id - """.trimIndent(), - args, - RowCallbackHandler { rs -> - if (batch == 0) { - writer.write( - "INSERT INTO stock_ledger (\n" + - " id, created, createdBy, version, modified, modifiedBy, deleted,\n" + - " date, stockInLineId, stockOutLineId, inventoryLotLineId, inventoryId,\n" + - " itemId, itemCode, inQty, outQty, balance, lotQtyBefore, lotQtyAfter,\n" + - " uomId, type\n) VALUES\n", - ) - } else { - writer.write(",\n") - } - 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, "date")}, ${rsSql(rs, "stockInLineId")}, ${rsSql(rs, "stockOutLineId")}, " + - "${rsSql(rs, "inventoryLotLineId")}, ${rsSql(rs, "inventoryId")}, " + - "${rsSql(rs, "itemId")}, ${rsSql(rs, "itemCode")}, ${rsSql(rs, "inQty")}, ${rsSql(rs, "outQty")}, " + - "${rsSql(rs, "balance")}, ${rsSql(rs, "lotQtyBefore")}, ${rsSql(rs, "lotQtyAfter")}, " + - "${rsSql(rs, "uomId")}, ${rsSql(rs, "type")})", - ) - batch++ - if (batch >= 200) { - 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), " + - "version=stock_ledger.version+1, deleted=0;\n\n", - ) - batch = 0 - } - }, - ) - if (batch > 0) { - 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), " + - "version=stock_ledger.version+1, deleted=0;\n\n", - ) - } else { - writer.write("-- (no ADJ rows from stock-ledger-fix in range)\n\n") - } - } - - private fun writeExportStockLotDay( - writer: BufferedWriter, - jdbc: NamedParameterJdbcTemplate, - args: Map, - from: LocalDate, - toExclusive: LocalDate, - ) { - writer.write("-- === 2.6 stock_lot_day ===\n") - writer.write( - """ - DELETE FROM stock_lot_day - WHERE `date` >= '$from' AND `date` < '$toExclusive'; - - """.trimIndent(), - ) - writer.write("\n") - var batch = 0 - jdbc.query( - """ - SELECT created, createdBy, version, modified, modifiedBy, deleted, - inventoryLotLineId, itemId, itemCode, uomId, lotNo, date, - opening, inQty, outQty, closing - FROM stock_lot_day - WHERE IFNULL(deleted, 0) = 0 AND date >= :from AND date < :toExclusive - ORDER BY date, inventoryLotLineId - """.trimIndent(), - args, - RowCallbackHandler { rs -> - if (batch == 0) { - writer.write( - "INSERT INTO stock_lot_day (\n" + - " created, createdBy, version, modified, modifiedBy, deleted,\n" + - " inventoryLotLineId, itemId, itemCode, uomId, lotNo, `date`,\n" + - " opening, inQty, outQty, closing\n) VALUES\n", - ) - } else { - writer.write(",\n") - } - writer.write( - "(${rsSql(rs, "created")}, ${rsSql(rs, "createdBy")}, ${rsSql(rs, "version")}, " + - "${rsSql(rs, "modified")}, ${rsSql(rs, "modifiedBy")}, ${rsSql(rs, "deleted")}, " + - "${rsSql(rs, "inventoryLotLineId")}, ${rsSql(rs, "itemId")}, ${rsSql(rs, "itemCode")}, " + - "${rsSql(rs, "uomId")}, ${rsSql(rs, "lotNo")}, ${rsSql(rs, "date")}, " + - "${rsSql(rs, "opening")}, ${rsSql(rs, "inQty")}, ${rsSql(rs, "outQty")}, " + - "${rsSql(rs, "closing")})", - ) - batch++ - if (batch >= 500) { - writer.write( - "\nON 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=VALUES(modified), modifiedBy=VALUES(modifiedBy), " + - "version=stock_lot_day.version+1, deleted=0;\n\n", - ) - batch = 0 - } - }, - ) - if (batch > 0) { - writer.write( - "\nON 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=VALUES(modified), modifiedBy=VALUES(modifiedBy), " + - "version=stock_lot_day.version+1, deleted=0;\n", - ) - } - } - - private fun parseExportParts(raw: List?): Set { - if (raw.isNullOrEmpty() || raw.any { it.trim().equals("all", ignoreCase = true) }) { - return EXPORT_PARTS_DEFAULT - } - val flat = raw.flatMap { it.split(",") }.map { it.trim() }.filter { it.isNotEmpty() } - if (flat.isEmpty() || flat.any { it.equals("all", ignoreCase = true) }) { - return EXPORT_PARTS_DEFAULT - } - val out = linkedSetOf() - flat.forEach { token -> - val t = token.lowercase() - val key = when (t) { - "1.0", "10", "inventory" -> "1.0" - "2.3", "23", "inventoryid" -> "2.3" - "ledger", "2.1", "2.2", "2.4", "2.5" -> "ledger" - "2.6", "26", "day", "stock_lot_day" -> "2.6" - "2.7", "27", "adj" -> "2.7" - else -> throw IllegalArgumentException( - "unknown export part '$token' (use 1.0, 2.3, ledger, 2.6, 2.7, or all)", - ) - } - out.add(key) - } - if (out.isEmpty()) throw IllegalArgumentException("parts must not be empty") - 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" - return when (v) { - is BigDecimal -> v.stripTrailingZeros().toPlainString() - is Number -> v.toString() - is java.sql.Date -> "'${v.toLocalDate()}'" - is java.sql.Timestamp -> "'${v.toLocalDateTime().toString().replace('T', ' ')}'" - is Boolean -> if (v) "1" else "0" - else -> "'" + v.toString().replace("\\", "\\\\").replace("'", "''") + "'" - } - } - - private fun dayArgs(day: LocalDate): Map = - mapOf( - "day" to day, - "dayNext" to day.plusDays(1), - "prevDay" to day.minusDays(1), - ) - - /** 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, - "dayNext" to to.plusDays(1), - "prevDay" to from.minusDays(1), - ) - - /** Same JDBC connection so TEMPORARY TABLEs survive across statements. */ - private fun withTempTx(block: () -> Int): Int { - return TransactionTemplate(transactionManager).execute { block() } ?: 0 - } - - private fun timed(name: String, block: () -> Int): Int { - val t0 = System.nanoTime() - return try { - val n = block() - log.info("stock-ledger-fix step {} rows={} {}ms", name, n, (System.nanoTime() - t0) / 1_000_000) - n - } catch (e: Exception) { - log.warn("stock-ledger-fix step {} FAILED {}ms: {}", name, (System.nanoTime() - t0) / 1_000_000, e.message) - throw e - } - } - - private fun parseDay(raw: String): LocalDate { - val trimmed = raw.trim() - if (trimmed.isEmpty()) throw IllegalArgumentException("date is required (yyyy-MM-dd)") - return try { - LocalDate.parse(trimmed) - } catch (_: DateTimeParseException) { - throw IllegalArgumentException("date must be yyyy-MM-dd") - } - } - - /** - * ADJ window: ledger date = [adjDate], gap sums use `date < adjDate+1`. - * Default adjDate = yesterday. Pass today for freeze-night dump (e.g. 31/8). - */ - private fun resolveAdjWindow(adjDateRaw: String?): Pair { - val today = LocalDate.now() - val adjDate = if (adjDateRaw.isNullOrBlank()) { - today.minusDays(1) - } else { - parseDay(adjDateRaw) - } - if (adjDate.isAfter(today)) { - throw IllegalArgumentException("adjDate cannot be in the future") - } - return adjDate to adjDate.plusDays(1) - } - - private fun parseDaySteps(raw: List?): Set { - if (raw.isNullOrEmpty() || raw.any { it.trim().equals("all", ignoreCase = true) }) { - return DAY_STEPS - } - val out = linkedSetOf() - raw.forEach { token -> - val t = token.trim().lowercase() - val key = when (t) { - "2.1", "21" -> "2.1" - "2.2", "22" -> "2.2" - "2.3", "23" -> "2.3" - "2.4", "24" -> "2.4" - "2.5", "25" -> "2.5" - "2.6", "26" -> "2.6" - else -> throw IllegalArgumentException("unknown step '$token' (use 2.1–2.6 or all)") - } - out.add(key) - } - if (out.isEmpty()) throw IllegalArgumentException("steps must not be empty") - return out - } - - private fun toInt(value: Any?): Int = when (value) { - null -> 0 - is Number -> value.toInt() - else -> value.toString().toIntOrNull() ?: 0 - } - - private fun toLong(value: Any?): Long = toLongOrNull(value) ?: 0L - - private fun toLongOrNull(value: Any?): Long? = when (value) { - null -> null - is Number -> value.toLong() - else -> value.toString().toLongOrNull() - } - - private fun loadAdjGaps(today: LocalDate): List { - val tx = TransactionTemplate(transactionManager) - tx.timeout = 300 - val args = mapOf("today" to today) - return tx.execute { - jdbcDao.executeUpdate("DROP TEMPORARY TABLE IF EXISTS tmp_adj_line") - jdbcDao.executeUpdate("DROP TEMPORARY TABLE IF EXISTS tmp_adj_led") - 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 - ) - """.trimIndent(), - ) - 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(), - ) - try { - timed("adj line-all") { - 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 = jdbcDao.queryForList( - "SELECT lotLineId FROM tmp_adj_line", - ).map { toLong(it["lotLineId"]) }.filter { it > 0 } - log.info("stock-ledger-fix adj line rows={}", lineIds.size) - if (lineIds.isEmpty()) return@execute emptyList() - timed("adj ledger-by-line") { - lineIds.chunked(40).sumOf { chunk -> - 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), - ) - } - } - 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 - """.trimIndent(), - ).map { row -> - val lineIn = toDecimal(row["lineIn"]) - val lineOut = toDecimal(row["lineOut"]) - val ledgerIn = toDecimal(row["ledgerIn"]) - val ledgerOut = toDecimal(row["ledgerOut"]) - AdjGap( - lotLineId = toLong(row["lotLineId"]), - inventoryId = toLongOrNull(row["inventoryId"]), - itemId = toLongOrNull(row["itemId"]), - itemCode = row["itemCode"]?.toString(), - uomId = toLongOrNull(row["uomId"]), - lineIn = lineIn, - lineOut = lineOut, - ledgerIn = ledgerIn, - ledgerOut = ledgerOut, - missIn = lineIn.subtract(ledgerIn), - missOut = lineOut.subtract(ledgerOut), - ) - } - } finally { - try { - jdbcDao.executeUpdate("DROP TEMPORARY TABLE IF EXISTS tmp_adj_led") - 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() - } - - 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, - "itemCode" to g.itemCode, - "inQty" to inQty, - "outQty" to outQty, - "uomId" to g.uomId, - ) - jdbcDao.executeUpdate( - """ - INSERT INTO stock_ledger ( - created, createdBy, version, modified, modifiedBy, deleted, - date, stockInLineId, stockOutLineId, inventoryLotLineId, inventoryId, - itemId, itemCode, inQty, outQty, balance, lotQtyBefore, lotQtyAfter, - uomId, type - ) VALUES ( - NOW(), 'stock-ledger-fix', 0, NOW(), 'stock-ledger-fix', 0, - :adjDate, :silId, :solId, :lotLineId, :inventoryId, - :itemId, :itemCode, :inQty, :outQty, NULL, NULL, NULL, - :uomId, 'ADJ' - ) - """.trimIndent(), - args, - ) - } - - /** 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 { - jdbcDao.executeUpdate("DROP TEMPORARY TABLE IF EXISTS tmp_sl_fix_adj_lots") - jdbcDao.executeUpdate( - "CREATE TEMPORARY TABLE tmp_sl_fix_adj_lots (id BIGINT NOT NULL PRIMARY KEY)", - ) - try { - lotIds.chunked(200).forEach { chunk -> - val values = chunk.joinToString(",") { "($it)" } - jdbcDao.executeUpdate("INSERT INTO tmp_sl_fix_adj_lots (id) VALUES $values") - } - 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.inventoryLotLineId - ORDER BY sl2.id - ROWS BETWEEN UNBOUNDED PRECEDING AND CURRENT ROW - ) - AS DECIMAL(14,2)) AS lotAfter, - CAST(COALESCE(sl2.inQty, 0) AS DECIMAL(14,2)) - - CAST(COALESCE(sl2.outQty, 0) AS DECIMAL(14,2)) AS delta - FROM stock_ledger sl2 - INNER JOIN tmp_sl_fix_adj_lots t ON t.id = sl2.inventoryLotLineId - WHERE sl2.deleted = 0 - AND sl2.date < :today - AND sl2.inventoryLotLineId IS NOT NULL - ) calc ON calc.id = sl.id - SET sl.lotQtyAfter = calc.lotAfter, - sl.lotQtyBefore = calc.lotAfter - calc.delta - WHERE sl.deleted = 0 - AND sl.date < :today - """.trimIndent(), - mapOf("today" to today), - ) - } finally { - try { - jdbcDao.executeUpdate("DROP TEMPORARY TABLE IF EXISTS tmp_sl_fix_adj_lots") - } catch (e: Exception) { - log.warn("stock-ledger-fix drop tmp_sl_fix_adj_lots skipped: {}", e.message) - } - } - } - } - - /** Chain balance on new ADJ rows only; older rows for the same SKU are unchanged. */ - private fun fillBalanceOnNewAdjRows(adjDate: LocalDate, today: LocalDate): Int { - val rows = jdbcDao.queryForList( - """ - SELECT id, inventoryId, - CAST(COALESCE(inQty, 0) AS DECIMAL(14,2)) AS inQty, - CAST(COALESCE(outQty, 0) AS DECIMAL(14,2)) AS outQty - FROM stock_ledger - WHERE deleted = 0 - AND date >= :adjDate AND date < :today - AND type = 'ADJ' - AND createdBy = 'stock-ledger-fix' - AND inventoryId IS NOT NULL - ORDER BY id - """.trimIndent(), - mapOf("adjDate" to adjDate, "today" to today), - ) - if (rows.isEmpty()) return 0 - val prevByInv = HashMap() - val updates = ArrayList>() - rows.forEach { row -> - val id = toLong(row["id"]) - val invId = toLong(row["inventoryId"]) - if (prevByInv[invId] == null) { - prevByInv[invId] = toDecimal( - jdbcDao.queryForList( - """ - SELECT balance - FROM stock_ledger - WHERE deleted = 0 - AND inventoryId = :invId - AND date < :today - AND id < :adjId - ORDER BY id DESC - LIMIT 1 - """.trimIndent(), - mapOf("invId" to invId, "today" to today, "adjId" to id), - ).firstOrNull()?.get("balance"), - ) - } - val delta = toDecimal(row["inQty"]).subtract(toDecimal(row["outQty"])) - val bal = prevByInv.getValue(invId).add(delta) - prevByInv[invId] = bal - updates.add(id to bal) - } - return withTempTx { - jdbcDao.executeUpdate("DROP TEMPORARY TABLE IF EXISTS tmp_sl_fix_adj_bal") - jdbcDao.executeUpdate( - """ - CREATE TEMPORARY TABLE tmp_sl_fix_adj_bal ( - id BIGINT NOT NULL PRIMARY KEY, - newBalance DECIMAL(14,2) NOT NULL - ) - """.trimIndent(), - ) - try { - updates.chunked(200).forEach { chunk -> - val values = chunk.joinToString(",") { (id, bal) -> - "($id, ${bal.toPlainString()})" - } - jdbcDao.executeUpdate( - "INSERT INTO tmp_sl_fix_adj_bal (id, newBalance) VALUES $values", - ) - } - jdbcDao.executeUpdate( - """ - UPDATE stock_ledger sl - INNER JOIN tmp_sl_fix_adj_bal t ON t.id = sl.id - SET sl.balance = t.newBalance - WHERE sl.deleted = 0 - """.trimIndent(), - ) - } finally { - try { - jdbcDao.executeUpdate("DROP TEMPORARY TABLE IF EXISTS tmp_sl_fix_adj_bal") - } catch (e: Exception) { - log.warn("stock-ledger-fix drop tmp_sl_fix_adj_bal skipped: {}", e.message) - } - } - } - } - - private fun toDecimal(value: Any?): BigDecimal = when (value) { - null -> BigDecimal.ZERO - is BigDecimal -> value - is Number -> BigDecimal(value.toString()) - else -> value.toString().toBigDecimalOrNull() ?: BigDecimal.ZERO - } - - private data class AdjGap( - val lotLineId: Long, - val inventoryId: Long?, - val itemId: Long?, - val itemCode: String?, - val uomId: Long?, - val lineIn: BigDecimal, - val lineOut: BigDecimal, - val ledgerIn: BigDecimal, - val ledgerOut: BigDecimal, - 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, - itemCode = itemCode, - lineIn = lineIn.stripTrailingZeros().toPlainString(), - lineOut = lineOut.stripTrailingZeros().toPlainString(), - ledgerIn = ledgerIn.stripTrailingZeros().toPlainString(), - ledgerOut = ledgerOut.stripTrailingZeros().toPlainString(), - missIn = missIn.stripTrailingZeros().toPlainString(), - missOut = missOut.stripTrailingZeros().toPlainString(), - overIssue = overIssue.stripTrailingZeros().toPlainString(), - ) - } - - private fun toLocalDate(value: Any?): LocalDate? = when (value) { - null -> null - is LocalDate -> value - is java.sql.Date -> value.toLocalDate() - is java.sql.Timestamp -> value.toLocalDateTime().toLocalDate() - else -> try { - LocalDate.parse(value.toString().take(10)) - } catch (_: Exception) { - null - } - } - - private data class MissCounts( - val missLot: Int, - val missUom: Int, - val missInventoryId: Int, - val missLotQty: Int, - ) - - companion object { - 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 ( - SELECT il.itemId, iu.uomId - 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, iu.uomId - ) x - """ - - private const val MISSING_UOM_PAIR_COUNT_SQL = """ - SELECT COUNT(*) AS c FROM ( - SELECT il.itemId, iu.uomId - 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 - LEFT JOIN inventory inv - ON inv.itemId = il.itemId - 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 - AND iu.uomId IS NOT NULL - AND inv.id IS NULL - GROUP BY il.itemId, iu.uomId - ) 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. - * One keeper per (itemId, uomId); skip if that stockUomId is already taken - * (including soft-deleted rows that still occupy the unique key). - */ - 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 - INNER JOIN ( - SELECT itemId, uomId, MIN(id) AS keepId - FROM inventory - WHERE IFNULL(deleted, 0) = 0 - AND stockUomId IS NULL - GROUP BY itemId, uomId - ) keep ON keep.keepId = i.id - 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 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 - """ - - /** - * Remaining NULL stockUomId: item has exactly one lot stock UOM - * (uomId already base, stock may differ). Keep MIN(id) only. - */ - 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 - INNER JOIN ( - SELECT itemId, MIN(id) AS keepId - FROM inventory - WHERE IFNULL(deleted, 0) = 0 - AND stockUomId IS NULL - GROUP BY itemId - ) keep ON keep.keepId = i.id - 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 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, 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), - tpl.currencyId, - COALESCE(tpl.cpu, 0), - COALESCE(tpl.cpuUnit, 'HKD'), - COALESCE(tpl.cpm, 0), - COALESCE(tpl.cpmUnit, 'HKD'), - 'unavailable', - NOW(), 'stock-ledger-fix', NOW(), 'stock-ledger-fix', 0, 0 - FROM ( - SELECT il.itemId, iu.uomId - 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, 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 IFNULL(existing.deleted, 0) = 0 - AND ( - existing.stockUomId = src.uomId - OR (existing.stockUomId IS NULL AND existing.uomId = src.uomId) - ) - LEFT JOIN inventory occupied - ON occupied.itemId = src.itemId - AND occupied.stockUomId = src.uomId - LEFT JOIN inventory tpl - ON tpl.id = ( - SELECT MIN(i.id) - FROM inventory i - WHERE i.itemId = src.itemId - AND IFNULL(i.deleted, 0) = 0 - ) - WHERE existing.id IS NULL - AND occupied.id IS NULL - """ - - /** - * Extra old UOM rows left NULL after a keeper bucket exists for the item - * (e.g. 18918 id 5261). Soft-delete so search / COALESCE fallbacks ignore them. - */ - private const val SOFT_DELETE_ORPHAN_NULL_STOCK_UOM_SQL = """ - UPDATE inventory i - INNER JOIN ( - SELECT DISTINCT itemId - FROM inventory - WHERE IFNULL(deleted, 0) = 0 - AND stockUomId IS NOT NULL - ) keeper ON keeper.itemId = i.itemId - SET - i.deleted = 1, - i.modified = NOW(), - i.modifiedBy = 'stock-ledger-fix' - WHERE IFNULL(i.deleted, 0) = 0 - AND i.stockUomId IS NULL - """ - - private const val UPDATE_INVENTORY_QTY_SQL = """ - UPDATE inventory i - LEFT JOIN ( - SELECT - il.itemId, - iu.uomId, - SUM(COALESCE(ill.inQty, 0) - COALESCE(ill.outQty, 0)) AS onHand, - SUM( - CASE - WHEN LOWER(ill.status) = 'unavailable' - THEN COALESCE(ill.inQty, 0) - COALESCE(ill.outQty, 0) - COALESCE(ill.holdQty, 0) - ELSE 0 - END - ) AS unavailable - 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, iu.uomId - ) agg ON agg.itemId = i.itemId AND agg.uomId = COALESCE(i.stockUomId, i.uomId) - SET - i.onHandQty = COALESCE(agg.onHand, 0), - i.unavailableQty = COALESCE(agg.unavailable, 0), - i.status = IF( - COALESCE(agg.onHand, 0) - COALESCE(agg.unavailable, 0) > 0, - 'available', - 'unavailable' - ), - i.modified = NOW(), - i.modifiedBy = 'stock-ledger-fix' - WHERE IFNULL(i.deleted, 0) = 0 - """ - } + ) = export.writeExportSql(from, to, out, partsRaw) } 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 new file mode 100644 index 0000000..6b97b64 --- /dev/null +++ b/src/main/java/com/ffii/fpsms/modules/master/service/ledgerfix/StockLedgerFixAdjService.kt @@ -0,0 +1,613 @@ +package com.ffii.fpsms.modules.master.service.ledgerfix + +import com.ffii.fpsms.modules.master.web.models.StockLedgerFixAdjPreview +import com.ffii.fpsms.modules.master.web.models.StockLedgerFixAdjResponse +import com.ffii.fpsms.modules.master.web.models.StockLedgerFixAdjRow +import org.slf4j.LoggerFactory +import org.springframework.jdbc.core.namedparam.MapSqlParameterSource +import org.springframework.jdbc.support.GeneratedKeyHolder +import org.springframework.stereotype.Service +import org.springframework.transaction.support.TransactionTemplate +import java.math.BigDecimal +import java.time.LocalDate + +@Service +open class StockLedgerFixAdjService( + private val support: StockLedgerFixSupport, + private val backfill: StockLedgerFixBackfillService, +) { + 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. + * 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 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={} 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 = 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(), + overIssueCount = over.size, + sumOverIssue = sumOver.stripTrailingZeros().toPlainString(), + rows = rows.map { it.toRow() }, + ) + } + + open fun applyAdjAlign(adjDateRaw: String? = null): StockLedgerFixAdjResponse { + if (!support.tryLock(StockLedgerFixSupport.RUN_LOCK)) { + throw IllegalStateException("fix already running") + } + 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() } + var insertedIn = 0 + var insertedOut = 0 + var overIssuePatched = 0 + if (work.isNotEmpty()) { + val insTx = TransactionTemplate(support.transactionManager) + insTx.timeout = 300 + insTx.execute { + 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, 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++ + } + } + } + } + val lotIds = linkedSetOf() + fun addLot(lotId: Long, invId: Long?) { + if (lotId > 0) lotIds.add(lotId) + } + work.forEach { addLot(it.lotLineId, it.inventoryId) } + support.jdbcDao.queryForList( + """ + SELECT DISTINCT inventoryLotLineId AS lotId, inventoryId AS invId + FROM stock_ledger + WHERE deleted = 0 + AND date >= :adjDate AND date < :asOf + AND type = 'ADJ' + AND createdBy = 'stock-ledger-fix' + """.trimIndent(), + 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 range = if (lotIds.isEmpty()) null else backfill.dateRangeForFilter( + "inventoryLotLineId IN (:lotIds)", + mapOf("today" to asOfExclusive, "lotIds" to lotIds.toList()), + ) + val dayRows = if (range != null && lotIds.isNotEmpty()) { + 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, + (System.nanoTime() - t0) / 1_000_000, + ) + return StockLedgerFixAdjResponse( + adjDate = adjDate.toString(), + insertedIn = insertedIn, + insertedOut = insertedOut, + overIssuePatched = overIssuePatched, + filledLotQty = filledLotQty, + filledBalance = filledBalance, + dayRowsWritten = dayRows, + ) + } catch (e: Exception) { + log.warn("stock-ledger-fix adj FAILED after {}ms: {}", (System.nanoTime() - t0) / 1_000_000, e.message) + throw e + } finally { + support.unlock(StockLedgerFixSupport.RUN_LOCK) + } + } + + /** + * ADJ window: ledger date = [adjDate], gap sums use `date < adjDate+1`. + * Default adjDate = yesterday. Pass today for freeze-night dump (e.g. 31/8). + */ + private fun resolveAdjWindow(adjDateRaw: String?): Pair { + val today = LocalDate.now() + val adjDate = if (adjDateRaw.isNullOrBlank()) { + today.minusDays(1) + } else { + support.parseDay(adjDateRaw) + } + if (adjDate.isAfter(today)) { + throw IllegalArgumentException("adjDate cannot be in the future") + } + 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 + ) + """.trimIndent(), + ) + 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(), + ) + 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), + ) + } + } + 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 + """.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() + } + + 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, + "itemCode" to g.itemCode, + "inQty" to inQty, + "outQty" to outQty, + "uomId" to g.uomId, + ) + support.jdbcDao.executeUpdate( + """ + INSERT INTO stock_ledger ( + created, createdBy, version, modified, modifiedBy, deleted, + date, stockInLineId, stockOutLineId, inventoryLotLineId, inventoryId, + itemId, itemCode, inQty, outQty, balance, lotQtyBefore, lotQtyAfter, + uomId, type + ) VALUES ( + NOW(), 'stock-ledger-fix', 0, NOW(), 'stock-ledger-fix', 0, + :adjDate, :silId, :solId, :lotLineId, :inventoryId, + :itemId, :itemCode, :inQty, :outQty, NULL, NULL, NULL, + :uomId, 'ADJ' + ) + """.trimIndent(), + args, + ) + } + + /** 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" + + 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 = support.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 support.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 = support.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 support.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 = support.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 = support.toLongOrNull(row["inventoryLotId"]), + lotNo = row["lotNo"]?.toString(), + ) + } + + private fun insertReturningId(sql: String, args: Map): Long { + val keyHolder = GeneratedKeyHolder() + support.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 data class AdjGap( + val lotLineId: Long, + val inventoryId: Long?, + val itemId: Long?, + val itemCode: String?, + val uomId: Long?, + val lineIn: BigDecimal, + val lineOut: BigDecimal, + val ledgerIn: BigDecimal, + val ledgerOut: BigDecimal, + 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, + itemCode = itemCode, + lineIn = lineIn.stripTrailingZeros().toPlainString(), + lineOut = lineOut.stripTrailingZeros().toPlainString(), + ledgerIn = ledgerIn.stripTrailingZeros().toPlainString(), + ledgerOut = ledgerOut.stripTrailingZeros().toPlainString(), + missIn = missIn.stripTrailingZeros().toPlainString(), + missOut = missOut.stripTrailingZeros().toPlainString(), + overIssue = overIssue.stripTrailingZeros().toPlainString(), + ) + } + + 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 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 new file mode 100644 index 0000000..e6ffce3 --- /dev/null +++ b/src/main/java/com/ffii/fpsms/modules/master/service/ledgerfix/StockLedgerFixBackfillService.kt @@ -0,0 +1,1403 @@ +package com.ffii.fpsms.modules.master.service.ledgerfix + +import com.ffii.fpsms.modules.master.web.models.StockLedgerFixInventoryResponse +import com.ffii.fpsms.modules.master.web.models.StockLedgerFixRunResponse +import com.ffii.fpsms.modules.stock.service.StockLotDayCloseService +import org.slf4j.LoggerFactory +import org.springframework.stereotype.Service +import org.springframework.transaction.annotation.Transactional +import org.springframework.transaction.support.TransactionTemplate +import java.math.BigDecimal +import java.time.LocalDate + +@Service +open class StockLedgerFixBackfillService( + private val support: StockLedgerFixSupport, + private val stockLotDayCloseService: StockLotDayCloseService, +) { + private val log = LoggerFactory.getLogger(StockLedgerFixBackfillService::class.java) + + @Transactional(timeout = 300) + open fun fixInventory10(): StockLedgerFixInventoryResponse { + if (!support.tryLock(StockLedgerFixSupport.INVENTORY_LOCK)) { + throw IllegalStateException("inventory 1.0 already running") + } + try { + val patchedFromUom = support.jdbcDao.executeUpdate(StockLedgerFixInventorySql.BACKFILL_STOCK_UOM_WHEN_UOM_MATCHES_LOT_SQL) + val patchedSingleLot = support.jdbcDao.executeUpdate(StockLedgerFixInventorySql.BACKFILL_STOCK_UOM_SINGLE_LOT_SQL) + val patchedStockUomId = patchedFromUom + patchedSingleLot + val inserted = support.jdbcDao.executeUpdate(StockLedgerFixInventorySql.INSERT_MISSING_INVENTORY_SQL) + val orphansDeleted = support.jdbcDao.executeUpdate(StockLedgerFixInventorySql.SOFT_DELETE_ORPHAN_NULL_STOCK_UOM_SQL) + val updated = support.jdbcDao.executeUpdate(StockLedgerFixInventorySql.UPDATE_INVENTORY_QTY_SQL) + val missingAfter = support.toInt( + support.jdbcDao.queryForList(StockLedgerFixInventorySql.MISSING_UOM_PAIR_COUNT_SQL).firstOrNull()?.get("c"), + ) + val nullStockUomIdAfter = support.toInt( + support.jdbcDao.queryForList(StockLedgerFixInventorySql.NULL_STOCK_UOM_COUNT_SQL).firstOrNull()?.get("c"), + ) + log.info( + "stock-ledger-fix inventory-1.0 patchedStockUomId={} inserted={} orphansDeleted={} updated={} missingAfter={} nullStockUomIdAfter={}", + patchedStockUomId, + inserted, + orphansDeleted, + updated, + missingAfter, + nullStockUomIdAfter, + ) + return StockLedgerFixInventoryResponse( + inserted = inserted, + updated = updated, + missingUomPairsAfter = missingAfter, + patchedStockUomId = patchedStockUomId, + nullStockUomIdAfter = nullStockUomIdAfter, + orphansDeleted = orphansDeleted, + ) + } finally { + support.unlock(StockLedgerFixSupport.INVENTORY_LOCK) + } + } + + /** + * 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 (!support.tryLock(StockLedgerFixSupport.RUN_LOCK)) throw IllegalStateException("fix already running") + val toExclusive = support.inventoryScopeToExclusive() + val t0 = System.nanoTime() + try { + val inv = support.jdbcDao.queryForList( + """ + SELECT id, itemId, uomId FROM inventory WHERE id = :inventoryId + """.trimIndent(), + mapOf("inventoryId" to inventoryId), + ).firstOrNull() ?: throw IllegalArgumentException("inventory not found: $inventoryId") + val args = mapOf( + "inventoryId" to inventoryId, + "itemId" to (inv["itemId"] ?: 0), + "uomId" to (inv["uomId"] ?: 0), + "today" to toExclusive, + ) + val filledLotLineId = support.timed("inv 2.1") { fillLotLineIdScope(args, byInventory = true) } + val filledUomId = support.timed("inv 2.2") { fillUomIdScope(args, byInventory = true) } + val filledInventoryId = support.timed("inv 2.3") { fillInventoryIdScope(args, byInventory = true) } + val filledLotQty = support.timed("inv 2.4") { fillLotQtyHistory(args, byInventory = true) } + val filledBalance = support.timed("inv 2.5") { fillBalanceHistory(args) } + val lotIds = lotIdsForInventory(inventoryId, toExclusive) + val range = dateRangeForFilter("inventoryId = :inventoryId", mapOf("inventoryId" to inventoryId, "today" to toExclusive)) + val dayRows = if (range != null) { + support.timed("inv 2.6") { upsertStockLotDayRange(lotIds, range.first, range.second) } + } else 0 + 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() ?: LocalDate.now().toString(), + filledLotLineId = filledLotLineId, + filledUomId = filledUomId, + filledInventoryId = filledInventoryId, + filledLotQty = filledLotQty, + filledBalance = filledBalance, + dayRowsWritten = dayRows, + stillMissLot = still.missLot, + stillMissUom = still.missUom, + stillMissInventoryId = still.missInventoryId, + stillMissLotQty = still.missLotQty, + ) + } catch (e: Exception) { + log.warn("stock-ledger-fix inventory {} FAILED after {}ms: {}", inventoryId, (System.nanoTime() - t0) / 1_000_000, e.message) + throw e + } finally { + support.unlock(StockLedgerFixSupport.RUN_LOCK) + } + } + + open fun fixLotScope(lotLineId: Long): StockLedgerFixRunResponse { + if (lotLineId <= 0) throw IllegalArgumentException("lotLineId is required") + if (!support.tryLock(StockLedgerFixSupport.RUN_LOCK)) throw IllegalStateException("fix already running") + val today = LocalDate.now() + val t0 = System.nanoTime() + try { + val exists = support.toInt( + support.jdbcDao.queryForList( + "SELECT COUNT(*) AS c FROM inventory_lot_line WHERE id = :lotId", + mapOf("lotId" to lotLineId), + ).firstOrNull()?.get("c"), + ) + if (exists == 0) throw IllegalArgumentException("lot line not found: $lotLineId") + val args = mapOf("lotId" to lotLineId, "today" to today) + val filledLotLineId = support.timed("lot 2.1") { fillLotLineIdScope(args, byInventory = false) } + val filledUomId = support.timed("lot 2.2") { fillUomIdScope(args, byInventory = false) } + val filledInventoryId = support.timed("lot 2.3") { fillInventoryIdScope(args, byInventory = false) } + val filledLotQty = support.timed("lot 2.4") { fillLotQtyHistory(args, byInventory = false) } + val range = dateRangeForFilter("inventoryLotLineId = :lotId", args) + val dayRows = if (range != null) { + support.timed("lot 2.6") { upsertStockLotDayRange(listOf(lotLineId), range.first, range.second) } + } else 0 + val still = stillMissScope("inventoryLotLineId = :lotId", args) + log.info( + "stock-ledger-fix lot {} lotLine={} uom={} inv={} lotQty={} dayRows={} total={}ms", + lotLineId, filledLotLineId, filledUomId, filledInventoryId, filledLotQty, dayRows, + (System.nanoTime() - t0) / 1_000_000, + ) + return StockLedgerFixRunResponse( + date = range?.first?.toString() ?: today.toString(), + filledLotLineId = filledLotLineId, + filledUomId = filledUomId, + filledInventoryId = filledInventoryId, + filledLotQty = filledLotQty, + filledBalance = 0, + dayRowsWritten = dayRows, + stillMissLot = still.missLot, + stillMissUom = still.missUom, + stillMissInventoryId = still.missInventoryId, + stillMissLotQty = still.missLotQty, + ) + } catch (e: Exception) { + log.warn("stock-ledger-fix lot {} FAILED after {}ms: {}", lotLineId, (System.nanoTime() - t0) / 1_000_000, e.message) + throw e + } finally { + support.unlock(StockLedgerFixSupport.RUN_LOCK) + } + } + + /** + * No wrapping transaction: each step auto-commits so a later timeout + * (typically 2.5) does not roll back 2.1–2.4 lot fills. + * [stepsRaw] empty / all = 2.1–2.6; otherwise only those keys (e.g. 2.3). + */ + open fun fixDay(dateRaw: String, stepsRaw: List? = null): StockLedgerFixRunResponse { + val day = support.parseDay(dateRaw) + // Allow today (freeze-night dump fix); reject future only. + if (day.isAfter(LocalDate.now())) { + throw IllegalArgumentException("cannot fix future dates") + } + val key = day.toString() + val steps = support.parseDaySteps(stepsRaw) + if (!support.tryLock(StockLedgerFixSupport.RUN_LOCK)) { + throw IllegalStateException("fix already running") + } + val t0 = System.nanoTime() + try { + val args = support.dayArgs(day) + val filledLotLineId = if ("2.1" in steps) support.timed("2.1 fillLotLineId") { fillLotLineId(args) } else 0 + val filledUomId = if ("2.2" in steps) support.timed("2.2 fillUomId") { fillUomId(args) } else 0 + val filledInventoryId = if ("2.3" in steps) support.timed("2.3 fillInventoryId") { fillInventoryId(args) } else 0 + val filledLotQty = if ("2.4" in steps) support.timed("2.4 fillLotQty") { fillLotQty(args) } else 0 + val filledBalance = if ("2.5" in steps) support.timed("2.5 fillBalance") { fillBalance(args) } else 0 + val dayRowsWritten = if ("2.6" in steps) { + support.timed("2.6 upsertStockLotDay") { + stockLotDayCloseService.upsertDayForFix(day).rowsWritten + } + } else { + 0 + } + val still = stillMiss(args) + log.info( + "stock-ledger-fix {} steps={} lotLine={} uom={} inv={} lotQty={} bal={} dayRows={} total={}ms", + key, steps.joinToString(","), filledLotLineId, filledUomId, filledInventoryId, + filledLotQty, filledBalance, dayRowsWritten, + (System.nanoTime() - t0) / 1_000_000, + ) + return StockLedgerFixRunResponse( + date = key, + filledLotLineId = filledLotLineId, + filledUomId = filledUomId, + filledInventoryId = filledInventoryId, + filledLotQty = filledLotQty, + filledBalance = filledBalance, + dayRowsWritten = dayRowsWritten, + stillMissLot = still.missLot, + stillMissUom = still.missUom, + stillMissInventoryId = still.missInventoryId, + stillMissLotQty = still.missLotQty, + ) + } catch (e: Exception) { + log.warn( + "stock-ledger-fix {} FAILED after {}ms: {}", + key, (System.nanoTime() - t0) / 1_000_000, e.message, + ) + throw e + } finally { + support.unlock(StockLedgerFixSupport.RUN_LOCK) + } + } + + /** + * 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 = support.parseDay(fromRaw) + val to = support.parseDay(toRaw) + if (to.isBefore(from)) { + throw IllegalArgumentException("to must be on or after from") + } + if (from.plusDays(400).isBefore(to)) { + throw IllegalArgumentException("date range must be at most 400 days") + } + if (to.isAfter(LocalDate.now())) { + throw IllegalArgumentException("cannot fix future dates") + } + val key = "$from..$to" + val steps = support.parseDaySteps(stepsRaw) + if (!support.tryLock(StockLedgerFixSupport.RUN_LOCK)) { + throw IllegalStateException("fix already running") + } + val t0 = System.nanoTime() + try { + 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 = support.dayArgs(d) + if ("2.1" in steps) filledLotLineId += support.timed("range $d 2.1") { fillLotLineId(args) } + if ("2.2" in steps) filledUomId += support.timed("range $d 2.2") { fillUomId(args) } + if ("2.3" in steps) filledInventoryId += support.timed("range $d 2.3") { fillInventoryId(args) } + if ("2.4" in steps) filledLotQty += support.timed("range $d 2.4") { fillLotQty(args) } + if ("2.5" in steps) filledBalance += support.timed("range $d 2.5") { fillBalance(args) } + if ("2.6" in steps) { + dayRowsWritten += support.timed("range $d 2.6") { + stockLotDayCloseService.upsertDayForFix(d).rowsWritten + } + } + d = d.plusDays(1) + } + val still = stillMiss(support.rangeArgs(from, to)) + log.info( + "stock-ledger-fix {} steps={} lotLine={} uom={} inv={} lotQty={} bal={} dayRows={} total={}ms", + key, steps.joinToString(","), filledLotLineId, filledUomId, filledInventoryId, + filledLotQty, filledBalance, dayRowsWritten, + (System.nanoTime() - t0) / 1_000_000, + ) + return StockLedgerFixRunResponse( + date = key, + filledLotLineId = filledLotLineId, + filledUomId = filledUomId, + filledInventoryId = filledInventoryId, + filledLotQty = filledLotQty, + filledBalance = filledBalance, + dayRowsWritten = dayRowsWritten, + stillMissLot = still.missLot, + stillMissUom = still.missUom, + stillMissInventoryId = still.missInventoryId, + stillMissLotQty = still.missLotQty, + ) + } catch (e: Exception) { + log.warn( + "stock-ledger-fix {} FAILED after {}ms: {}", + key, (System.nanoTime() - t0) / 1_000_000, e.message, + ) + throw e + } finally { + support.unlock(StockLedgerFixSupport.RUN_LOCK) + } + } + + /** 2.1 SOL / SIL lot line; one-line fallback when SIL has lot header only. */ + private fun fillLotLineId(args: Map): Int { + val fromSolSil = support.timed("2.1a solSil") { + support.jdbcDao.executeUpdate( + """ + UPDATE stock_ledger sl + LEFT JOIN stock_out_line sol + ON sol.id = sl.stockOutLineId AND IFNULL(sol.deleted, 0) = 0 + LEFT JOIN stock_in_line sil + ON sil.id = sl.stockInLineId AND IFNULL(sil.deleted, 0) = 0 + SET sl.inventoryLotLineId = COALESCE(sol.inventoryLotLineId, sil.inventoryLotLineId) + WHERE sl.deleted = 0 + AND sl.date >= :day AND sl.date < :dayNext + AND sl.inventoryLotLineId IS NULL + AND COALESCE(sol.inventoryLotLineId, sil.inventoryLotLineId) IS NOT NULL + """.trimIndent(), + args, + ) + } + val fromOneLine = support.timed("2.1b oneLine") { + support.jdbcDao.executeUpdate( + """ + UPDATE stock_ledger sl + INNER JOIN stock_in_line sil + ON sil.id = sl.stockInLineId AND IFNULL(sil.deleted, 0) = 0 + INNER JOIN ( + SELECT ill.inventoryLotId, MIN(ill.id) AS onlyLineId + FROM inventory_lot_line ill + INNER JOIN ( + SELECT DISTINCT sil2.inventoryLotId + FROM stock_ledger slx + INNER JOIN stock_in_line sil2 + ON sil2.id = slx.stockInLineId AND IFNULL(sil2.deleted, 0) = 0 + WHERE slx.deleted = 0 + AND slx.date >= :day AND slx.date < :dayNext + AND slx.inventoryLotLineId IS NULL + AND sil2.inventoryLotLineId IS NULL + AND sil2.inventoryLotId IS NOT NULL + ) hdr ON hdr.inventoryLotId = ill.inventoryLotId + WHERE IFNULL(ill.deleted, 0) = 0 + GROUP BY ill.inventoryLotId + HAVING COUNT(*) = 1 + ) one ON one.inventoryLotId = sil.inventoryLotId + SET sl.inventoryLotLineId = one.onlyLineId + WHERE sl.deleted = 0 + AND sl.date >= :day AND sl.date < :dayNext + AND sl.inventoryLotLineId IS NULL + AND sil.inventoryLotLineId IS NULL + AND sil.inventoryLotId IS NOT NULL + """.trimIndent(), + args, + ) + } + return fromSolSil + fromOneLine + } + + /** 2.2 lot line stock UOM → uom_conversion id. Include deleted item_uom (historical lots still point there). */ + private fun fillUomId(args: Map): Int { + return support.jdbcDao.executeUpdate( + """ + UPDATE stock_ledger sl + INNER JOIN inventory_lot_line ill + ON ill.id = sl.inventoryLotLineId AND IFNULL(ill.deleted, 0) = 0 + INNER JOIN item_uom iu + ON iu.id = ill.stockItemUomId + SET sl.uomId = iu.uomId + WHERE sl.deleted = 0 + AND sl.date >= :day AND sl.date < :dayNext + AND sl.inventoryLotLineId IS NOT NULL + """.trimIndent(), + args, + ) + } + + /** + * 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 applyInventoryIdUpdates( + args, + extraJoin = "", + extraWhere = "sl.date >= :day AND sl.date < :dayNext", + ) + } + + private fun applyInventoryIdUpdates( + args: Map, + extraJoin: String, + extraWhere: String, + ): Int { + val byStockUom = support.jdbcDao.executeUpdate( + """ + UPDATE stock_ledger sl + $extraJoin + INNER JOIN ( + 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 = support.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.itemId IS NOT NULL + AND sl.uomId IS NOT NULL + AND sl.inventoryId IS NULL + AND ($extraWhere) + """.trimIndent(), + args, + ) + return byStockUom + byLegacyUom + } + + /** + * 2.4 Opening = last lotQtyAfter before the window start (yesterday first, then lookback). + * Idle days must not reset to 0. Window is :day .. :dayNext (one day or a range). + */ + private fun fillLotQty(args: Map): Int { + return support.withTempTx { + support.jdbcDao.executeUpdate("DROP TEMPORARY TABLE IF EXISTS tmp_sl_fix_lot_today") + support.jdbcDao.executeUpdate("DROP TEMPORARY TABLE IF EXISTS tmp_sl_fix_lot_prev") + support.jdbcDao.executeUpdate("DROP TEMPORARY TABLE IF EXISTS tmp_sl_fix_lot_miss") + support.jdbcDao.executeUpdate("DROP TEMPORARY TABLE IF EXISTS tmp_sl_fix_lot_qty") + support.jdbcDao.executeUpdate( + """ + CREATE TEMPORARY TABLE tmp_sl_fix_lot_today ( + inventoryLotLineId BIGINT NOT NULL PRIMARY KEY + ) + """.trimIndent(), + ) + support.jdbcDao.executeUpdate( + """ + CREATE TEMPORARY TABLE tmp_sl_fix_lot_miss ( + inventoryLotLineId BIGINT NOT NULL PRIMARY KEY + ) + """.trimIndent(), + ) + support.jdbcDao.executeUpdate( + """ + CREATE TEMPORARY TABLE tmp_sl_fix_lot_prev ( + inventoryLotLineId BIGINT NOT NULL PRIMARY KEY, + prevAfter DECIMAL(14,2) NOT NULL + ) + """.trimIndent(), + ) + support.jdbcDao.executeUpdate( + """ + CREATE TEMPORARY TABLE tmp_sl_fix_lot_qty ( + id INT NOT NULL PRIMARY KEY, + lotAfter DECIMAL(14,2) NOT NULL, + delta DECIMAL(14,2) NOT NULL + ) + """.trimIndent(), + ) + try { + support.jdbcDao.executeUpdate( + """ + INSERT INTO tmp_sl_fix_lot_today (inventoryLotLineId) + SELECT DISTINCT inventoryLotLineId + FROM stock_ledger + WHERE deleted = 0 + AND date >= :day AND date < :dayNext + AND inventoryLotLineId IS NOT NULL + """.trimIndent(), + args, + ) + support.timed("2.4a1 yesterday") { + support.jdbcDao.executeUpdate( + """ + INSERT INTO tmp_sl_fix_lot_prev (inventoryLotLineId, prevAfter) + SELECT slp.inventoryLotLineId, slp.lotQtyAfter + FROM stock_ledger slp + INNER JOIN ( + SELECT sl.inventoryLotLineId, MAX(sl.id) AS maxId + FROM stock_ledger sl + INNER JOIN tmp_sl_fix_lot_today t + ON t.inventoryLotLineId = sl.inventoryLotLineId + WHERE sl.deleted = 0 + AND sl.date >= :prevDay AND sl.date < :day + AND sl.lotQtyAfter IS NOT NULL + GROUP BY sl.inventoryLotLineId + ) m ON m.maxId = slp.id + """.trimIndent(), + args, + ) + } + support.timed("2.4a2 lookback") { + support.jdbcDao.executeUpdate( + """ + INSERT INTO tmp_sl_fix_lot_miss (inventoryLotLineId) + SELECT t.inventoryLotLineId + FROM tmp_sl_fix_lot_today t + LEFT JOIN tmp_sl_fix_lot_prev p + ON p.inventoryLotLineId = t.inventoryLotLineId + WHERE p.inventoryLotLineId IS NULL + """.trimIndent(), + ) + support.jdbcDao.executeUpdate( + """ + INSERT INTO tmp_sl_fix_lot_prev (inventoryLotLineId, prevAfter) + SELECT slp.inventoryLotLineId, slp.lotQtyAfter + FROM stock_ledger slp + INNER JOIN ( + SELECT sl.inventoryLotLineId, MAX(sl.id) AS maxId + FROM stock_ledger sl + WHERE sl.deleted = 0 + AND sl.date < :prevDay + AND sl.lotQtyAfter IS NOT NULL + AND sl.inventoryLotLineId IN ( + SELECT inventoryLotLineId FROM tmp_sl_fix_lot_miss + ) + GROUP BY sl.inventoryLotLineId + ) x ON x.maxId = slp.id + """.trimIndent(), + args, + ) + } + support.timed("2.4b window") { + support.jdbcDao.executeUpdate( + """ + INSERT INTO tmp_sl_fix_lot_qty (id, lotAfter, delta) + SELECT + sl2.id, + CAST( + COALESCE(p.prevAfter, 0) + + SUM( + CAST(COALESCE(sl2.inQty, 0) AS DECIMAL(14,2)) + - CAST(COALESCE(sl2.outQty, 0) AS DECIMAL(14,2)) + ) OVER ( + PARTITION BY sl2.inventoryLotLineId + ORDER BY sl2.date, sl2.id + ROWS BETWEEN UNBOUNDED PRECEDING AND CURRENT ROW + ) + AS DECIMAL(14,2)) AS lotAfter, + CAST(COALESCE(sl2.inQty, 0) AS DECIMAL(14,2)) + - CAST(COALESCE(sl2.outQty, 0) AS DECIMAL(14,2)) AS delta + FROM stock_ledger sl2 + LEFT JOIN tmp_sl_fix_lot_prev p + ON p.inventoryLotLineId = sl2.inventoryLotLineId + WHERE sl2.deleted = 0 + AND sl2.date >= :day AND sl2.date < :dayNext + AND sl2.inventoryLotLineId IS NOT NULL + """.trimIndent(), + args, + ) + } + support.timed("2.4c apply") { + support.jdbcDao.executeUpdate( + """ + UPDATE stock_ledger sl + INNER JOIN tmp_sl_fix_lot_qty t ON t.id = sl.id + SET sl.lotQtyAfter = t.lotAfter, + sl.lotQtyBefore = t.lotAfter - t.delta + """.trimIndent(), + ) + } + } finally { + support.jdbcDao.executeUpdate("DROP TEMPORARY TABLE IF EXISTS tmp_sl_fix_lot_qty") + support.jdbcDao.executeUpdate("DROP TEMPORARY TABLE IF EXISTS tmp_sl_fix_lot_prev") + support.jdbcDao.executeUpdate("DROP TEMPORARY TABLE IF EXISTS tmp_sl_fix_lot_today") + support.jdbcDao.executeUpdate("DROP TEMPORARY TABLE IF EXISTS tmp_sl_fix_lot_miss") + } + } + } + + /** + * 2.5 Running balance per inventoryId. + * Opening = last balance before the window. Fast path: day before :day. Lookback only for + * inventoryIds that had no row that day (idle days must not reset to 0). + * Window is :day .. :dayNext (one day or a range). + */ + private fun fillBalance(args: Map): Int { + val tx = TransactionTemplate(support.transactionManager) + return tx.execute { + support.jdbcDao.executeUpdate("DROP TEMPORARY TABLE IF EXISTS tmp_stock_ledger_fix_bal") + support.jdbcDao.executeUpdate("DROP TEMPORARY TABLE IF EXISTS tmp_stock_ledger_fix_prev") + support.jdbcDao.executeUpdate("DROP TEMPORARY TABLE IF EXISTS tmp_stock_ledger_fix_today") + support.jdbcDao.executeUpdate("DROP TEMPORARY TABLE IF EXISTS tmp_stock_ledger_fix_miss") + support.jdbcDao.executeUpdate( + """ + CREATE TEMPORARY TABLE tmp_stock_ledger_fix_today ( + inventoryId INT NOT NULL PRIMARY KEY + ) + """.trimIndent(), + ) + support.jdbcDao.executeUpdate( + """ + CREATE TEMPORARY TABLE tmp_stock_ledger_fix_miss ( + inventoryId INT NOT NULL PRIMARY KEY + ) + """.trimIndent(), + ) + support.jdbcDao.executeUpdate( + """ + CREATE TEMPORARY TABLE tmp_stock_ledger_fix_prev ( + inventoryId INT NOT NULL PRIMARY KEY, + prevBalance DECIMAL(14,2) NOT NULL + ) + """.trimIndent(), + ) + support.jdbcDao.executeUpdate( + """ + CREATE TEMPORARY TABLE tmp_stock_ledger_fix_bal ( + id INT NOT NULL PRIMARY KEY, + newBalance DECIMAL(14,2) NOT NULL + ) + """.trimIndent(), + ) + try { + support.jdbcDao.executeUpdate( + """ + INSERT INTO tmp_stock_ledger_fix_today (inventoryId) + SELECT DISTINCT inventoryId + FROM stock_ledger + WHERE deleted = 0 + AND date >= :day AND date < :dayNext + AND inventoryId IS NOT NULL + """.trimIndent(), + args, + ) + support.timed("2.5a1 yesterday") { + support.jdbcDao.executeUpdate( + """ + INSERT INTO tmp_stock_ledger_fix_prev (inventoryId, prevBalance) + SELECT slp.inventoryId, slp.balance + FROM stock_ledger slp + INNER JOIN ( + SELECT sl.inventoryId, MAX(sl.id) AS maxId + FROM stock_ledger sl + INNER JOIN tmp_stock_ledger_fix_today t + ON t.inventoryId = sl.inventoryId + WHERE sl.deleted = 0 + AND sl.date >= :prevDay AND sl.date < :day + AND sl.balance IS NOT NULL + GROUP BY sl.inventoryId + ) m ON m.maxId = slp.id + """.trimIndent(), + args, + ) + } + support.timed("2.5a2 lookback") { + support.jdbcDao.executeUpdate( + """ + INSERT INTO tmp_stock_ledger_fix_miss (inventoryId) + SELECT t.inventoryId + FROM tmp_stock_ledger_fix_today t + LEFT JOIN tmp_stock_ledger_fix_prev p + ON p.inventoryId = t.inventoryId + WHERE p.inventoryId IS NULL + """.trimIndent(), + ) + support.jdbcDao.executeUpdate( + """ + INSERT INTO tmp_stock_ledger_fix_prev (inventoryId, prevBalance) + SELECT slp.inventoryId, slp.balance + FROM stock_ledger slp + INNER JOIN ( + SELECT sl.inventoryId, MAX(sl.id) AS maxId + FROM stock_ledger sl + WHERE sl.deleted = 0 + AND sl.date < :prevDay + AND sl.balance IS NOT NULL + AND sl.inventoryId IN ( + SELECT inventoryId FROM tmp_stock_ledger_fix_miss + ) + GROUP BY sl.inventoryId + ) x ON x.maxId = slp.id + """.trimIndent(), + args, + ) + } + support.timed("2.5b window") { + support.jdbcDao.executeUpdate( + """ + INSERT INTO tmp_stock_ledger_fix_bal (id, newBalance) + SELECT + sl2.id, + CAST( + COALESCE(p.prevBalance, 0) + + 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 + LEFT JOIN tmp_stock_ledger_fix_prev p + ON p.inventoryId = sl2.inventoryId + WHERE sl2.deleted = 0 + AND sl2.date >= :day AND sl2.date < :dayNext + AND sl2.inventoryId IS NOT NULL + """.trimIndent(), + args, + ) + } + support.timed("2.5c apply") { + support.jdbcDao.executeUpdate( + """ + UPDATE stock_ledger sl + INNER JOIN tmp_stock_ledger_fix_bal t ON t.id = sl.id + SET sl.balance = t.newBalance + """.trimIndent(), + ) + } + } finally { + support.jdbcDao.executeUpdate("DROP TEMPORARY TABLE IF EXISTS tmp_stock_ledger_fix_bal") + support.jdbcDao.executeUpdate("DROP TEMPORARY TABLE IF EXISTS tmp_stock_ledger_fix_prev") + support.jdbcDao.executeUpdate("DROP TEMPORARY TABLE IF EXISTS tmp_stock_ledger_fix_today") + support.jdbcDao.executeUpdate("DROP TEMPORARY TABLE IF EXISTS tmp_stock_ledger_fix_miss") + } + } ?: 0 + } + + private fun fillLotLineIdScope(args: Map, byInventory: Boolean): Int { + if (byInventory) return fillLotLineIdScopeByInventory(args) + val lotFilter = """ + AND ( + COALESCE(sol.inventoryLotLineId, sil.inventoryLotLineId) = :lotId + OR sil.inventoryLotId IN (SELECT inventoryLotId FROM inventory_lot_line WHERE id = :lotId) + ) + """.trimIndent() + val fromSolSil = support.jdbcDao.executeUpdate( + """ + UPDATE stock_ledger sl + LEFT JOIN stock_out_line sol + ON sol.id = sl.stockOutLineId AND IFNULL(sol.deleted, 0) = 0 + LEFT JOIN stock_in_line sil + ON sil.id = sl.stockInLineId AND IFNULL(sil.deleted, 0) = 0 + SET sl.inventoryLotLineId = COALESCE(sol.inventoryLotLineId, sil.inventoryLotLineId) + WHERE sl.deleted = 0 + AND sl.date < :today + AND sl.inventoryLotLineId IS NULL + AND COALESCE(sol.inventoryLotLineId, sil.inventoryLotLineId) IS NOT NULL + $lotFilter + """.trimIndent(), + args, + ) + val fromOneLine = support.timed("lot 2.1b oneLine") { + support.jdbcDao.executeUpdate( + """ + UPDATE stock_ledger sl + INNER JOIN stock_in_line sil + ON sil.id = sl.stockInLineId AND IFNULL(sil.deleted, 0) = 0 + INNER JOIN ( + SELECT ill.inventoryLotId, MIN(ill.id) AS onlyLineId + FROM inventory_lot_line ill + INNER JOIN ( + SELECT DISTINCT sil2.inventoryLotId + FROM stock_ledger slx + INNER JOIN stock_in_line sil2 + ON sil2.id = slx.stockInLineId AND IFNULL(sil2.deleted, 0) = 0 + WHERE slx.deleted = 0 + AND slx.date < :today + AND slx.inventoryLotLineId IS NULL + AND sil2.inventoryLotLineId IS NULL + AND sil2.inventoryLotId IS NOT NULL + AND sil2.inventoryLotId IN (SELECT inventoryLotId FROM inventory_lot_line WHERE id = :lotId) + ) hdr ON hdr.inventoryLotId = ill.inventoryLotId + WHERE IFNULL(ill.deleted, 0) = 0 + GROUP BY ill.inventoryLotId + HAVING COUNT(*) = 1 + ) one ON one.inventoryLotId = sil.inventoryLotId + SET sl.inventoryLotLineId = one.onlyLineId + WHERE sl.deleted = 0 + AND sl.date < :today + AND sl.inventoryLotLineId IS NULL + AND sil.inventoryLotLineId IS NULL + AND sil.inventoryLotId IS NOT NULL + AND sil.inventoryLotId IN (SELECT inventoryLotId FROM inventory_lot_line WHERE id = :lotId) + """.trimIndent(), + args, + ) + } + return fromSolSil + fromOneLine + } + + /** Collect candidate ledger ids first — do not UPDATE with date < today OR …. */ + private fun fillLotLineIdScopeByInventory(args: Map): Int { + return support.withTempTx { + support.jdbcDao.executeUpdate("DROP TEMPORARY TABLE IF EXISTS tmp_sl_fix_lot_ids") + support.jdbcDao.executeUpdate("DROP TEMPORARY TABLE IF EXISTS tmp_sl_fix_sil_lots") + support.jdbcDao.executeUpdate("DROP TEMPORARY TABLE IF EXISTS tmp_sl_fix_one_line") + support.jdbcDao.executeUpdate( + """ + CREATE TEMPORARY TABLE tmp_sl_fix_lot_ids ( + id INT NOT NULL PRIMARY KEY + ) + """.trimIndent(), + ) + support.jdbcDao.executeUpdate( + """ + CREATE TEMPORARY TABLE tmp_sl_fix_sil_lots ( + inventoryLotId BIGINT NOT NULL PRIMARY KEY + ) + """.trimIndent(), + ) + support.jdbcDao.executeUpdate( + """ + CREATE TEMPORARY TABLE tmp_sl_fix_one_line ( + inventoryLotId BIGINT NOT NULL PRIMARY KEY, + onlyLineId BIGINT NOT NULL + ) + """.trimIndent(), + ) + try { + support.jdbcDao.executeUpdate( + """ + INSERT INTO tmp_sl_fix_lot_ids (id) + SELECT sl.id + FROM stock_ledger sl + WHERE sl.deleted = 0 + AND sl.date < :today + AND sl.inventoryLotLineId IS NULL + AND sl.inventoryId = :inventoryId + """.trimIndent(), + args, + ) + support.jdbcDao.executeUpdate( + """ + INSERT IGNORE INTO tmp_sl_fix_lot_ids (id) + SELECT sl.id + FROM stock_ledger sl + WHERE sl.deleted = 0 + AND sl.date < :today + AND sl.inventoryLotLineId IS NULL + AND sl.itemId = :itemId + AND (sl.uomId = :uomId OR sl.uomId IS NULL) + """.trimIndent(), + args, + ) + val fromSolSil = support.jdbcDao.executeUpdate( + """ + UPDATE stock_ledger sl + INNER JOIN tmp_sl_fix_lot_ids t ON t.id = sl.id + LEFT JOIN stock_out_line sol + ON sol.id = sl.stockOutLineId AND IFNULL(sol.deleted, 0) = 0 + LEFT JOIN stock_in_line sil + ON sil.id = sl.stockInLineId AND IFNULL(sil.deleted, 0) = 0 + SET sl.inventoryLotLineId = COALESCE(sol.inventoryLotLineId, sil.inventoryLotLineId) + WHERE sl.inventoryLotLineId IS NULL + AND COALESCE(sol.inventoryLotLineId, sil.inventoryLotLineId) IS NOT NULL + """.trimIndent(), + ) + support.jdbcDao.executeUpdate( + """ + INSERT INTO tmp_sl_fix_sil_lots (inventoryLotId) + SELECT DISTINCT sil.inventoryLotId + FROM tmp_sl_fix_lot_ids t + INNER JOIN stock_ledger sl ON sl.id = t.id + INNER JOIN stock_in_line sil + ON sil.id = sl.stockInLineId AND IFNULL(sil.deleted, 0) = 0 + WHERE sl.inventoryLotLineId IS NULL + AND sil.inventoryLotLineId IS NULL + AND sil.inventoryLotId IS NOT NULL + """.trimIndent(), + ) + support.jdbcDao.executeUpdate( + """ + INSERT INTO tmp_sl_fix_one_line (inventoryLotId, onlyLineId) + SELECT ill.inventoryLotId, MIN(ill.id) + FROM inventory_lot_line ill + INNER JOIN tmp_sl_fix_sil_lots hdr ON hdr.inventoryLotId = ill.inventoryLotId + WHERE IFNULL(ill.deleted, 0) = 0 + GROUP BY ill.inventoryLotId + HAVING COUNT(*) = 1 + """.trimIndent(), + ) + val fromOneLine = support.timed("inv 2.1b oneLine") { + support.jdbcDao.executeUpdate( + """ + UPDATE stock_ledger sl + INNER JOIN tmp_sl_fix_lot_ids t ON t.id = sl.id + INNER JOIN stock_in_line sil + ON sil.id = sl.stockInLineId AND IFNULL(sil.deleted, 0) = 0 + INNER JOIN tmp_sl_fix_one_line one ON one.inventoryLotId = sil.inventoryLotId + SET sl.inventoryLotLineId = one.onlyLineId + WHERE sl.inventoryLotLineId IS NULL + AND sil.inventoryLotLineId IS NULL + AND sil.inventoryLotId IS NOT NULL + """.trimIndent(), + ) + } + fromSolSil + fromOneLine + } finally { + support.jdbcDao.executeUpdate("DROP TEMPORARY TABLE IF EXISTS tmp_sl_fix_one_line") + support.jdbcDao.executeUpdate("DROP TEMPORARY TABLE IF EXISTS tmp_sl_fix_sil_lots") + support.jdbcDao.executeUpdate("DROP TEMPORARY TABLE IF EXISTS tmp_sl_fix_lot_ids") + } + } + } + + private fun fillUomIdScope(args: Map, byInventory: Boolean): Int { + if (!byInventory) { + return support.jdbcDao.executeUpdate( + """ + UPDATE stock_ledger sl + INNER JOIN inventory_lot_line ill + ON ill.id = sl.inventoryLotLineId AND IFNULL(ill.deleted, 0) = 0 + INNER JOIN item_uom iu + ON iu.id = ill.stockItemUomId + SET sl.uomId = iu.uomId + WHERE sl.deleted = 0 + AND sl.date < :today + AND sl.inventoryLotLineId = :lotId + """.trimIndent(), + args, + ) + } + return support.withTempTx { + support.jdbcDao.executeUpdate("DROP TEMPORARY TABLE IF EXISTS tmp_sl_fix_uom_lots") + support.jdbcDao.executeUpdate("DROP TEMPORARY TABLE IF EXISTS tmp_sl_fix_uom_ids") + support.jdbcDao.executeUpdate( + """ + CREATE TEMPORARY TABLE tmp_sl_fix_uom_lots ( + id BIGINT NOT NULL PRIMARY KEY + ) + """.trimIndent(), + ) + support.jdbcDao.executeUpdate( + """ + CREATE TEMPORARY TABLE tmp_sl_fix_uom_ids ( + id INT NOT NULL PRIMARY KEY + ) + """.trimIndent(), + ) + try { + support.jdbcDao.executeUpdate( + """ + INSERT INTO tmp_sl_fix_uom_lots (id) + SELECT DISTINCT inventoryLotLineId + FROM stock_ledger + WHERE deleted = 0 + AND date < :today + AND inventoryId = :inventoryId + AND inventoryLotLineId IS NOT NULL + """.trimIndent(), + args, + ) + support.jdbcDao.executeUpdate( + """ + INSERT INTO tmp_sl_fix_uom_ids (id) + SELECT sl.id + FROM stock_ledger sl + INNER JOIN tmp_sl_fix_uom_lots t ON t.id = sl.inventoryLotLineId + WHERE sl.deleted = 0 + AND sl.date < :today + """.trimIndent(), + args, + ) + support.jdbcDao.executeUpdate( + """ + UPDATE stock_ledger sl + INNER JOIN tmp_sl_fix_uom_ids t ON t.id = sl.id + INNER JOIN inventory_lot_line ill + ON ill.id = sl.inventoryLotLineId AND IFNULL(ill.deleted, 0) = 0 + INNER JOIN item_uom iu + ON iu.id = ill.stockItemUomId + SET sl.uomId = iu.uomId + """.trimIndent(), + ) + } finally { + support.jdbcDao.executeUpdate("DROP TEMPORARY TABLE IF EXISTS tmp_sl_fix_uom_ids") + support.jdbcDao.executeUpdate("DROP TEMPORARY TABLE IF EXISTS tmp_sl_fix_uom_lots") + } + } + } + + private fun fillInventoryIdScope(args: Map, byInventory: Boolean): Int { + if (!byInventory) { + return applyInventoryIdUpdates( + args, + extraJoin = "", + extraWhere = "sl.date < :today AND sl.inventoryLotLineId = :lotId", + ) + } + return support.withTempTx { + support.jdbcDao.executeUpdate("DROP TEMPORARY TABLE IF EXISTS tmp_sl_fix_inv_ids") + support.jdbcDao.executeUpdate( + """ + CREATE TEMPORARY TABLE tmp_sl_fix_inv_ids ( + id INT NOT NULL PRIMARY KEY + ) + """.trimIndent(), + ) + try { + support.jdbcDao.executeUpdate( + """ + INSERT INTO tmp_sl_fix_inv_ids (id) + SELECT sl.id + FROM stock_ledger sl + WHERE sl.deleted = 0 + AND sl.date < :today + AND sl.inventoryId = :inventoryId + AND sl.itemId IS NOT NULL + AND sl.uomId IS NOT NULL + """.trimIndent(), + args, + ) + support.jdbcDao.executeUpdate( + """ + INSERT IGNORE INTO tmp_sl_fix_inv_ids (id) + SELECT sl.id + FROM stock_ledger sl + WHERE sl.deleted = 0 + AND sl.date < :today + AND sl.itemId = :itemId + AND sl.itemId IS NOT NULL + AND sl.uomId IS NOT NULL + """.trimIndent(), + args, + ) + applyInventoryIdUpdates( + args, + extraJoin = "INNER JOIN tmp_sl_fix_inv_ids t ON t.id = sl.id", + extraWhere = "1=1", + ) + } finally { + support.jdbcDao.executeUpdate("DROP TEMPORARY TABLE IF EXISTS tmp_sl_fix_inv_ids") + } + } + } + + private fun fillLotQtyHistory(args: Map, byInventory: Boolean): Int { + val extra = if (byInventory) "AND sl2.inventoryId = :inventoryId" else "AND sl2.inventoryLotLineId = :lotId" + val extraUpd = if (byInventory) "AND sl.inventoryId = :inventoryId" else "AND sl.inventoryLotLineId = :lotId" + return 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.inventoryLotLineId + ORDER BY sl2.id + ROWS BETWEEN UNBOUNDED PRECEDING AND CURRENT ROW + ) + AS DECIMAL(14,2)) AS lotAfter, + CAST(COALESCE(sl2.inQty, 0) AS DECIMAL(14,2)) + - CAST(COALESCE(sl2.outQty, 0) AS DECIMAL(14,2)) AS delta + FROM stock_ledger sl2 + WHERE sl2.deleted = 0 + AND sl2.date < :today + AND sl2.inventoryLotLineId IS NOT NULL + $extra + ) calc ON calc.id = sl.id + SET sl.lotQtyAfter = calc.lotAfter, + sl.lotQtyBefore = calc.lotAfter - calc.delta + WHERE sl.deleted = 0 + AND sl.date < :today + $extraUpd + """.trimIndent(), + args, + ) + } + + private fun fillBalanceHistory(args: Map): Int { + return 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.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(), + args, + ) + } + + private fun lotIdsForInventory(inventoryId: Long, today: LocalDate): List { + return support.jdbcDao.queryForList( + """ + SELECT DISTINCT inventoryLotLineId AS id + FROM stock_ledger + WHERE deleted = 0 + AND inventoryId = :inventoryId + AND inventoryLotLineId IS NOT NULL + AND date < :today + """.trimIndent(), + mapOf("inventoryId" to inventoryId, "today" to today), + ).map { support.toLong(it["id"]) }.filter { it > 0 } + } + + internal fun dateRangeForFilter(filter: String, args: Map): Pair? { + val row = support.jdbcDao.queryForList( + """ + SELECT MIN(date) AS firstDate, MAX(date) AS lastDate + FROM stock_ledger + WHERE deleted = 0 + AND date < :today + AND $filter + """.trimIndent(), + args, + ).firstOrNull() ?: return null + val first = support.toLocalDate(row["firstDate"]) ?: return null + val last = support.toLocalDate(row["lastDate"]) ?: return null + val today = args["today"] as LocalDate + val toExclusive = last.plusDays(1).let { if (it.isAfter(today)) today else it } + return first to toExclusive + } + + internal fun upsertStockLotDayRange(lotIds: List, from: LocalDate, toExclusive: LocalDate): Int { + if (lotIds.isEmpty()) return 0 + return support.jdbcDao.executeUpdate( + """ + INSERT INTO stock_lot_day ( + created, createdBy, version, modified, modifiedBy, deleted, + inventoryLotLineId, itemId, itemCode, uomId, lotNo, date, + opening, inQty, outQty, closing + ) + SELECT + NOW(), 'ledger-fix-api', 0, NOW(), 'ledger-fix-api', 0, + x.inventoryLotLineId, + x.itemId, + x.itemCode, + x.uomId, + il.lotNo, + x.d, + firstSl.lotQtyBefore, + x.inQty, + x.outQty, + lastSl.lotQtyAfter + FROM ( + SELECT + sl.inventoryLotLineId, + CAST(sl.date AS DATE) AS d, + MIN(sl.itemId) AS itemId, + MIN(sl.itemCode) AS itemCode, + MIN(sl.uomId) AS uomId, + MIN(sl.id) AS firstId, + MAX(sl.id) AS lastId, + CAST(SUM(COALESCE(sl.inQty, 0)) AS DECIMAL(14,2)) AS inQty, + CAST(SUM(COALESCE(sl.outQty, 0)) AS DECIMAL(14,2)) AS outQty + FROM stock_ledger sl + WHERE sl.deleted = 0 + AND sl.date >= :from AND sl.date < :toExclusive + AND sl.inventoryLotLineId IN (:lotIds) + AND sl.lotQtyAfter IS NOT NULL + GROUP BY sl.inventoryLotLineId, CAST(sl.date AS DATE) + ) x + INNER JOIN stock_ledger firstSl ON firstSl.id = x.firstId + 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 = 'ledger-fix-api', + version = stock_lot_day.version + 1, + deleted = 0 + """.trimIndent(), + mapOf( + "lotIds" to lotIds, + "from" to from, + "toExclusive" to toExclusive, + ), + ) + } + + private fun stillMissScope(filter: String, args: Map): MissCounts { + val row = support.jdbcDao.queryForList( + """ + SELECT + SUM(inventoryLotLineId IS NULL) AS missLot, + SUM(uomId IS NULL) AS missUom, + SUM(inventoryId IS NULL) AS missInventoryId, + SUM(lotQtyAfter IS NULL) AS missLotQty + FROM stock_ledger + WHERE deleted = 0 + AND date < :today + AND $filter + """.trimIndent(), + args, + ).firstOrNull() ?: emptyMap() + return MissCounts( + missLot = support.toInt(row["missLot"]), + missUom = support.toInt(row["missUom"]), + missInventoryId = support.toInt(row["missInventoryId"]), + missLotQty = support.toInt(row["missLotQty"]), + ) + } + + private fun stillMiss(args: Map): MissCounts { + val row = support.jdbcDao.queryForList( + """ + SELECT + SUM(inventoryLotLineId IS NULL) AS missLot, + SUM(uomId IS NULL) AS missUom, + SUM(inventoryId IS NULL) AS missInventoryId, + SUM(lotQtyAfter IS NULL) AS missLotQty + FROM stock_ledger + WHERE deleted = 0 + AND date >= :day AND date < :dayNext + """.trimIndent(), + args, + ).firstOrNull() ?: emptyMap() + return MissCounts( + missLot = support.toInt(row["missLot"]), + missUom = support.toInt(row["missUom"]), + missInventoryId = support.toInt(row["missInventoryId"]), + missLotQty = support.toInt(row["missLotQty"]), + ) + } + + internal fun fillLotQtyForLotIds(lotIds: List, today: LocalDate): Int { + if (lotIds.isEmpty()) return 0 + return support.withTempTx { + support.jdbcDao.executeUpdate("DROP TEMPORARY TABLE IF EXISTS tmp_sl_fix_adj_lots") + support.jdbcDao.executeUpdate( + "CREATE TEMPORARY TABLE tmp_sl_fix_adj_lots (id BIGINT NOT NULL PRIMARY KEY)", + ) + try { + lotIds.chunked(200).forEach { chunk -> + val values = chunk.joinToString(",") { "($it)" } + support.jdbcDao.executeUpdate("INSERT INTO tmp_sl_fix_adj_lots (id) VALUES $values") + } + 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.inventoryLotLineId + ORDER BY sl2.id + ROWS BETWEEN UNBOUNDED PRECEDING AND CURRENT ROW + ) + AS DECIMAL(14,2)) AS lotAfter, + CAST(COALESCE(sl2.inQty, 0) AS DECIMAL(14,2)) + - CAST(COALESCE(sl2.outQty, 0) AS DECIMAL(14,2)) AS delta + FROM stock_ledger sl2 + INNER JOIN tmp_sl_fix_adj_lots t ON t.id = sl2.inventoryLotLineId + WHERE sl2.deleted = 0 + AND sl2.date < :today + AND sl2.inventoryLotLineId IS NOT NULL + ) calc ON calc.id = sl.id + SET sl.lotQtyAfter = calc.lotAfter, + sl.lotQtyBefore = calc.lotAfter - calc.delta + WHERE sl.deleted = 0 + AND sl.date < :today + """.trimIndent(), + mapOf("today" to today), + ) + } finally { + try { + support.jdbcDao.executeUpdate("DROP TEMPORARY TABLE IF EXISTS tmp_sl_fix_adj_lots") + } catch (e: Exception) { + log.warn("stock-ledger-fix drop tmp_sl_fix_adj_lots skipped: {}", e.message) + } + } + } + } + + /** Chain balance on new ADJ rows only; older rows for the same SKU are unchanged. */ + internal fun fillBalanceOnNewAdjRows(adjDate: LocalDate, today: LocalDate): Int { + val rows = support.jdbcDao.queryForList( + """ + SELECT id, inventoryId, + CAST(COALESCE(inQty, 0) AS DECIMAL(14,2)) AS inQty, + CAST(COALESCE(outQty, 0) AS DECIMAL(14,2)) AS outQty + FROM stock_ledger + WHERE deleted = 0 + AND date >= :adjDate AND date < :today + AND type = 'ADJ' + AND createdBy = 'stock-ledger-fix' + AND inventoryId IS NOT NULL + ORDER BY id + """.trimIndent(), + mapOf("adjDate" to adjDate, "today" to today), + ) + if (rows.isEmpty()) return 0 + val prevByInv = HashMap() + val updates = ArrayList>() + rows.forEach { row -> + val id = support.toLong(row["id"]) + val invId = support.toLong(row["inventoryId"]) + if (prevByInv[invId] == null) { + prevByInv[invId] = support.toDecimal( + support.jdbcDao.queryForList( + """ + SELECT balance + FROM stock_ledger + WHERE deleted = 0 + AND inventoryId = :invId + AND date < :today + AND id < :adjId + ORDER BY id DESC + LIMIT 1 + """.trimIndent(), + mapOf("invId" to invId, "today" to today, "adjId" to id), + ).firstOrNull()?.get("balance"), + ) + } + val delta = support.toDecimal(row["inQty"]).subtract(support.toDecimal(row["outQty"])) + val bal = prevByInv.getValue(invId).add(delta) + prevByInv[invId] = bal + updates.add(id to bal) + } + return support.withTempTx { + support.jdbcDao.executeUpdate("DROP TEMPORARY TABLE IF EXISTS tmp_sl_fix_adj_bal") + support.jdbcDao.executeUpdate( + """ + CREATE TEMPORARY TABLE tmp_sl_fix_adj_bal ( + id BIGINT NOT NULL PRIMARY KEY, + newBalance DECIMAL(14,2) NOT NULL + ) + """.trimIndent(), + ) + try { + updates.chunked(200).forEach { chunk -> + val values = chunk.joinToString(",") { (id, bal) -> + "($id, ${bal.toPlainString()})" + } + support.jdbcDao.executeUpdate( + "INSERT INTO tmp_sl_fix_adj_bal (id, newBalance) VALUES $values", + ) + } + support.jdbcDao.executeUpdate( + """ + UPDATE stock_ledger sl + INNER JOIN tmp_sl_fix_adj_bal t ON t.id = sl.id + SET sl.balance = t.newBalance + WHERE sl.deleted = 0 + """.trimIndent(), + ) + } finally { + try { + support.jdbcDao.executeUpdate("DROP TEMPORARY TABLE IF EXISTS tmp_sl_fix_adj_bal") + } catch (e: Exception) { + log.warn("stock-ledger-fix drop tmp_sl_fix_adj_bal skipped: {}", e.message) + } + } + } + } + + private data class MissCounts( + val missLot: Int, + val missUom: Int, + val missInventoryId: Int, + val missLotQty: Int, + ) +} diff --git a/src/main/java/com/ffii/fpsms/modules/master/service/ledgerfix/StockLedgerFixExportService.kt b/src/main/java/com/ffii/fpsms/modules/master/service/ledgerfix/StockLedgerFixExportService.kt new file mode 100644 index 0000000..1c1dea2 --- /dev/null +++ b/src/main/java/com/ffii/fpsms/modules/master/service/ledgerfix/StockLedgerFixExportService.kt @@ -0,0 +1,625 @@ +package com.ffii.fpsms.modules.master.service.ledgerfix + +import org.slf4j.LoggerFactory +import org.springframework.jdbc.core.RowCallbackHandler +import org.springframework.jdbc.core.namedparam.NamedParameterJdbcTemplate +import org.springframework.stereotype.Service +import java.io.BufferedWriter +import java.io.OutputStream +import java.io.OutputStreamWriter +import java.math.BigDecimal +import java.nio.charset.StandardCharsets +import java.sql.ResultSet +import java.time.LocalDate + +@Service +open class StockLedgerFixExportService( + private val support: StockLedgerFixSupport, +) { + private val log = LoggerFactory.getLogger(StockLedgerFixExportService::class.java) + + /** + * 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 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, + to: LocalDate, + out: OutputStream, + partsRaw: List? = null, + ) { + if (to.isBefore(from)) throw IllegalArgumentException("to must be on or after from") + if (from.plusDays(400).isBefore(to)) { + throw IllegalArgumentException("date range must be at most 400 days") + } + val today = LocalDate.now() + if (to.isAfter(today)) { + throw IllegalArgumentException("cannot export future dates") + } + val toExclusive = to.plusDays(1) + if (!from.isBefore(toExclusive)) { + throw IllegalArgumentException("from must be before toExclusive") + } + val parts = parseExportParts(partsRaw) + val want10 = "1.0" in parts + val want23 = "2.3" in parts + val wantLedger = "ledger" in parts + val want26 = "2.6" in parts + val want27 = "2.7" in parts + if (!want10 && !want23 && !wantLedger && !want26 && !want27) { + throw IllegalArgumentException("parts must include at least one of: 1.0, 2.3, ledger, 2.6, 2.7") + } + val args = mapOf("from" to from, "toExclusive" to toExclusive) + val ledgerCnt = if (wantLedger || want23 || want27) { + support.toInt( + support.jdbcDao.queryForList( + """ + SELECT COUNT(*) AS c FROM stock_ledger + WHERE deleted = 0 AND date >= :from AND date < :toExclusive + """.trimIndent(), + args, + ).firstOrNull()?.get("c"), + ) + } else { + 0 + } + val dayCnt = if (want26) { + support.toInt( + support.jdbcDao.queryForList( + """ + SELECT COUNT(*) AS c FROM stock_lot_day + WHERE IFNULL(deleted, 0) = 0 AND date >= :from AND date < :toExclusive + """.trimIndent(), + args, + ).firstOrNull()?.get("c"), + ) + } else { + 0 + } + val adjCnt = if (want27) { + support.toInt( + support.jdbcDao.queryForList( + """ + SELECT COUNT(*) AS c FROM stock_ledger + WHERE deleted = 0 + AND date >= :from AND date < :toExclusive + AND type = 'ADJ' + AND createdBy = 'stock-ledger-fix' + """.trimIndent(), + args, + ).firstOrNull()?.get("c"), + ) + } else { + 0 + } + val invCnt = if (want10) { + support.toInt( + support.jdbcDao.queryForList( + "SELECT COUNT(*) AS c FROM inventory WHERE IFNULL(deleted, 0) = 0", + ).firstOrNull()?.get("c"), + ) + } else { + 0 + } + val writer = BufferedWriter(OutputStreamWriter(out, StandardCharsets.UTF_8), 65536) + val jdbc = NamedParameterJdbcTemplate(support.dataSource) + jdbc.jdbcTemplate.fetchSize = 2000 + try { + 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 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") + } + writer.write("-- please run in the target DB/schema, e.g. USE fpsmsdb;\n") + writer.write( + "-- expected inventoryRows=$invCnt ledgerRows=$ledgerCnt adjRows=$adjCnt stock_lot_dayRows=$dayCnt\n", + ) + writer.write("-- SET NAMES utf8mb4;\n\n") + + if (want10) { + writeExportInventory10(writer, jdbc) + } + if (wantLedger || want23) { + writeExportLedgerPatch(writer, jdbc, args, wantLedger, want23) + } + if (want27) { + writeExportAdjDocs(writer, jdbc, args) + writeExportAdjInsert(writer, jdbc, args) + } + if (want26) { + writeExportStockLotDay(writer, jdbc, args, from, toExclusive) + } + + writer.write("\n-- done\n") + writer.flush() + log.info( + "stock-ledger-fix export {}..{} parts={} inv={} ledger={} adj={} day={}", + from, toExclusive.minusDays(1), parts.joinToString(","), invCnt, ledgerCnt, adjCnt, dayCnt, + ) + } finally { + writer.flush() + } + } + + private fun writeExportInventory10(writer: BufferedWriter, jdbc: NamedParameterJdbcTemplate) { + writer.write("-- === 1.0 inventory (INSERT with fixed id + UPDATE qty/status) ===\n") + var batch = 0 + jdbc.query( + """ + SELECT id, created, createdBy, version, modified, modifiedBy, deleted, + itemId, uomId, stockUomId, onHandQty, onHoldQty, unavailableQty, + price, currencyId, cpu, cpuUnit, cpm, cpmUnit, status + FROM inventory + WHERE IFNULL(deleted, 0) = 0 + ORDER BY id + """.trimIndent(), + emptyMap(), + RowCallbackHandler { rs -> + if (batch == 0) { + writer.write( + "INSERT INTO inventory (\n" + + " id, created, createdBy, version, modified, modifiedBy, deleted,\n" + + " itemId, uomId, stockUomId, onHandQty, onHoldQty, unavailableQty,\n" + + " price, currencyId, cpu, cpuUnit, cpm, cpmUnit, status\n" + + ") VALUES\n", + ) + } else { + writer.write(",\n") + } + 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, "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")})", + ) + batch++ + if (batch >= 200) { + writer.write( + "\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), " + + "version=inventory.version+1, deleted=0;\n\n", + ) + batch = 0 + } + }, + ) + if (batch > 0) { + writer.write( + "\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), " + + "version=inventory.version+1, deleted=0;\n\n", + ) + } else { + writer.write("-- (no inventory rows)\n\n") + } + } + + private fun writeExportLedgerPatch( + writer: BufferedWriter, + jdbc: NamedParameterJdbcTemplate, + args: Map, + wantLedger: Boolean, + want23: Boolean, + ) { + writer.write("-- === ledger UPDATE (JOIN id)") + if (wantLedger) writer.write(" lot/uom/lotQty/balance") + if (want23) writer.write(" inventoryId") + writer.write(" ===\n") + val cols = mutableListOf("id") + if (wantLedger) { + cols += listOf("inventoryLotLineId", "uomId", "lotQtyBefore", "lotQtyAfter", "balance") + } + if (want23) cols += "inventoryId" + writer.write("CREATE TEMPORARY TABLE tmp_sl_fix_patch (\n") + writer.write(" id INT NOT NULL PRIMARY KEY") + if (wantLedger) { + writer.write(",\n inventoryLotLineId BIGINT NULL,\n uomId INT NULL,\n") + writer.write(" lotQtyBefore DECIMAL(14,2) NULL,\n lotQtyAfter DECIMAL(14,2) NULL,\n") + writer.write(" balance DECIMAL(14,2) NULL") + } + if (want23) writer.write(",\n inventoryId INT NULL") + writer.write("\n);\n\n") + var batch = 0 + val selectCols = cols.joinToString(", ") + jdbc.query( + """ + SELECT $selectCols + FROM stock_ledger + WHERE deleted = 0 AND date >= :from AND date < :toExclusive + """.trimIndent(), + args, + RowCallbackHandler { rs -> + if (batch == 0) { + writer.write("INSERT INTO tmp_sl_fix_patch (${cols.joinToString(", ")}) VALUES\n") + } else { + writer.write(",\n") + } + writer.write("(" + cols.joinToString(", ") { rsSql(rs, it) } + ")") + batch++ + if (batch >= 500) { + writer.write(";\n\n") + batch = 0 + } + }, + ) + if (batch > 0) writer.write(";\n\n") + val setParts = mutableListOf() + if (wantLedger) { + setParts += listOf( + "sl.inventoryLotLineId = t.inventoryLotLineId", + "sl.uomId = t.uomId", + "sl.lotQtyBefore = t.lotQtyBefore", + "sl.lotQtyAfter = t.lotQtyAfter", + "sl.balance = t.balance", + ) + } + if (want23) setParts += "sl.inventoryId = t.inventoryId" + writer.write( + """ + UPDATE stock_ledger sl + INNER JOIN tmp_sl_fix_patch t ON t.id = sl.id + SET + ${setParts.joinToString(",\n ")} + WHERE sl.deleted = 0; + + DROP TEMPORARY TABLE IF EXISTS tmp_sl_fix_patch; + + """.trimIndent(), + ) + 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, + args: Map, + ) { + writer.write("-- === 2.7 ADJ INSERT (fixed id; rows missing on target are created) ===\n") + var batch = 0 + jdbc.query( + """ + SELECT id, created, createdBy, version, modified, modifiedBy, deleted, + date, stockInLineId, stockOutLineId, inventoryLotLineId, inventoryId, + itemId, itemCode, inQty, outQty, balance, lotQtyBefore, lotQtyAfter, + uomId, type + FROM stock_ledger + WHERE deleted = 0 + AND date >= :from AND date < :toExclusive + AND type = 'ADJ' + AND createdBy = 'stock-ledger-fix' + ORDER BY id + """.trimIndent(), + args, + RowCallbackHandler { rs -> + if (batch == 0) { + writer.write( + "INSERT INTO stock_ledger (\n" + + " id, created, createdBy, version, modified, modifiedBy, deleted,\n" + + " date, stockInLineId, stockOutLineId, inventoryLotLineId, inventoryId,\n" + + " itemId, itemCode, inQty, outQty, balance, lotQtyBefore, lotQtyAfter,\n" + + " uomId, type\n) VALUES\n", + ) + } else { + writer.write(",\n") + } + 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, "date")}, ${rsSql(rs, "stockInLineId")}, ${rsSql(rs, "stockOutLineId")}, " + + "${rsSql(rs, "inventoryLotLineId")}, ${rsSql(rs, "inventoryId")}, " + + "${rsSql(rs, "itemId")}, ${rsSql(rs, "itemCode")}, ${rsSql(rs, "inQty")}, ${rsSql(rs, "outQty")}, " + + "${rsSql(rs, "balance")}, ${rsSql(rs, "lotQtyBefore")}, ${rsSql(rs, "lotQtyAfter")}, " + + "${rsSql(rs, "uomId")}, ${rsSql(rs, "type")})", + ) + batch++ + if (batch >= 200) { + 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), " + + "version=stock_ledger.version+1, deleted=0;\n\n", + ) + batch = 0 + } + }, + ) + if (batch > 0) { + 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), " + + "version=stock_ledger.version+1, deleted=0;\n\n", + ) + } else { + writer.write("-- (no ADJ rows from stock-ledger-fix in range)\n\n") + } + } + + private fun writeExportStockLotDay( + writer: BufferedWriter, + jdbc: NamedParameterJdbcTemplate, + args: Map, + from: LocalDate, + toExclusive: LocalDate, + ) { + writer.write("-- === 2.6 stock_lot_day ===\n") + writer.write( + """ + DELETE FROM stock_lot_day + WHERE `date` >= '$from' AND `date` < '$toExclusive'; + + """.trimIndent(), + ) + writer.write("\n") + var batch = 0 + jdbc.query( + """ + SELECT created, createdBy, version, modified, modifiedBy, deleted, + inventoryLotLineId, itemId, itemCode, uomId, lotNo, date, + opening, inQty, outQty, closing + FROM stock_lot_day + WHERE IFNULL(deleted, 0) = 0 AND date >= :from AND date < :toExclusive + ORDER BY date, inventoryLotLineId + """.trimIndent(), + args, + RowCallbackHandler { rs -> + if (batch == 0) { + writer.write( + "INSERT INTO stock_lot_day (\n" + + " created, createdBy, version, modified, modifiedBy, deleted,\n" + + " inventoryLotLineId, itemId, itemCode, uomId, lotNo, `date`,\n" + + " opening, inQty, outQty, closing\n) VALUES\n", + ) + } else { + writer.write(",\n") + } + writer.write( + "(${rsSql(rs, "created")}, ${rsSql(rs, "createdBy")}, ${rsSql(rs, "version")}, " + + "${rsSql(rs, "modified")}, ${rsSql(rs, "modifiedBy")}, ${rsSql(rs, "deleted")}, " + + "${rsSql(rs, "inventoryLotLineId")}, ${rsSql(rs, "itemId")}, ${rsSql(rs, "itemCode")}, " + + "${rsSql(rs, "uomId")}, ${rsSql(rs, "lotNo")}, ${rsSql(rs, "date")}, " + + "${rsSql(rs, "opening")}, ${rsSql(rs, "inQty")}, ${rsSql(rs, "outQty")}, " + + "${rsSql(rs, "closing")})", + ) + batch++ + if (batch >= 500) { + writer.write( + "\nON 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=VALUES(modified), modifiedBy=VALUES(modifiedBy), " + + "version=stock_lot_day.version+1, deleted=0;\n\n", + ) + batch = 0 + } + }, + ) + if (batch > 0) { + writer.write( + "\nON 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=VALUES(modified), modifiedBy=VALUES(modifiedBy), " + + "version=stock_lot_day.version+1, deleted=0;\n", + ) + } + } + + private fun parseExportParts(raw: List?): Set { + if (raw.isNullOrEmpty() || raw.any { it.trim().equals("all", ignoreCase = true) }) { + return EXPORT_PARTS_DEFAULT + } + val flat = raw.flatMap { it.split(",") }.map { it.trim() }.filter { it.isNotEmpty() } + if (flat.isEmpty() || flat.any { it.equals("all", ignoreCase = true) }) { + return EXPORT_PARTS_DEFAULT + } + val out = linkedSetOf() + flat.forEach { token -> + val t = token.lowercase() + val key = when (t) { + "1.0", "10", "inventory" -> "1.0" + "2.3", "23", "inventoryid" -> "2.3" + "ledger", "2.1", "2.2", "2.4", "2.5" -> "ledger" + "2.6", "26", "day", "stock_lot_day" -> "2.6" + "2.7", "27", "adj" -> "2.7" + else -> throw IllegalArgumentException( + "unknown export part '$token' (use 1.0, 2.3, ledger, 2.6, 2.7, or all)", + ) + } + out.add(key) + } + if (out.isEmpty()) throw IllegalArgumentException("parts must not be empty") + 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" + return when (v) { + is BigDecimal -> v.stripTrailingZeros().toPlainString() + is Number -> v.toString() + is java.sql.Date -> "'${v.toLocalDate()}'" + is java.sql.Timestamp -> "'${v.toLocalDateTime().toString().replace('T', ' ')}'" + is Boolean -> if (v) "1" else "0" + else -> "'" + v.toString().replace("\\", "\\\\").replace("'", "''") + "'" + } + } + + companion object { + /** Legacy default export: ledger calc fields + stock_lot_day (no inventory / inventoryId / ADJ). */ + private val EXPORT_PARTS_DEFAULT = linkedSetOf("ledger", "2.6") + } +} diff --git a/src/main/java/com/ffii/fpsms/modules/master/service/ledgerfix/StockLedgerFixInventorySql.kt b/src/main/java/com/ffii/fpsms/modules/master/service/ledgerfix/StockLedgerFixInventorySql.kt new file mode 100644 index 0000000..cef015d --- /dev/null +++ b/src/main/java/com/ffii/fpsms/modules/master/service/ledgerfix/StockLedgerFixInventorySql.kt @@ -0,0 +1,246 @@ +package com.ffii.fpsms.modules.master.service.ledgerfix + +internal object StockLedgerFixInventorySql { + internal const val LOT_UOM_PAIR_COUNT_SQL = """ + SELECT COUNT(*) AS c FROM ( + SELECT il.itemId, iu.uomId + 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, iu.uomId + ) x + """ + + internal const val MISSING_UOM_PAIR_COUNT_SQL = """ + SELECT COUNT(*) AS c FROM ( + SELECT il.itemId, iu.uomId + 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 + LEFT JOIN inventory inv + ON inv.itemId = il.itemId + 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 + AND iu.uomId IS NOT NULL + AND inv.id IS NULL + GROUP BY il.itemId, iu.uomId + ) x + """ + + internal 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. + * One keeper per (itemId, uomId); skip if that stockUomId is already taken + * (including soft-deleted rows that still occupy the unique key). + */ + internal 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 + INNER JOIN ( + SELECT itemId, uomId, MIN(id) AS keepId + FROM inventory + WHERE IFNULL(deleted, 0) = 0 + AND stockUomId IS NULL + GROUP BY itemId, uomId + ) keep ON keep.keepId = i.id + 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 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 + """ + + /** + * Remaining NULL stockUomId: item has exactly one lot stock UOM + * (uomId already base, stock may differ). Keep MIN(id) only. + */ + internal 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 + INNER JOIN ( + SELECT itemId, MIN(id) AS keepId + FROM inventory + WHERE IFNULL(deleted, 0) = 0 + AND stockUomId IS NULL + GROUP BY itemId + ) keep ON keep.keepId = i.id + 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 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 + """ + + internal const val INSERT_MISSING_INVENTORY_SQL = """ + INSERT INTO inventory ( + 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), + tpl.currencyId, + COALESCE(tpl.cpu, 0), + COALESCE(tpl.cpuUnit, 'HKD'), + COALESCE(tpl.cpm, 0), + COALESCE(tpl.cpmUnit, 'HKD'), + 'unavailable', + NOW(), 'stock-ledger-fix', NOW(), 'stock-ledger-fix', 0, 0 + FROM ( + SELECT il.itemId, iu.uomId + 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, 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 IFNULL(existing.deleted, 0) = 0 + AND ( + existing.stockUomId = src.uomId + OR (existing.stockUomId IS NULL AND existing.uomId = src.uomId) + ) + LEFT JOIN inventory occupied + ON occupied.itemId = src.itemId + AND occupied.stockUomId = src.uomId + LEFT JOIN inventory tpl + ON tpl.id = ( + SELECT MIN(i.id) + FROM inventory i + WHERE i.itemId = src.itemId + AND IFNULL(i.deleted, 0) = 0 + ) + WHERE existing.id IS NULL + AND occupied.id IS NULL + """ + + /** + * Extra old UOM rows left NULL after a keeper bucket exists for the item + * (e.g. 18918 id 5261). Soft-delete so search / COALESCE fallbacks ignore them. + */ + internal const val SOFT_DELETE_ORPHAN_NULL_STOCK_UOM_SQL = """ + UPDATE inventory i + INNER JOIN ( + SELECT DISTINCT itemId + FROM inventory + WHERE IFNULL(deleted, 0) = 0 + AND stockUomId IS NOT NULL + ) keeper ON keeper.itemId = i.itemId + SET + i.deleted = 1, + i.modified = NOW(), + i.modifiedBy = 'stock-ledger-fix' + WHERE IFNULL(i.deleted, 0) = 0 + AND i.stockUomId IS NULL + """ + + internal const val UPDATE_INVENTORY_QTY_SQL = """ + UPDATE inventory i + LEFT JOIN ( + SELECT + il.itemId, + iu.uomId, + SUM(COALESCE(ill.inQty, 0) - COALESCE(ill.outQty, 0)) AS onHand, + SUM( + CASE + WHEN LOWER(ill.status) = 'unavailable' + THEN COALESCE(ill.inQty, 0) - COALESCE(ill.outQty, 0) - COALESCE(ill.holdQty, 0) + ELSE 0 + END + ) AS unavailable + 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, iu.uomId + ) agg ON agg.itemId = i.itemId AND agg.uomId = COALESCE(i.stockUomId, i.uomId) + SET + i.onHandQty = COALESCE(agg.onHand, 0), + i.unavailableQty = COALESCE(agg.unavailable, 0), + i.status = IF( + COALESCE(agg.onHand, 0) - COALESCE(agg.unavailable, 0) > 0, + 'available', + 'unavailable' + ), + i.modified = NOW(), + i.modifiedBy = 'stock-ledger-fix' + WHERE IFNULL(i.deleted, 0) = 0 + """ +} diff --git a/src/main/java/com/ffii/fpsms/modules/master/service/ledgerfix/StockLedgerFixQueryService.kt b/src/main/java/com/ffii/fpsms/modules/master/service/ledgerfix/StockLedgerFixQueryService.kt new file mode 100644 index 0000000..af138c9 --- /dev/null +++ b/src/main/java/com/ffii/fpsms/modules/master/service/ledgerfix/StockLedgerFixQueryService.kt @@ -0,0 +1,703 @@ +package com.ffii.fpsms.modules.master.service.ledgerfix + +import com.ffii.fpsms.modules.master.web.models.StockLedgerFixCalendarResponse +import com.ffii.fpsms.modules.master.web.models.StockLedgerFixCheckPart +import com.ffii.fpsms.modules.master.web.models.StockLedgerFixDayDetail +import com.ffii.fpsms.modules.master.web.models.StockLedgerFixDayStatus +import com.ffii.fpsms.modules.master.web.models.StockLedgerFixInventoryPreview +import com.ffii.fpsms.modules.master.web.models.StockLedgerFixScopeDetail +import com.ffii.fpsms.modules.master.web.models.StockLedgerFixSearchInventoryHit +import com.ffii.fpsms.modules.master.web.models.StockLedgerFixSearchLotHit +import org.springframework.stereotype.Service +import java.time.LocalDate + +@Service +open class StockLedgerFixQueryService( + private val support: StockLedgerFixSupport, +) { + open fun calendar(from: LocalDate, to: LocalDate): StockLedgerFixCalendarResponse { + if (to.isBefore(from)) { + throw IllegalArgumentException("to must be on or after from") + } + if (from.plusDays(62).isBefore(to)) { + throw IllegalArgumentException("date range must be at most 62 days") + } + val args = mapOf("from" to from, "toNext" to to.plusDays(1)) + val rows = support.jdbcDao.queryForList( + """ + SELECT + DATE_FORMAT(sl.date, '%Y-%m-%d') AS d, + COUNT(*) AS cnt, + SUM(sl.inventoryLotLineId IS NULL) AS missLot, + SUM(sl.uomId IS NULL) AS missUom, + SUM(sl.inventoryId IS NULL) AS missInventoryId, + SUM(sl.lotQtyAfter IS NULL) AS missLotQty + FROM stock_ledger sl + WHERE sl.deleted = 0 + AND sl.date >= :from + AND sl.date < :toNext + GROUP BY DATE_FORMAT(sl.date, '%Y-%m-%d') + ORDER BY d + """.trimIndent(), + args, + ) + val dayLots = support.jdbcDao.queryForList( + """ + SELECT DATE_FORMAT(date, '%Y-%m-%d') AS d, COUNT(*) AS lots + FROM stock_lot_day + WHERE deleted = 0 + AND date >= :from + AND date < :toNext + GROUP BY DATE_FORMAT(date, '%Y-%m-%d') + """.trimIndent(), + args, + ).associate { row -> + row["d"].toString() to support.toInt(row["lots"]) + } + val days = rows.map { row -> + val d = row["d"].toString() + StockLedgerFixDayStatus( + date = d, + cnt = support.toInt(row["cnt"]), + missLot = support.toInt(row["missLot"]), + missUom = support.toInt(row["missUom"]), + missInventoryId = support.toInt(row["missInventoryId"]), + missLotQty = support.toInt(row["missLotQty"]), + dayTableLots = dayLots[d] ?: 0, + ) + } + return StockLedgerFixCalendarResponse( + from = from.toString(), + to = to.toString(), + days = days, + ) + } + + /** + * A: keep existing inventory ids; + * backfill stockUomId from lot stock UOM (and set uomId to base) — one row per + * (itemId, stockUomId) so uk_inventory_item_stock_uom is not violated; + * INSERT missing (itemId, stockUomId); + * soft-delete leftover NULL stockUomId rows on items that already have a bucket; + * 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 = support.toInt( + support.jdbcDao.queryForList( + "SELECT COUNT(*) AS c FROM inventory WHERE IFNULL(deleted, 0) = 0", + ).firstOrNull()?.get("c"), + ) + val lotUomPairs = support.toInt( + support.jdbcDao.queryForList(StockLedgerFixInventorySql.LOT_UOM_PAIR_COUNT_SQL).firstOrNull()?.get("c"), + ) + val missingUomPairs = support.toInt( + support.jdbcDao.queryForList(StockLedgerFixInventorySql.MISSING_UOM_PAIR_COUNT_SQL).firstOrNull()?.get("c"), + ) + val nullStockUomId = support.toInt( + support.jdbcDao.queryForList(StockLedgerFixInventorySql.NULL_STOCK_UOM_COUNT_SQL).firstOrNull()?.get("c"), + ) + return StockLedgerFixInventoryPreview( + inventoryRows = inventoryRows, + lotUomPairs = lotUomPairs, + missingUomPairs = missingUomPairs, + nullStockUomId = nullStockUomId, + ) + } + + open fun dayDetail(day: LocalDate): StockLedgerFixDayDetail { + val args = support.dayArgs(day) + val ledger = support.jdbcDao.queryForList( + """ + SELECT + COUNT(*) AS cnt, + SUM(sl.inventoryLotLineId IS NULL) AS lotMiss, + SUM( + sl.inventoryLotLineId IS NOT NULL + AND COALESCE(sol.inventoryLotLineId, sil.inventoryLotLineId) IS NOT NULL + AND sl.inventoryLotLineId <> COALESCE(sol.inventoryLotLineId, sil.inventoryLotLineId) + ) AS lotIncorrect, + SUM(sl.uomId IS NULL) AS uomMiss, + SUM( + sl.inventoryLotLineId IS NOT NULL + AND sl.uomId IS NOT NULL + AND iu.uomId IS NOT NULL + AND sl.uomId <> iu.uomId + ) AS uomIncorrect, + SUM(sl.inventoryId IS NULL) AS invMiss, + SUM( + sl.inventoryId IS NOT NULL + AND sl.itemId IS NOT NULL + AND sl.uomId IS NOT NULL + AND NOT EXISTS ( + SELECT 1 FROM inventory inv + WHERE inv.id = sl.inventoryId + AND IFNULL(inv.deleted, 0) = 0 + AND inv.itemId = sl.itemId + AND COALESCE(inv.stockUomId, inv.uomId) = sl.uomId + ) + ) AS invIncorrect, + SUM(sl.lotQtyAfter IS NULL OR sl.lotQtyBefore IS NULL) AS lotQtyMiss, + SUM( + sl.inventoryLotLineId IS NOT NULL + AND (sl.lotQtyAfter IS NULL OR sl.lotQtyBefore IS NULL) + ) AS lotQtyMissWithLot, + SUM( + sl.lotQtyAfter IS NOT NULL + AND sl.lotQtyBefore IS NOT NULL + AND ( + sl.lotQtyAfter - sl.lotQtyBefore + <> CAST(COALESCE(sl.inQty, 0) AS DECIMAL(14,2)) + - CAST(COALESCE(sl.outQty, 0) AS DECIMAL(14,2)) + ) + ) AS lotQtyIncorrect, + SUM( + sl.lotQtyAfter IS NOT NULL + AND sl.lotQtyBefore IS NOT NULL + AND sl.lotQtyAfter - sl.lotQtyBefore + = CAST(COALESCE(sl.inQty, 0) AS DECIMAL(14,2)) + - CAST(COALESCE(sl.outQty, 0) AS DECIMAL(14,2)) + AND (sl.lotQtyAfter < 0 OR sl.lotQtyBefore < 0) + ) AS overIssue, + SUM(sl.balance IS NULL) AS balanceMiss, + SUM( + sl.inventoryLotLineId IS NULL + AND COALESCE(sol.inventoryLotLineId, sil.inventoryLotLineId) IS NOT NULL + ) AS fixable21a, + SUM( + sl.inventoryLotLineId IS NULL + AND COALESCE(sol.inventoryLotLineId, sil.inventoryLotLineId) IS NULL + AND sil.inventoryLotId IS NOT NULL + AND IFNULL(lc.lineCnt, 0) = 1 + ) AS fixable21b, + SUM( + sl.inventoryLotLineId IS NULL + AND COALESCE(sol.inventoryLotLineId, sil.inventoryLotLineId) IS NULL + AND sil.inventoryLotId IS NOT NULL + AND IFNULL(lc.lineCnt, 0) > 1 + ) AS cannotMultiLine, + SUM( + sl.inventoryLotLineId IS NULL + AND COALESCE(sol.inventoryLotLineId, sil.inventoryLotLineId) IS NULL + AND (sil.inventoryLotId IS NULL OR IFNULL(lc.lineCnt, 0) = 0) + ) AS cannotNoSource + FROM stock_ledger sl + LEFT JOIN stock_out_line sol ON sol.id = sl.stockOutLineId + LEFT JOIN stock_in_line sil ON sil.id = sl.stockInLineId + LEFT JOIN inventory_lot_line ill ON ill.id = sl.inventoryLotLineId + LEFT JOIN item_uom iu ON iu.id = ill.stockItemUomId + LEFT JOIN ( + SELECT inventoryLotId, COUNT(*) AS lineCnt + FROM inventory_lot_line + WHERE IFNULL(deleted, 0) = 0 + GROUP BY inventoryLotId + ) lc ON lc.inventoryLotId = sil.inventoryLotId + WHERE sl.deleted = 0 + AND sl.date >= :day AND sl.date < :dayNext + """.trimIndent(), + args, + ).firstOrNull() ?: emptyMap() + + val cnt = support.toInt(ledger["cnt"]) + val lotMiss = support.toInt(ledger["lotMiss"]) + val lotIncorrect = support.toInt(ledger["lotIncorrect"]) + val uomMiss = support.toInt(ledger["uomMiss"]) + val uomIncorrect = support.toInt(ledger["uomIncorrect"]) + val invMiss = support.toInt(ledger["invMiss"]) + val invIncorrect = support.toInt(ledger["invIncorrect"]) + val lotQtyMissWithLot = support.toInt(ledger["lotQtyMissWithLot"]) + val lotQtyIncorrect = support.toInt(ledger["lotQtyIncorrect"]) + val overIssue = support.toInt(ledger["overIssue"]) + val balanceMiss = support.toInt(ledger["balanceMiss"]) + + val dayTable = support.jdbcDao.queryForList( + """ + SELECT + COUNT(*) AS expected, + SUM(d.id IS NULL) AS miss, + SUM( + d.id IS NOT NULL AND ( + (x.kind = 'move' AND ( + d.closing <> lastSl.lotQtyAfter + OR d.opening <> firstSl.lotQtyBefore + OR d.inQty <> x.inQty + OR d.outQty <> x.outQty + )) + OR (x.kind = 'carry' AND ( + d.inQty <> 0 + OR d.outQty <> 0 + OR d.opening <> x.prevClosing + OR d.closing <> x.prevClosing + )) + ) + ) AS incorrect + FROM ( + SELECT + sl.inventoryLotLineId, + 'move' AS kind, + MIN(sl.id) AS firstId, + MAX(sl.id) AS lastId, + CAST(SUM(COALESCE(sl.inQty, 0)) AS DECIMAL(14,2)) AS inQty, + CAST(SUM(COALESCE(sl.outQty, 0)) AS DECIMAL(14,2)) AS outQty, + CAST(NULL AS DECIMAL(14,2)) AS prevClosing + FROM stock_ledger sl + WHERE sl.deleted = 0 + AND sl.date >= :day AND sl.date < :dayNext + AND sl.inventoryLotLineId IS NOT NULL + GROUP BY sl.inventoryLotLineId + + UNION ALL + + SELECT + prev.inventoryLotLineId, + 'carry' AS kind, + NULL AS firstId, + NULL AS lastId, + CAST(0 AS DECIMAL(14,2)) AS inQty, + CAST(0 AS DECIMAL(14,2)) AS outQty, + prev.closing AS prevClosing + FROM stock_lot_day prev + 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 + ) + ) x + LEFT JOIN stock_ledger firstSl ON firstSl.id = x.firstId + LEFT JOIN stock_ledger lastSl ON lastSl.id = x.lastId + LEFT JOIN stock_lot_day d + ON d.inventoryLotLineId = x.inventoryLotLineId + AND d.date = :day + AND IFNULL(d.deleted, 0) = 0 + """.trimIndent(), + args, + ).firstOrNull() ?: emptyMap() + + val dayLots = support.toInt(dayTable["expected"]) + val dayMiss = support.toInt(dayTable["miss"]) + val dayIncorrect = support.toInt(dayTable["incorrect"]) + // No lot ids yet → SQL finds 0 lots, 0/0/0 would look "correct". Ledger exists but day table cannot be built. + val dayTablePending = cnt > 0 && dayLots == 0 + val dayTableMiss = if (dayTablePending) 1 else dayMiss + val dayTableTotal = if (dayTablePending) 1 else dayLots + + fun part(key: String, label: String, miss: Int, incorrect: Int, total: Int) = + StockLedgerFixCheckPart( + key = key, + label = label, + ok = (total - miss - incorrect).coerceAtLeast(0), + miss = miss, + incorrect = incorrect, + ) + + return StockLedgerFixDayDetail( + date = day.toString(), + cnt = cnt, + parts = fieldAndReasonParts( + cnt = cnt, + lotMiss = lotMiss, + lotIncorrect = lotIncorrect, + uomMiss = uomMiss, + uomIncorrect = uomIncorrect, + invMiss = invMiss, + invIncorrect = invIncorrect, + lotQtyMissWithLot = lotQtyMissWithLot, + lotQtyIncorrect = lotQtyIncorrect, + overIssue = overIssue, + balanceMiss = balanceMiss, + fixable21a = support.toInt(ledger["fixable21a"]), + fixable21b = support.toInt(ledger["fixable21b"]), + cannotMultiLine = support.toInt(ledger["cannotMultiLine"]), + cannotNoSource = support.toInt(ledger["cannotNoSource"]), + earlyTkeOver = 0, + otherOver = 0, + extra = listOf( + part("dayTable", "stock_lot_day", dayTableMiss, dayIncorrect, dayTableTotal), + ), + ), + ) + } + + open fun searchInventory(qRaw: String): List { + val q = qRaw.trim() + if (q.isEmpty()) throw IllegalArgumentException("q is required") + val args = mapOf("q" to q, "like" to "%$q%") + return support.jdbcDao.queryForList( + """ + SELECT + i.id AS inventoryId, + i.itemId AS itemId, + it.code AS itemCode, + i.uomId AS uomId, + ( + SELECT COUNT(*) FROM stock_ledger sl + WHERE sl.deleted = 0 AND sl.inventoryId = i.id + ) AS ledgerCnt + FROM inventory i + LEFT JOIN items it ON it.id = i.itemId + WHERE IFNULL(i.deleted, 0) = 0 + AND ( + CAST(i.id AS CHAR) = :q + OR CAST(i.itemId AS CHAR) = :q + OR it.code LIKE :like + ) + ORDER BY ledgerCnt DESC, i.id + LIMIT 20 + """.trimIndent(), + args, + ).map { row -> + StockLedgerFixSearchInventoryHit( + inventoryId = support.toLong(row["inventoryId"]), + itemId = support.toLongOrNull(row["itemId"]), + itemCode = row["itemCode"]?.toString(), + uomId = support.toLongOrNull(row["uomId"]), + ledgerCnt = support.toInt(row["ledgerCnt"]), + ) + } + } + + open fun searchLot(qRaw: String): List { + val q = qRaw.trim() + if (q.isEmpty()) throw IllegalArgumentException("q is required") + val args = mapOf("q" to q, "like" to "%$q%") + return support.jdbcDao.queryForList( + """ + SELECT + ill.id AS inventoryLotLineId, + il.lotNo AS lotNo, + it.code AS itemCode, + ( + SELECT sl.inventoryId FROM stock_ledger sl + WHERE sl.deleted = 0 AND sl.inventoryLotLineId = ill.id + AND sl.inventoryId IS NOT NULL + ORDER BY sl.id DESC LIMIT 1 + ) AS inventoryId, + ( + SELECT COUNT(*) FROM stock_ledger sl + WHERE sl.deleted = 0 AND sl.inventoryLotLineId = ill.id + ) AS ledgerCnt + FROM inventory_lot_line ill + INNER JOIN inventory_lot il ON il.id = ill.inventoryLotId + LEFT JOIN items it ON it.id = il.itemId + WHERE IFNULL(ill.deleted, 0) = 0 + AND ( + CAST(ill.id AS CHAR) = :q + OR il.lotNo LIKE :like + ) + ORDER BY ledgerCnt DESC, ill.id + LIMIT 20 + """.trimIndent(), + args, + ).map { row -> + StockLedgerFixSearchLotHit( + inventoryLotLineId = support.toLong(row["inventoryLotLineId"]), + lotNo = row["lotNo"]?.toString(), + itemCode = row["itemCode"]?.toString(), + inventoryId = support.toLongOrNull(row["inventoryId"]), + ledgerCnt = support.toInt(row["ledgerCnt"]), + ) + } + } + + open fun inventoryScopeDetail(inventoryId: Long): StockLedgerFixScopeDetail { + val args = mapOf("inventoryId" to inventoryId, "today" to support.inventoryScopeToExclusive()) + val head = support.jdbcDao.queryForList( + """ + SELECT + i.id AS inventoryId, + i.itemId AS itemId, + it.code AS itemCode, + i.uomId AS uomId + FROM inventory i + LEFT JOIN items it ON it.id = i.itemId + WHERE i.id = :inventoryId + """.trimIndent(), + args, + ).firstOrNull() ?: throw IllegalArgumentException("inventory not found: $inventoryId") + val stats = ledgerScopeStats( + "sl.inventoryId = :inventoryId", + args, + itemId = support.toLongOrNull(head["itemId"]), + ) + return StockLedgerFixScopeDetail( + kind = "inventory", + id = inventoryId, + itemCode = head["itemCode"]?.toString(), + lotNo = null, + inventoryId = inventoryId, + uomId = support.toLongOrNull(head["uomId"]), + firstDate = stats.firstDate, + lastDate = stats.lastDate, + cnt = stats.cnt, + lastBalance = stats.lastBalance, + lastLotQtyAfter = stats.lastLotQtyAfter, + parts = stats.parts, + ) + } + + open fun lotScopeDetail(lotLineId: Long): StockLedgerFixScopeDetail { + val today = LocalDate.now() + val args = mapOf("lotId" to lotLineId, "today" to today) + val head = support.jdbcDao.queryForList( + """ + SELECT + ill.id AS inventoryLotLineId, + il.lotNo AS lotNo, + il.itemId AS itemId, + it.code AS itemCode + FROM inventory_lot_line ill + INNER JOIN inventory_lot il ON il.id = ill.inventoryLotId + LEFT JOIN items it ON it.id = il.itemId + WHERE ill.id = :lotId + """.trimIndent(), + args, + ).firstOrNull() ?: throw IllegalArgumentException("lot line not found: $lotLineId") + val stats = ledgerScopeStats( + "sl.inventoryLotLineId = :lotId", + args, + itemId = support.toLongOrNull(head["itemId"]), + ) + return StockLedgerFixScopeDetail( + kind = "lot", + id = lotLineId, + itemCode = head["itemCode"]?.toString(), + lotNo = head["lotNo"]?.toString(), + inventoryId = support.toLongOrNull(stats.lastInventoryId), + uomId = null, + firstDate = stats.firstDate, + lastDate = stats.lastDate, + cnt = stats.cnt, + lastBalance = stats.lastBalance, + lastLotQtyAfter = stats.lastLotQtyAfter, + parts = stats.parts, + ) + } + + private data class ScopeStats( + val cnt: Int, + val firstDate: String?, + val lastDate: String?, + val lastBalance: String?, + val lastLotQtyAfter: String?, + val lastInventoryId: Any?, + val parts: List, + ) + + private fun ledgerScopeStats( + filter: String, + args: Map, + itemId: Long? = null, + ): ScopeStats { + val ledger = support.jdbcDao.queryForList( + """ + SELECT + COUNT(*) AS cnt, + DATE_FORMAT(MIN(sl.date), '%Y-%m-%d') AS firstDate, + DATE_FORMAT(MAX(sl.date), '%Y-%m-%d') AS lastDate, + SUM(sl.inventoryLotLineId IS NULL) AS lotMiss, + SUM(sl.uomId IS NULL) AS uomMiss, + SUM(sl.inventoryId IS NULL) AS invMiss, + SUM( + sl.inventoryLotLineId IS NOT NULL + AND (sl.lotQtyAfter IS NULL OR sl.lotQtyBefore IS NULL) + ) AS lotQtyMissWithLot, + SUM( + sl.lotQtyAfter IS NOT NULL + AND sl.lotQtyBefore IS NOT NULL + AND sl.lotQtyAfter - sl.lotQtyBefore + <> CAST(COALESCE(sl.inQty, 0) AS DECIMAL(14,2)) + - CAST(COALESCE(sl.outQty, 0) AS DECIMAL(14,2)) + ) AS lotQtyIncorrect, + SUM( + sl.lotQtyAfter IS NOT NULL + AND sl.lotQtyBefore IS NOT NULL + AND sl.lotQtyAfter - sl.lotQtyBefore + = CAST(COALESCE(sl.inQty, 0) AS DECIMAL(14,2)) + - CAST(COALESCE(sl.outQty, 0) AS DECIMAL(14,2)) + AND (sl.lotQtyAfter < 0 OR sl.lotQtyBefore < 0) + ) AS overIssue, + SUM(sl.balance IS NULL) AS balanceMiss, + SUM( + sl.inventoryLotLineId IS NULL + AND COALESCE(sol.inventoryLotLineId, sil.inventoryLotLineId) IS NOT NULL + ) AS fixable21a, + SUM( + sl.inventoryLotLineId IS NULL + AND COALESCE(sol.inventoryLotLineId, sil.inventoryLotLineId) IS NULL + AND sil.inventoryLotId IS NOT NULL + AND IFNULL(lc.lineCnt, 0) = 1 + ) AS fixable21b, + SUM( + sl.inventoryLotLineId IS NULL + AND COALESCE(sol.inventoryLotLineId, sil.inventoryLotLineId) IS NULL + AND sil.inventoryLotId IS NOT NULL + AND IFNULL(lc.lineCnt, 0) > 1 + ) AS cannotMultiLine, + SUM( + sl.inventoryLotLineId IS NULL + AND COALESCE(sol.inventoryLotLineId, sil.inventoryLotLineId) IS NULL + AND (sil.inventoryLotId IS NULL OR IFNULL(lc.lineCnt, 0) = 0) + ) AS cannotNoSource + FROM stock_ledger sl + LEFT JOIN stock_out_line sol + ON sol.id = sl.stockOutLineId AND IFNULL(sol.deleted, 0) = 0 + LEFT JOIN stock_in_line sil + ON sil.id = sl.stockInLineId AND IFNULL(sil.deleted, 0) = 0 + LEFT JOIN ( + SELECT inventoryLotId, COUNT(*) AS lineCnt + FROM inventory_lot_line + WHERE IFNULL(deleted, 0) = 0 + GROUP BY inventoryLotId + ) lc ON lc.inventoryLotId = sil.inventoryLotId + WHERE sl.deleted = 0 + AND sl.date < :today + AND $filter + """.trimIndent(), + args, + ).firstOrNull() ?: emptyMap() + val last = support.jdbcDao.queryForList( + """ + SELECT sl.balance, sl.lotQtyAfter, sl.inventoryId + FROM stock_ledger sl + WHERE sl.deleted = 0 + AND sl.date < :today + AND $filter + ORDER BY sl.id DESC + LIMIT 1 + """.trimIndent(), + args, + ).firstOrNull() ?: emptyMap() + val cnt = support.toInt(ledger["cnt"]) + val overIssue = support.toInt(ledger["overIssue"]) + val earlyTke = if (overIssue > 0) overIssueEarlyTkeRows(filter, args, itemId) else 0 + return ScopeStats( + cnt = cnt, + firstDate = ledger["firstDate"]?.toString(), + lastDate = ledger["lastDate"]?.toString(), + lastBalance = last["balance"]?.toString(), + lastLotQtyAfter = last["lotQtyAfter"]?.toString(), + lastInventoryId = last["inventoryId"], + parts = fieldAndReasonParts( + cnt = cnt, + lotMiss = support.toInt(ledger["lotMiss"]), + lotIncorrect = 0, + uomMiss = support.toInt(ledger["uomMiss"]), + uomIncorrect = 0, + invMiss = support.toInt(ledger["invMiss"]), + invIncorrect = 0, + lotQtyMissWithLot = support.toInt(ledger["lotQtyMissWithLot"]), + lotQtyIncorrect = support.toInt(ledger["lotQtyIncorrect"]), + overIssue = overIssue, + balanceMiss = support.toInt(ledger["balanceMiss"]), + fixable21a = support.toInt(ledger["fixable21a"]), + fixable21b = support.toInt(ledger["fixable21b"]), + cannotMultiLine = support.toInt(ledger["cannotMultiLine"]), + cannotNoSource = support.toInt(ledger["cannotNoSource"]), + earlyTkeOver = earlyTke, + otherOver = (overIssue - earlyTke).coerceAtLeast(0), + ), + ) + } + + private fun fieldAndReasonParts( + cnt: Int, + lotMiss: Int, + lotIncorrect: Int, + uomMiss: Int, + uomIncorrect: Int, + invMiss: Int, + invIncorrect: Int, + lotQtyMissWithLot: Int, + lotQtyIncorrect: Int, + overIssue: Int, + balanceMiss: Int, + fixable21a: Int, + fixable21b: Int, + cannotMultiLine: Int, + cannotNoSource: Int, + earlyTkeOver: Int, + otherOver: Int, + extra: List = emptyList(), + ): List { + fun field(key: String, label: String, miss: Int, incorrect: Int) = + StockLedgerFixCheckPart( + key = key, + label = label, + ok = (cnt - miss - incorrect).coerceAtLeast(0), + miss = miss, + incorrect = incorrect, + group = "field", + ) + fun canFix(key: String, label: String, n: Int) = + StockLedgerFixCheckPart(key, label, ok = 0, miss = n, incorrect = 0, group = "canFix") + fun cannotFix(key: String, label: String, n: Int) = + StockLedgerFixCheckPart(key, label, ok = 0, miss = 0, incorrect = n, group = "cannotFix") + return buildList { + add(field("lot", "inventoryLotLineId", lotMiss, lotIncorrect)) + add(field("uom", "uomId", uomMiss, uomIncorrect)) + add(field("inventory", "inventoryId", invMiss, invIncorrect)) + add(field("lotQty", "lotQtyBefore/After(lot 已有)", lotQtyMissWithLot, lotQtyIncorrect)) + add(field("overIssue", "over-issue (lotQty < 0)", 0, overIssue)) + add(field("balance", "balance", balanceMiss, 0)) + addAll(extra) + add(canFix("fixable21a", "可修 2.1a:SIL/SOL 已有 lot line", fixable21a)) + add(canFix("fixable21b", "可修 2.1b:inventoryLotId 僅一條 line", fixable21b)) + add(canFix("fixableLotQty", "可修:lot 已有但缺 lotQty", lotQtyMissWithLot)) + add(cannotFix("cannotNoSource", "不能修:沒有 SIL/SOL lot 來源", cannotNoSource)) + add(cannotFix("cannotMultiLine", "不能修:同一 lot 有多條 warehouse line", cannotMultiLine)) + add(cannotFix("earlyTke", "不能修:舊 TKE 開新批(sibling 盤盈)", earlyTkeOver)) + add(cannotFix("realOver", "不能修:over-issue 非舊 TKE(真超發/帳不一致)", otherOver)) + } + } + + private fun overIssueEarlyTkeRows(filter: String, args: Map, itemId: Long?): Int { + if (itemId == null || itemId <= 0) return 0 + val q = args.toMutableMap() + q["itemId"] = itemId + return support.toInt( + support.jdbcDao.queryForList( + """ + SELECT SUM(ok) AS c FROM ( + SELECT EXISTS ( + SELECT 1 + FROM inventory_lot_line ill2 + INNER JOIN inventory_lot il2 + ON il2.id = ill2.inventoryLotId AND il2.itemId = :itemId + INNER JOIN ( + SELECT sl3.inventoryLotLineId + FROM stock_ledger sl3 + INNER JOIN inventory_lot_line ill3 ON ill3.id = sl3.inventoryLotLineId + INNER JOIN inventory_lot il3 ON il3.id = ill3.inventoryLotId AND il3.itemId = :itemId + WHERE sl3.deleted = 0 + AND sl3.date < :today + GROUP BY sl3.inventoryLotLineId + HAVING SUM(COALESCE(sl3.inQty, 0)) > 0 + AND SUM(CASE WHEN sl3.type = 'TKE' AND sl3.inQty > 0 THEN sl3.inQty ELSE 0 END) + >= SUM(COALESCE(sl3.inQty, 0)) * 0.99 + ) tke ON tke.inventoryLotLineId = ill2.id + WHERE IFNULL(ill2.deleted, 0) = 0 + AND ill2.warehouseId = ill.warehouseId + AND ill2.id <> sl.inventoryLotLineId + ) AS ok + FROM stock_ledger sl + INNER JOIN inventory_lot_line ill ON ill.id = sl.inventoryLotLineId + WHERE sl.deleted = 0 + AND sl.date < :today + AND $filter + AND sl.lotQtyAfter IS NOT NULL + AND sl.lotQtyBefore IS NOT NULL + AND sl.lotQtyAfter - sl.lotQtyBefore + = CAST(COALESCE(sl.inQty, 0) AS DECIMAL(14,2)) + - CAST(COALESCE(sl.outQty, 0) AS DECIMAL(14,2)) + AND (sl.lotQtyAfter < 0 OR sl.lotQtyBefore < 0) + ) x + """.trimIndent(), + q, + ).firstOrNull()?.get("c"), + ) + } +} diff --git a/src/main/java/com/ffii/fpsms/modules/master/service/ledgerfix/StockLedgerFixSupport.kt b/src/main/java/com/ffii/fpsms/modules/master/service/ledgerfix/StockLedgerFixSupport.kt new file mode 100644 index 0000000..cf5a3b6 --- /dev/null +++ b/src/main/java/com/ffii/fpsms/modules/master/service/ledgerfix/StockLedgerFixSupport.kt @@ -0,0 +1,137 @@ +package com.ffii.fpsms.modules.master.service.ledgerfix + +import com.ffii.core.support.JdbcDao +import org.slf4j.LoggerFactory +import org.springframework.jdbc.core.namedparam.NamedParameterJdbcTemplate +import org.springframework.stereotype.Component +import org.springframework.transaction.PlatformTransactionManager +import org.springframework.transaction.support.TransactionTemplate +import java.math.BigDecimal +import java.time.LocalDate +import java.time.format.DateTimeParseException +import java.util.concurrent.ConcurrentHashMap +import javax.sql.DataSource + +/** Shared JDBC, conversions, and the single in-flight lock for stock-ledger-fix. */ +@Component +open class StockLedgerFixSupport( + val jdbcDao: JdbcDao, + val transactionManager: PlatformTransactionManager, + val dataSource: DataSource, +) { + private val log = LoggerFactory.getLogger(StockLedgerFixSupport::class.java) + val namedJdbc = NamedParameterJdbcTemplate(dataSource) + private val inFlight = ConcurrentHashMap.newKeySet() + + fun tryLock(key: String): Boolean = inFlight.add(key) + + fun unlock(key: String) { + inFlight.remove(key) + } + + /** Exclusive bound for inventory-scope inspect/fix: include calendar today. */ + fun inventoryScopeToExclusive(): LocalDate = LocalDate.now().plusDays(1) + + fun dayArgs(day: LocalDate): Map = + mapOf( + "day" to day, + "dayNext" to day.plusDays(1), + "prevDay" to day.minusDays(1), + ) + + /** Inclusive [from]..[to]; same keys as [dayArgs] (used by stillMiss over the whole range). */ + fun rangeArgs(from: LocalDate, to: LocalDate): Map = + mapOf( + "day" to from, + "dayNext" to to.plusDays(1), + "prevDay" to from.minusDays(1), + ) + + /** Same JDBC connection so TEMPORARY TABLEs survive across statements. */ + fun withTempTx(block: () -> Int): Int { + return TransactionTemplate(transactionManager).execute { block() } ?: 0 + } + + fun timed(name: String, block: () -> Int): Int { + val t0 = System.nanoTime() + return try { + val n = block() + log.info("stock-ledger-fix step {} rows={} {}ms", name, n, (System.nanoTime() - t0) / 1_000_000) + n + } catch (e: Exception) { + log.warn("stock-ledger-fix step {} FAILED {}ms: {}", name, (System.nanoTime() - t0) / 1_000_000, e.message) + throw e + } + } + + fun parseDay(raw: String): LocalDate { + val trimmed = raw.trim() + if (trimmed.isEmpty()) throw IllegalArgumentException("date is required (yyyy-MM-dd)") + return try { + LocalDate.parse(trimmed) + } catch (_: DateTimeParseException) { + throw IllegalArgumentException("date must be yyyy-MM-dd") + } + } + + + fun parseDaySteps(raw: List?): Set { + if (raw.isNullOrEmpty() || raw.any { it.trim().equals("all", ignoreCase = true) }) { + return DAY_STEPS + } + val out = linkedSetOf() + raw.forEach { token -> + val t = token.trim().lowercase() + val key = when (t) { + "2.1", "21" -> "2.1" + "2.2", "22" -> "2.2" + "2.3", "23" -> "2.3" + "2.4", "24" -> "2.4" + "2.5", "25" -> "2.5" + "2.6", "26" -> "2.6" + else -> throw IllegalArgumentException("unknown step '$token' (use 2.1–2.6 or all)") + } + out.add(key) + } + if (out.isEmpty()) throw IllegalArgumentException("steps must not be empty") + return out + } + + fun toInt(value: Any?): Int = when (value) { + null -> 0 + is Number -> value.toInt() + else -> value.toString().toIntOrNull() ?: 0 + } + + fun toLong(value: Any?): Long = toLongOrNull(value) ?: 0L + + fun toLongOrNull(value: Any?): Long? = when (value) { + null -> null + is Number -> value.toLong() + else -> value.toString().toLongOrNull() + } + + fun toDecimal(value: Any?): BigDecimal = when (value) { + null -> BigDecimal.ZERO + is BigDecimal -> value + is Number -> BigDecimal(value.toString()) + else -> value.toString().toBigDecimalOrNull() ?: BigDecimal.ZERO + } + + fun toLocalDate(value: Any?): LocalDate? = when (value) { + null -> null + is LocalDate -> value + is java.sql.Date -> value.toLocalDate() + is java.sql.Timestamp -> value.toLocalDateTime().toLocalDate() + else -> try { + LocalDate.parse(value.toString().take(10)) + } catch (_: Exception) { + null + } + } + companion object { + const val INVENTORY_LOCK = "inventory-1.0" + const val RUN_LOCK = "ledger-fix-run" + private val DAY_STEPS = linkedSetOf("2.1", "2.2", "2.3", "2.4", "2.5", "2.6") + } +}