| @@ -24,7 +24,8 @@ open class StockLedgerFixAdjService( | |||||
| * Quantity is onHandQty minus the bucket ledger net, including rows with no lot. | * Quantity is onHandQty minus the bucket ledger net, including rows with no lot. | ||||
| * The row is attached to the lot line whose own net is furthest from its ledger. | * The row is attached to the lot line whose own net is furthest from its ledger. | ||||
| * Lot lines and inventory are not updated, so the lot-line trigger does not run. | * Lot lines and inventory are not updated, so the lot-line trigger does not run. | ||||
| * A bucket whose ledger net already equals onHandQty is skipped. A second run inserts nothing. | |||||
| * Each ADJ line also gets one stock_adjustment_record. A later run fills any ADJ line that still has none. | |||||
| * A bucket whose ledger net already equals onHandQty is skipped. A second run inserts no new ADJ. | |||||
| * All inbound lines share one stock_in; all outbound lines share one stock_out (per adjDate). | * All inbound lines share one stock_in; all outbound lines share one stock_out (per adjDate). | ||||
| * Ledger net uses `date < adjDate+1`. | * Ledger net uses `date < adjDate+1`. | ||||
| */ | */ | ||||
| @@ -96,6 +97,10 @@ open class StockLedgerFixAdjService( | |||||
| } | } | ||||
| } | } | ||||
| } | } | ||||
| val adjustmentRecords = TransactionTemplate(support.transactionManager).let { tx -> | |||||
| tx.timeout = 300 | |||||
| tx.execute { insertMissingAdjustmentRecords() } ?: 0 | |||||
| } | |||||
| val lotIds = linkedSetOf<Long>() | val lotIds = linkedSetOf<Long>() | ||||
| fun addLot(lotId: Long, invId: Long?) { | fun addLot(lotId: Long, invId: Long?) { | ||||
| if (lotId > 0) lotIds.add(lotId) | if (lotId > 0) lotIds.add(lotId) | ||||
| @@ -122,8 +127,8 @@ open class StockLedgerFixAdjService( | |||||
| support.timed("adj 2.6") { backfill.upsertStockLotDayRange(lotIds.toList(), range.first, range.second) } | support.timed("adj 2.6") { backfill.upsertStockLotDayRange(lotIds.toList(), range.first, range.second) } | ||||
| } else 0 | } else 0 | ||||
| log.info( | log.info( | ||||
| "stock-ledger-fix adj {} buckets={} in={} out={} lotQty={} bal={} day={} total={}ms", | |||||
| adjDate, plans.size, insertedIn, insertedOut, | |||||
| "stock-ledger-fix adj {} buckets={} in={} out={} adjRecord={} lotQty={} bal={} day={} total={}ms", | |||||
| adjDate, plans.size, insertedIn, insertedOut, adjustmentRecords, | |||||
| filledLotQty, filledBalance, dayRows, | filledLotQty, filledBalance, dayRows, | ||||
| (System.nanoTime() - t0) / 1_000_000, | (System.nanoTime() - t0) / 1_000_000, | ||||
| ) | ) | ||||
| @@ -135,6 +140,7 @@ open class StockLedgerFixAdjService( | |||||
| filledLotQty = filledLotQty, | filledLotQty = filledLotQty, | ||||
| filledBalance = filledBalance, | filledBalance = filledBalance, | ||||
| dayRowsWritten = dayRows, | dayRowsWritten = dayRows, | ||||
| adjustmentRecords = adjustmentRecords, | |||||
| ) | ) | ||||
| } catch (e: Exception) { | } catch (e: Exception) { | ||||
| log.warn("stock-ledger-fix adj FAILED after {}ms: {}", (System.nanoTime() - t0) / 1_000_000, e.message) | log.warn("stock-ledger-fix adj FAILED after {}ms: {}", (System.nanoTime() - t0) / 1_000_000, e.message) | ||||
| @@ -475,6 +481,50 @@ open class StockLedgerFixAdjService( | |||||
| ) | ) | ||||
| } | } | ||||
| /** One remarks row per stock-ledger-fix ADJ line that does not already have one. */ | |||||
| private fun insertMissingAdjustmentRecords(): Int { | |||||
| return support.jdbcDao.executeUpdate( | |||||
| """ | |||||
| INSERT INTO stock_adjustment_record ( | |||||
| created, createdBy, version, modified, modifiedBy, deleted, | |||||
| itemId, itemCode, itemName, lotNo, inQty, outQty, remarks, | |||||
| stockInLineId, stockOutLineId | |||||
| ) | |||||
| SELECT | |||||
| NOW(), 'stock-ledger-fix', 0, NOW(), 'stock-ledger-fix', 0, | |||||
| sl.itemId, | |||||
| LEFT(COALESCE(sl.itemCode, it.code), 50), | |||||
| LEFT(it.name, 200), | |||||
| LEFT(il.lotNo, 512), | |||||
| CASE WHEN sl.stockInLineId IS NOT NULL THEN sl.inQty ELSE NULL END, | |||||
| CASE WHEN sl.stockOutLineId IS NOT NULL THEN sl.outQty ELSE NULL END, | |||||
| LEFT(COALESCE(sil.remarks, so.remarks, 'stock-ledger-fix 2.7 ADJ'), 500), | |||||
| sl.stockInLineId, | |||||
| sl.stockOutLineId | |||||
| FROM stock_ledger sl | |||||
| INNER JOIN items it ON it.id = sl.itemId | |||||
| LEFT JOIN inventory_lot_line ill ON ill.id = sl.inventoryLotLineId | |||||
| LEFT JOIN inventory_lot il ON il.id = ill.inventoryLotId | |||||
| LEFT JOIN stock_in_line sil ON sil.id = sl.stockInLineId | |||||
| LEFT JOIN stock_out_line sol ON sol.id = sl.stockOutLineId | |||||
| LEFT JOIN stock_out so ON so.id = sol.stockOutId | |||||
| WHERE sl.deleted = 0 | |||||
| AND sl.type = 'ADJ' | |||||
| AND sl.createdBy = 'stock-ledger-fix' | |||||
| AND (sl.stockInLineId IS NOT NULL OR sl.stockOutLineId IS NOT NULL) | |||||
| AND NOT EXISTS ( | |||||
| SELECT 1 | |||||
| FROM stock_adjustment_record r | |||||
| WHERE IFNULL(r.deleted, 0) = 0 | |||||
| AND ( | |||||
| (sl.stockInLineId IS NOT NULL AND r.stockInLineId = sl.stockInLineId) | |||||
| OR (sl.stockOutLineId IS NOT NULL AND r.stockOutLineId = sl.stockOutLineId) | |||||
| ) | |||||
| ) | |||||
| """.trimIndent(), | |||||
| ) | |||||
| } | |||||
| /** Line on shared stock_in; does not UPDATE inventory_lot_line or inventory. */ | /** Line on shared stock_in; does not UPDATE inventory_lot_line or inventory. */ | ||||
| private fun insertAdjStockInLine( | private fun insertAdjStockInLine( | ||||
| g: AdjGap, | g: AdjGap, | ||||
| @@ -79,7 +79,8 @@ open class StockLedgerFixBackfillService( | |||||
| "uomId" to (inv["uomId"] ?: 0), | "uomId" to (inv["uomId"] ?: 0), | ||||
| "today" to toExclusive, | "today" to toExclusive, | ||||
| ) | ) | ||||
| val filledLotLineId = support.timed("inv 2.1") { fillLotLineIdScope(args, byInventory = true) } | |||||
| val lotStep = support.timedValue("inv 2.1") { fillLotLineIdScope(args, byInventory = true) } | |||||
| val filledLotLineId = lotStep.filled | |||||
| val filledUomId = support.timed("inv 2.2") { fillUomIdScope(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 filledInventoryId = support.timed("inv 2.3") { fillInventoryIdScope(args, byInventory = true) } | ||||
| val filledLotQty = support.timed("inv 2.4") { fillLotQtyHistory(args, byInventory = true) } | val filledLotQty = support.timed("inv 2.4") { fillLotQtyHistory(args, byInventory = true) } | ||||
| @@ -107,6 +108,11 @@ open class StockLedgerFixBackfillService( | |||||
| stillMissUom = still.missUom, | stillMissUom = still.missUom, | ||||
| stillMissInventoryId = still.missInventoryId, | stillMissInventoryId = still.missInventoryId, | ||||
| stillMissLotQty = still.missLotQty, | stillMissLotQty = still.missLotQty, | ||||
| deletedUnlinkedLot = lotStep.deletedUnlinked, | |||||
| existingLotMatch = lotStep.existingMatch, | |||||
| existingLotMismatch = lotStep.existingMismatch, | |||||
| deletedBlankOutLine = lotStep.deletedBlankOutLine, | |||||
| alignedOutLine = lotStep.alignedOutLine, | |||||
| ) | ) | ||||
| } catch (e: Exception) { | } catch (e: Exception) { | ||||
| log.warn("stock-ledger-fix inventory {} FAILED after {}ms: {}", inventoryId, (System.nanoTime() - t0) / 1_000_000, e.message) | log.warn("stock-ledger-fix inventory {} FAILED after {}ms: {}", inventoryId, (System.nanoTime() - t0) / 1_000_000, e.message) | ||||
| @@ -130,7 +136,8 @@ open class StockLedgerFixBackfillService( | |||||
| ) | ) | ||||
| if (exists == 0) throw IllegalArgumentException("lot line not found: $lotLineId") | if (exists == 0) throw IllegalArgumentException("lot line not found: $lotLineId") | ||||
| val args = mapOf("lotId" to lotLineId, "today" to today) | val args = mapOf("lotId" to lotLineId, "today" to today) | ||||
| val filledLotLineId = support.timed("lot 2.1") { fillLotLineIdScope(args, byInventory = false) } | |||||
| val lotStep = support.timedValue("lot 2.1") { fillLotLineIdScope(args, byInventory = false) } | |||||
| val filledLotLineId = lotStep.filled | |||||
| val filledUomId = support.timed("lot 2.2") { fillUomIdScope(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 filledInventoryId = support.timed("lot 2.3") { fillInventoryIdScope(args, byInventory = false) } | ||||
| val filledLotQty = support.timed("lot 2.4") { fillLotQtyHistory(args, byInventory = false) } | val filledLotQty = support.timed("lot 2.4") { fillLotQtyHistory(args, byInventory = false) } | ||||
| @@ -156,6 +163,11 @@ open class StockLedgerFixBackfillService( | |||||
| stillMissUom = still.missUom, | stillMissUom = still.missUom, | ||||
| stillMissInventoryId = still.missInventoryId, | stillMissInventoryId = still.missInventoryId, | ||||
| stillMissLotQty = still.missLotQty, | stillMissLotQty = still.missLotQty, | ||||
| deletedUnlinkedLot = lotStep.deletedUnlinked, | |||||
| existingLotMatch = lotStep.existingMatch, | |||||
| existingLotMismatch = lotStep.existingMismatch, | |||||
| deletedBlankOutLine = lotStep.deletedBlankOutLine, | |||||
| alignedOutLine = lotStep.alignedOutLine, | |||||
| ) | ) | ||||
| } catch (e: Exception) { | } catch (e: Exception) { | ||||
| log.warn("stock-ledger-fix lot {} FAILED after {}ms: {}", lotLineId, (System.nanoTime() - t0) / 1_000_000, e.message) | log.warn("stock-ledger-fix lot {} FAILED after {}ms: {}", lotLineId, (System.nanoTime() - t0) / 1_000_000, e.message) | ||||
| @@ -184,7 +196,8 @@ open class StockLedgerFixBackfillService( | |||||
| val t0 = System.nanoTime() | val t0 = System.nanoTime() | ||||
| try { | try { | ||||
| val args = support.dayArgs(day) | val args = support.dayArgs(day) | ||||
| val filledLotLineId = if ("2.1" in steps) support.timed("2.1 fillLotLineId") { fillLotLineId(args) } else 0 | |||||
| val lotStep = if ("2.1" in steps) support.timedValue("2.1 fillLotLineId") { fillLotLineId(args) } else LotLineStep(0, 0, 0, 0) | |||||
| val filledLotLineId = lotStep.filled | |||||
| val filledUomId = if ("2.2" in steps) support.timed("2.2 fillUomId") { fillUomId(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 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 filledLotQty = if ("2.4" in steps) support.timed("2.4 fillLotQty") { fillLotQty(args) } else 0 | ||||
| @@ -215,6 +228,11 @@ open class StockLedgerFixBackfillService( | |||||
| stillMissUom = still.missUom, | stillMissUom = still.missUom, | ||||
| stillMissInventoryId = still.missInventoryId, | stillMissInventoryId = still.missInventoryId, | ||||
| stillMissLotQty = still.missLotQty, | stillMissLotQty = still.missLotQty, | ||||
| deletedUnlinkedLot = lotStep.deletedUnlinked, | |||||
| existingLotMatch = lotStep.existingMatch, | |||||
| existingLotMismatch = lotStep.existingMismatch, | |||||
| deletedBlankOutLine = lotStep.deletedBlankOutLine, | |||||
| alignedOutLine = lotStep.alignedOutLine, | |||||
| ) | ) | ||||
| } catch (e: Exception) { | } catch (e: Exception) { | ||||
| log.warn( | log.warn( | ||||
| @@ -251,6 +269,11 @@ open class StockLedgerFixBackfillService( | |||||
| val t0 = System.nanoTime() | val t0 = System.nanoTime() | ||||
| try { | try { | ||||
| var filledLotLineId = 0 | var filledLotLineId = 0 | ||||
| var deletedUnlinkedLot = 0 | |||||
| var existingLotMatch = 0 | |||||
| var existingLotMismatch = 0 | |||||
| var deletedBlankOutLine = 0 | |||||
| var alignedOutLine = 0 | |||||
| var filledUomId = 0 | var filledUomId = 0 | ||||
| var filledInventoryId = 0 | var filledInventoryId = 0 | ||||
| var filledLotQty = 0 | var filledLotQty = 0 | ||||
| @@ -259,7 +282,15 @@ open class StockLedgerFixBackfillService( | |||||
| var d = from | var d = from | ||||
| while (!d.isAfter(to)) { | while (!d.isAfter(to)) { | ||||
| val args = support.dayArgs(d) | val args = support.dayArgs(d) | ||||
| if ("2.1" in steps) filledLotLineId += support.timed("range $d 2.1") { fillLotLineId(args) } | |||||
| if ("2.1" in steps) { | |||||
| val lotStep = support.timedValue("range $d 2.1") { fillLotLineId(args) } | |||||
| filledLotLineId += lotStep.filled | |||||
| deletedUnlinkedLot += lotStep.deletedUnlinked | |||||
| existingLotMatch += lotStep.existingMatch | |||||
| existingLotMismatch += lotStep.existingMismatch | |||||
| deletedBlankOutLine += lotStep.deletedBlankOutLine | |||||
| alignedOutLine += lotStep.alignedOutLine | |||||
| } | |||||
| if ("2.2" in steps) filledUomId += support.timed("range $d 2.2") { fillUomId(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.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.4" in steps) filledLotQty += support.timed("range $d 2.4") { fillLotQty(args) } | ||||
| @@ -290,6 +321,11 @@ open class StockLedgerFixBackfillService( | |||||
| stillMissUom = still.missUom, | stillMissUom = still.missUom, | ||||
| stillMissInventoryId = still.missInventoryId, | stillMissInventoryId = still.missInventoryId, | ||||
| stillMissLotQty = still.missLotQty, | stillMissLotQty = still.missLotQty, | ||||
| deletedUnlinkedLot = deletedUnlinkedLot, | |||||
| existingLotMatch = existingLotMatch, | |||||
| existingLotMismatch = existingLotMismatch, | |||||
| deletedBlankOutLine = deletedBlankOutLine, | |||||
| alignedOutLine = alignedOutLine, | |||||
| ) | ) | ||||
| } catch (e: Exception) { | } catch (e: Exception) { | ||||
| log.warn( | log.warn( | ||||
| @@ -302,8 +338,22 @@ open class StockLedgerFixBackfillService( | |||||
| } | } | ||||
| } | } | ||||
| /** 2.1 fill, then soft-delete rows whose in/out line still cannot point at a lot line. */ | |||||
| private fun fillLotLineId(args: Map<String, Any>): LotLineStep { | |||||
| val filled = fillLotLineIdOnly(args) | |||||
| val where = "sl.date >= :day AND sl.date < :dayNext" | |||||
| val deleted = softDeleteUnlinkedLot(where, args) | |||||
| val outLine = alignExistingOutLine(where, args) | |||||
| val check = countExistingLotLineMatch(where, args) | |||||
| return LotLineStep( | |||||
| filled, deleted, check.first, check.second, | |||||
| deletedBlankOutLine = outLine.first, | |||||
| alignedOutLine = outLine.second, | |||||
| ) | |||||
| } | |||||
| /** 2.1 SOL / SIL lot line; one-line fallback when SIL has lot header only. */ | /** 2.1 SOL / SIL lot line; one-line fallback when SIL has lot header only. */ | ||||
| private fun fillLotLineId(args: Map<String, Any>): Int { | |||||
| private fun fillLotLineIdOnly(args: Map<String, Any>): Int { | |||||
| val fromSolSil = support.timed("2.1a solSil") { | val fromSolSil = support.timed("2.1a solSil") { | ||||
| support.jdbcDao.executeUpdate( | support.jdbcDao.executeUpdate( | ||||
| """ | """ | ||||
| @@ -358,6 +408,136 @@ open class StockLedgerFixBackfillService( | |||||
| return fromSolSil + fromOneLine | return fromSolSil + fromOneLine | ||||
| } | } | ||||
| /** | |||||
| * Rows that already have a lot line stay. Soft-delete only when a related | |||||
| * stock in/out line exists and 2.1a/2.1b still could not set inventoryLotLineId. | |||||
| */ | |||||
| private fun softDeleteUnlinkedLot( | |||||
| dateWhere: String, | |||||
| args: Map<String, Any>, | |||||
| extraAnd: String = "1 = 1", | |||||
| ): Int { | |||||
| return support.jdbcDao.executeUpdate( | |||||
| """ | |||||
| UPDATE stock_ledger sl | |||||
| LEFT JOIN stock_in_line sil | |||||
| ON sil.id = sl.stockInLineId AND IFNULL(sil.deleted, 0) = 0 | |||||
| SET | |||||
| sl.deleted = 1, | |||||
| sl.modified = NOW(), | |||||
| sl.modifiedBy = 'stock-ledger-fix' | |||||
| WHERE sl.deleted = 0 | |||||
| AND ($dateWhere) | |||||
| AND sl.inventoryLotLineId IS NULL | |||||
| AND (sl.stockInLineId IS NOT NULL OR sl.stockOutLineId IS NOT NULL) | |||||
| AND ($extraAnd) | |||||
| """.trimIndent(), | |||||
| args, | |||||
| ) | |||||
| } | |||||
| /** Ledger lot line vs live SIL/SOL lot line. Does not update those rows. */ | |||||
| private fun countExistingLotLineMatch(dateWhere: String, args: Map<String, Any>): Pair<Int, Int> { | |||||
| val row = support.jdbcDao.queryForList( | |||||
| """ | |||||
| SELECT | |||||
| SUM( | |||||
| sl.inventoryLotLineId IS NOT NULL | |||||
| AND (sl.stockInLineId IS NOT NULL OR sl.stockOutLineId IS NOT NULL) | |||||
| AND COALESCE(sol.inventoryLotLineId, sil.inventoryLotLineId) = sl.inventoryLotLineId | |||||
| ) AS matched, | |||||
| SUM( | |||||
| sl.inventoryLotLineId IS NOT NULL | |||||
| AND (sl.stockInLineId IS NOT NULL OR sl.stockOutLineId IS NOT NULL) | |||||
| AND ( | |||||
| COALESCE(sol.inventoryLotLineId, sil.inventoryLotLineId) IS NULL | |||||
| OR COALESCE(sol.inventoryLotLineId, sil.inventoryLotLineId) <> sl.inventoryLotLineId | |||||
| ) | |||||
| ) AS mismatched | |||||
| 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 | |||||
| WHERE sl.deleted = 0 | |||||
| AND ($dateWhere) | |||||
| """.trimIndent(), | |||||
| args, | |||||
| ).firstOrNull() | |||||
| return support.toInt(row?.get("matched")) to support.toInt(row?.get("mismatched")) | |||||
| } | |||||
| private data class LotLineStep( | |||||
| val filled: Int, | |||||
| val deletedUnlinked: Int, | |||||
| val existingMatch: Int, | |||||
| val existingMismatch: Int, | |||||
| val deletedBlankOutLine: Int = 0, | |||||
| val alignedOutLine: Int = 0, | |||||
| ) | |||||
| /** | |||||
| * Ledger already has a lot line. | |||||
| * Stock-out line lot is null: clear the ledger lot and soft-delete the ledger. | |||||
| * Stock-out line lot differs: set the line to the ledger lot. Skip a line whose live ledgers disagree. | |||||
| */ | |||||
| private fun alignExistingOutLine( | |||||
| dateWhere: String, | |||||
| args: Map<String, Any>, | |||||
| extraAnd: String = "1 = 1", | |||||
| ): Pair<Int, Int> { | |||||
| val aligned = support.jdbcDao.executeUpdate( | |||||
| """ | |||||
| UPDATE stock_out_line sol | |||||
| INNER JOIN ( | |||||
| SELECT sl.stockOutLineId AS lineId, MIN(sl.inventoryLotLineId) AS lotLineId | |||||
| FROM stock_ledger sl | |||||
| WHERE sl.deleted = 0 | |||||
| AND ($dateWhere) | |||||
| AND sl.stockOutLineId IS NOT NULL | |||||
| AND sl.inventoryLotLineId IS NOT NULL | |||||
| AND ($extraAnd) | |||||
| AND NOT EXISTS ( | |||||
| SELECT 1 | |||||
| FROM stock_ledger sl2 | |||||
| WHERE sl2.deleted = 0 | |||||
| AND sl2.stockOutLineId = sl.stockOutLineId | |||||
| AND sl2.inventoryLotLineId IS NOT NULL | |||||
| AND sl2.inventoryLotLineId <> sl.inventoryLotLineId | |||||
| ) | |||||
| GROUP BY sl.stockOutLineId | |||||
| ) src ON src.lineId = sol.id | |||||
| SET | |||||
| sol.inventoryLotLineId = src.lotLineId, | |||||
| sol.modified = NOW(), | |||||
| sol.modifiedBy = 'stock-ledger-fix' | |||||
| WHERE IFNULL(sol.deleted, 0) = 0 | |||||
| AND sol.inventoryLotLineId IS NOT NULL | |||||
| AND sol.inventoryLotLineId <> src.lotLineId | |||||
| """.trimIndent(), | |||||
| args, | |||||
| ) | |||||
| val deletedBlank = support.jdbcDao.executeUpdate( | |||||
| """ | |||||
| UPDATE stock_ledger sl | |||||
| INNER JOIN stock_out_line sol | |||||
| ON sol.id = sl.stockOutLineId AND IFNULL(sol.deleted, 0) = 0 | |||||
| SET | |||||
| sl.inventoryLotLineId = NULL, | |||||
| sl.deleted = 1, | |||||
| sl.modified = NOW(), | |||||
| sl.modifiedBy = 'stock-ledger-fix' | |||||
| WHERE sl.deleted = 0 | |||||
| AND ($dateWhere) | |||||
| AND sl.inventoryLotLineId IS NOT NULL | |||||
| AND sol.inventoryLotLineId IS NULL | |||||
| AND ($extraAnd) | |||||
| """.trimIndent(), | |||||
| args, | |||||
| ) | |||||
| return deletedBlank to aligned | |||||
| } | |||||
| /** 2.2 lot line stock UOM → uom_conversion id. Include deleted item_uom (historical lots still point there). */ | /** 2.2 lot line stock UOM → uom_conversion id. Include deleted item_uom (historical lots still point there). */ | ||||
| private fun fillUomId(args: Map<String, Any>): Int { | private fun fillUomId(args: Map<String, Any>): Int { | ||||
| return support.jdbcDao.executeUpdate( | return support.jdbcDao.executeUpdate( | ||||
| @@ -758,7 +938,7 @@ open class StockLedgerFixBackfillService( | |||||
| } ?: 0 | } ?: 0 | ||||
| } | } | ||||
| private fun fillLotLineIdScope(args: Map<String, Any>, byInventory: Boolean): Int { | |||||
| private fun fillLotLineIdScope(args: Map<String, Any>, byInventory: Boolean): LotLineStep { | |||||
| if (byInventory) return fillLotLineIdScopeByInventory(args) | if (byInventory) return fillLotLineIdScopeByInventory(args) | ||||
| val lotFilter = """ | val lotFilter = """ | ||||
| AND ( | AND ( | ||||
| @@ -818,12 +998,34 @@ open class StockLedgerFixBackfillService( | |||||
| args, | args, | ||||
| ) | ) | ||||
| } | } | ||||
| return fromSolSil + fromOneLine | |||||
| val filled = fromSolSil + fromOneLine | |||||
| val deleted = softDeleteUnlinkedLot( | |||||
| "sl.date < :today", | |||||
| args, | |||||
| "sil.inventoryLotId IN (SELECT inventoryLotId FROM inventory_lot_line WHERE id = :lotId)", | |||||
| ) | |||||
| val outLine = alignExistingOutLine("sl.date < :today", args, "sl.inventoryLotLineId = :lotId") | |||||
| val check = countExistingLotLineMatch( | |||||
| """ | |||||
| sl.date < :today | |||||
| AND ( | |||||
| sl.inventoryLotLineId = :lotId | |||||
| OR sil.inventoryLotId IN (SELECT inventoryLotId FROM inventory_lot_line WHERE id = :lotId) | |||||
| ) | |||||
| """.trimIndent(), | |||||
| args, | |||||
| ) | |||||
| return LotLineStep( | |||||
| filled, deleted, check.first, check.second, | |||||
| deletedBlankOutLine = outLine.first, | |||||
| alignedOutLine = outLine.second, | |||||
| ) | |||||
| } | } | ||||
| /** Collect candidate ledger ids first — do not UPDATE with date < today OR …. */ | /** Collect candidate ledger ids first — do not UPDATE with date < today OR …. */ | ||||
| private fun fillLotLineIdScopeByInventory(args: Map<String, Any>): Int { | |||||
| return support.withTempTx { | |||||
| private fun fillLotLineIdScopeByInventory(args: Map<String, Any>): LotLineStep { | |||||
| var step = LotLineStep(0, 0, 0, 0) | |||||
| 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_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_sil_lots") | ||||
| support.jdbcDao.executeUpdate("DROP TEMPORARY TABLE IF EXISTS tmp_sl_fix_one_line") | support.jdbcDao.executeUpdate("DROP TEMPORARY TABLE IF EXISTS tmp_sl_fix_one_line") | ||||
| @@ -927,13 +1129,34 @@ open class StockLedgerFixBackfillService( | |||||
| """.trimIndent(), | """.trimIndent(), | ||||
| ) | ) | ||||
| } | } | ||||
| fromSolSil + fromOneLine | |||||
| val deleted = support.jdbcDao.executeUpdate( | |||||
| """ | |||||
| UPDATE stock_ledger sl | |||||
| INNER JOIN tmp_sl_fix_lot_ids t ON t.id = sl.id | |||||
| SET | |||||
| sl.deleted = 1, | |||||
| sl.modified = NOW(), | |||||
| sl.modifiedBy = 'stock-ledger-fix' | |||||
| WHERE sl.deleted = 0 | |||||
| AND sl.inventoryLotLineId IS NULL | |||||
| AND (sl.stockInLineId IS NOT NULL OR sl.stockOutLineId IS NOT NULL) | |||||
| """.trimIndent(), | |||||
| ) | |||||
| val outLine = alignExistingOutLine("sl.date < :today", args, "sl.inventoryId = :inventoryId") | |||||
| val check = countExistingLotLineMatch("sl.inventoryId = :inventoryId", args) | |||||
| step = LotLineStep( | |||||
| fromSolSil + fromOneLine, deleted, check.first, check.second, | |||||
| deletedBlankOutLine = outLine.first, | |||||
| alignedOutLine = outLine.second, | |||||
| ) | |||||
| 0 | |||||
| } finally { | } 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_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_sil_lots") | ||||
| support.jdbcDao.executeUpdate("DROP TEMPORARY TABLE IF EXISTS tmp_sl_fix_lot_ids") | support.jdbcDao.executeUpdate("DROP TEMPORARY TABLE IF EXISTS tmp_sl_fix_lot_ids") | ||||
| } | } | ||||
| } | } | ||||
| return step | |||||
| } | } | ||||
| private fun fillUomIdScope(args: Map<String, Any>, byInventory: Boolean): Int { | private fun fillUomIdScope(args: Map<String, Any>, byInventory: Boolean): Int { | ||||
| @@ -21,8 +21,9 @@ open class StockLedgerFixExportService( | |||||
| /** | /** | ||||
| * Export SQL patch for target DB (same dump lineage / freeze). | * Export SQL patch for target DB (same dump lineage / freeze). | ||||
| * [partsRaw] omit / empty / all = ledger + 2.6 (legacy). Else subset: | * [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. | |||||
| * 1.0 inventory, 2.1 soft-deleted ledger + stock-out lot, 2.3 inventoryId, | |||||
| * ledger (lot/uom/lotQty/balance), 2.6 day, 2.7 SIL/SOL + adjustment record + ADJ INSERT. | |||||
| * Apply order in file: 1.0 → 2.1 → ledger(+2.3) → 2.7 docs → 2.7 ADJ → 2.6. | |||||
| */ | */ | ||||
| open fun writeExportSql( | open fun writeExportSql( | ||||
| from: LocalDate, | from: LocalDate, | ||||
| @@ -44,12 +45,13 @@ open class StockLedgerFixExportService( | |||||
| } | } | ||||
| val parts = parseExportParts(partsRaw) | val parts = parseExportParts(partsRaw) | ||||
| val want10 = "1.0" in parts | val want10 = "1.0" in parts | ||||
| val want21 = "2.1" in parts | |||||
| val want23 = "2.3" in parts | val want23 = "2.3" in parts | ||||
| val wantLedger = "ledger" in parts | val wantLedger = "ledger" in parts | ||||
| val want26 = "2.6" in parts | val want26 = "2.6" in parts | ||||
| val want27 = "2.7" 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") | |||||
| if (!want10 && !want21 && !want23 && !wantLedger && !want26 && !want27) { | |||||
| throw IllegalArgumentException("parts must include at least one of: 1.0, 2.1, 2.3, ledger, 2.6, 2.7") | |||||
| } | } | ||||
| val args = mapOf("from" to from, "toExclusive" to toExclusive) | val args = mapOf("from" to from, "toExclusive" to toExclusive) | ||||
| val ledgerCnt = if (wantLedger || want23 || want27) { | val ledgerCnt = if (wantLedger || want23 || want27) { | ||||
| @@ -110,7 +112,7 @@ open class StockLedgerFixExportService( | |||||
| writer.write("-- stock-ledger-fix patch\n") | writer.write("-- stock-ledger-fix patch\n") | ||||
| writer.write("-- from $from inclusive to ${toExclusive.minusDays(1)} inclusive (date < $toExclusive)\n") | writer.write("-- from $from inclusive to ${toExclusive.minusDays(1)} inclusive (date < $toExclusive)\n") | ||||
| writer.write("-- parts=${parts.joinToString(",")}\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("-- apply order: 1.0 inventory → 2.1 delete ledger + stock_out lot → ledger(+2.3) UPDATE → 2.7 SIL/SOL/adjustment record then ADJ INSERT → 2.6 stock_lot_day\n") | |||||
| writer.write("-- INNER JOIN id: target rows without matching id are skipped on UPDATE\n") | writer.write("-- INNER JOIN id: target rows without matching id are skipped on UPDATE\n") | ||||
| if (want23 && !want10) { | 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("-- WARN: 2.3 without 1.0 — new inventory ids may be missing on target; prefer include 1.0\n") | ||||
| @@ -124,6 +126,9 @@ open class StockLedgerFixExportService( | |||||
| if (want10) { | if (want10) { | ||||
| writeExportInventory10(writer, jdbc) | writeExportInventory10(writer, jdbc) | ||||
| } | } | ||||
| if (want21) { | |||||
| writeExport21(writer, jdbc, args) | |||||
| } | |||||
| if (wantLedger || want23) { | if (wantLedger || want23) { | ||||
| writeExportLedgerPatch(writer, jdbc, args, wantLedger, want23) | writeExportLedgerPatch(writer, jdbc, args, wantLedger, want23) | ||||
| } | } | ||||
| @@ -209,6 +214,146 @@ open class StockLedgerFixExportService( | |||||
| } else { | } else { | ||||
| writer.write("-- (no inventory rows)\n\n") | writer.write("-- (no inventory rows)\n\n") | ||||
| } | } | ||||
| writeTempUpdate( | |||||
| writer, | |||||
| jdbc, | |||||
| emptyMap(), | |||||
| title = "-- === 1.0 soft-delete orphan inventory (stockUomId still null) ===\n", | |||||
| selectSql = """ | |||||
| SELECT id, modified, modifiedBy | |||||
| FROM inventory | |||||
| WHERE deleted = 1 | |||||
| AND modifiedBy = 'stock-ledger-fix' | |||||
| ORDER BY id | |||||
| """.trimIndent(), | |||||
| tempName = "tmp_sl_fix_inv_del", | |||||
| columnDefs = "modified DATETIME NULL, modifiedBy VARCHAR(255) NULL", | |||||
| columns = listOf("id", "modified", "modifiedBy"), | |||||
| updateSql = """ | |||||
| UPDATE inventory i | |||||
| INNER JOIN tmp_sl_fix_inv_del t ON t.id = i.id | |||||
| SET | |||||
| i.deleted = 1, | |||||
| i.modified = t.modified, | |||||
| i.modifiedBy = t.modifiedBy; | |||||
| """.trimIndent(), | |||||
| emptyComment = "-- (no stock-ledger-fix deleted inventory rows)", | |||||
| ) | |||||
| } | |||||
| /** | |||||
| * 2.1 results the live-row ledger patch cannot carry: | |||||
| * soft-deleted ledger rows (including lot cleared when the out line has no lot), | |||||
| * and stock_out_line.inventoryLotLineId set to the ledger lot. | |||||
| */ | |||||
| private fun writeExport21( | |||||
| writer: BufferedWriter, | |||||
| jdbc: NamedParameterJdbcTemplate, | |||||
| args: Map<String, Any>, | |||||
| ) { | |||||
| writeTempUpdate( | |||||
| writer, | |||||
| jdbc, | |||||
| args, | |||||
| title = "-- === 2.1 soft-delete stock_ledger (id missing on target is skipped) ===\n", | |||||
| selectSql = """ | |||||
| SELECT id, inventoryLotLineId, modified, modifiedBy | |||||
| FROM stock_ledger | |||||
| WHERE deleted = 1 | |||||
| AND modifiedBy = 'stock-ledger-fix' | |||||
| AND date >= :from AND date < :toExclusive | |||||
| ORDER BY id | |||||
| """.trimIndent(), | |||||
| tempName = "tmp_sl_fix_ledger_del", | |||||
| columnDefs = "inventoryLotLineId BIGINT NULL, modified DATETIME NULL, modifiedBy VARCHAR(255) NULL", | |||||
| columns = listOf("id", "inventoryLotLineId", "modified", "modifiedBy"), | |||||
| updateSql = """ | |||||
| UPDATE stock_ledger sl | |||||
| INNER JOIN tmp_sl_fix_ledger_del t ON t.id = sl.id | |||||
| SET | |||||
| sl.deleted = 1, | |||||
| sl.inventoryLotLineId = t.inventoryLotLineId, | |||||
| sl.modified = t.modified, | |||||
| sl.modifiedBy = t.modifiedBy; | |||||
| """.trimIndent(), | |||||
| emptyComment = "-- (no stock-ledger-fix deleted ledger rows in range)", | |||||
| ) | |||||
| writeTempUpdate( | |||||
| writer, | |||||
| jdbc, | |||||
| args, | |||||
| title = "-- === 2.1 stock_out_line lot = ledger lot (existing lines only) ===\n", | |||||
| selectSql = """ | |||||
| SELECT sol.id, sol.inventoryLotLineId, sol.modified, sol.modifiedBy | |||||
| FROM stock_out_line sol | |||||
| WHERE sol.modifiedBy = 'stock-ledger-fix' | |||||
| AND IFNULL(sol.createdBy, '') <> 'stock-ledger-fix' | |||||
| AND EXISTS ( | |||||
| SELECT 1 | |||||
| FROM stock_ledger sl | |||||
| WHERE sl.stockOutLineId = sol.id | |||||
| AND sl.date >= :from AND sl.date < :toExclusive | |||||
| ) | |||||
| ORDER BY sol.id | |||||
| """.trimIndent(), | |||||
| tempName = "tmp_sl_fix_sol_lot", | |||||
| columnDefs = "inventoryLotLineId BIGINT NULL, modified DATETIME NULL, modifiedBy VARCHAR(255) NULL", | |||||
| columns = listOf("id", "inventoryLotLineId", "modified", "modifiedBy"), | |||||
| updateSql = """ | |||||
| UPDATE stock_out_line sol | |||||
| INNER JOIN tmp_sl_fix_sol_lot t ON t.id = sol.id | |||||
| SET | |||||
| sol.inventoryLotLineId = t.inventoryLotLineId, | |||||
| sol.modified = t.modified, | |||||
| sol.modifiedBy = t.modifiedBy; | |||||
| """.trimIndent(), | |||||
| emptyComment = "-- (no stock-ledger-fix stock_out_line lot updates in range)", | |||||
| ) | |||||
| } | |||||
| private fun writeTempUpdate( | |||||
| writer: BufferedWriter, | |||||
| jdbc: NamedParameterJdbcTemplate, | |||||
| args: Map<String, Any>, | |||||
| title: String, | |||||
| selectSql: String, | |||||
| tempName: String, | |||||
| columnDefs: String, | |||||
| columns: List<String>, | |||||
| updateSql: String, | |||||
| emptyComment: String, | |||||
| ) { | |||||
| writer.write(title) | |||||
| writer.write("DROP TEMPORARY TABLE IF EXISTS $tempName;\n") | |||||
| writer.write("CREATE TEMPORARY TABLE $tempName (\n") | |||||
| writer.write(" id INT NOT NULL PRIMARY KEY,\n $columnDefs\n);\n\n") | |||||
| var batch = 0 | |||||
| var any = false | |||||
| jdbc.query( | |||||
| selectSql, | |||||
| args, | |||||
| RowCallbackHandler { rs -> | |||||
| if (batch == 0) { | |||||
| writer.write("INSERT INTO $tempName (${columns.joinToString(", ")}) VALUES\n") | |||||
| } else { | |||||
| writer.write(",\n") | |||||
| } | |||||
| writer.write("(" + columns.joinToString(", ") { rsSql(rs, it) } + ")") | |||||
| any = true | |||||
| batch++ | |||||
| if (batch >= 500) { | |||||
| writer.write(";\n\n") | |||||
| batch = 0 | |||||
| } | |||||
| }, | |||||
| ) | |||||
| if (batch > 0) writer.write(";\n\n") | |||||
| if (any) { | |||||
| writer.write("$updateSql\n\n") | |||||
| } else { | |||||
| writer.write("$emptyComment\n\n") | |||||
| } | |||||
| writer.write("DROP TEMPORARY TABLE IF EXISTS $tempName;\n\n") | |||||
| } | } | ||||
| private fun writeExportLedgerPatch( | private fun writeExportLedgerPatch( | ||||
| @@ -397,6 +542,42 @@ open class StockLedgerFixExportService( | |||||
| "modifiedBy=VALUES(modifiedBy), version=stock_out_line.version+1, deleted=0", | "modifiedBy=VALUES(modifiedBy), version=stock_out_line.version+1, deleted=0", | ||||
| emptyComment = "-- (no ADJ stock_out_line in range)", | emptyComment = "-- (no ADJ stock_out_line in range)", | ||||
| ) | ) | ||||
| writer.write("-- === 2.7 stock_adjustment_record (after SIL/SOL; FK to those lines) ===\n") | |||||
| writeExportRows( | |||||
| writer, | |||||
| jdbc, | |||||
| """ | |||||
| SELECT DISTINCT r.id, r.created, r.createdBy, r.version, r.modified, r.modifiedBy, r.deleted, | |||||
| r.itemId, r.itemCode, r.itemName, r.lotNo, r.inQty, r.outQty, r.remarks, | |||||
| r.stockInLineId, r.stockOutLineId | |||||
| FROM stock_adjustment_record r | |||||
| INNER JOIN stock_ledger sl | |||||
| ON ( | |||||
| (r.stockInLineId IS NOT NULL AND sl.stockInLineId = r.stockInLineId) | |||||
| OR (r.stockOutLineId IS NOT NULL AND sl.stockOutLineId = r.stockOutLineId) | |||||
| ) | |||||
| WHERE IFNULL(r.deleted, 0) = 0 | |||||
| AND r.createdBy = 'stock-ledger-fix' | |||||
| AND sl.deleted = 0 | |||||
| AND sl.date >= :from AND sl.date < :toExclusive | |||||
| AND sl.type = 'ADJ' | |||||
| AND sl.createdBy = 'stock-ledger-fix' | |||||
| ORDER BY r.id | |||||
| """.trimIndent(), | |||||
| args, | |||||
| table = "stock_adjustment_record", | |||||
| columns = listOf( | |||||
| "id", "created", "createdBy", "version", "modified", "modifiedBy", "deleted", | |||||
| "itemId", "itemCode", "itemName", "lotNo", "inQty", "outQty", "remarks", | |||||
| "stockInLineId", "stockOutLineId", | |||||
| ), | |||||
| onDup = "itemCode=VALUES(itemCode), itemName=VALUES(itemName), lotNo=VALUES(lotNo), " + | |||||
| "inQty=VALUES(inQty), outQty=VALUES(outQty), remarks=VALUES(remarks), " + | |||||
| "stockInLineId=VALUES(stockInLineId), stockOutLineId=VALUES(stockOutLineId), " + | |||||
| "modified=VALUES(modified), modifiedBy=VALUES(modifiedBy), " + | |||||
| "version=stock_adjustment_record.version+1, deleted=0", | |||||
| emptyComment = "-- (no ADJ stock_adjustment_record in range)", | |||||
| ) | |||||
| } | } | ||||
| private fun writeExportAdjInsert( | private fun writeExportAdjInsert( | ||||
| @@ -557,11 +738,12 @@ open class StockLedgerFixExportService( | |||||
| val key = when (t) { | val key = when (t) { | ||||
| "1.0", "10", "inventory" -> "1.0" | "1.0", "10", "inventory" -> "1.0" | ||||
| "2.3", "23", "inventoryid" -> "2.3" | "2.3", "23", "inventoryid" -> "2.3" | ||||
| "ledger", "2.1", "2.2", "2.4", "2.5" -> "ledger" | |||||
| "2.1", "21" -> "2.1" | |||||
| "ledger", "2.2", "2.4", "2.5" -> "ledger" | |||||
| "2.6", "26", "day", "stock_lot_day" -> "2.6" | "2.6", "26", "day", "stock_lot_day" -> "2.6" | ||||
| "2.7", "27", "adj" -> "2.7" | "2.7", "27", "adj" -> "2.7" | ||||
| else -> throw IllegalArgumentException( | else -> throw IllegalArgumentException( | ||||
| "unknown export part '$token' (use 1.0, 2.3, ledger, 2.6, 2.7, or all)", | |||||
| "unknown export part '$token' (use 1.0, 2.1, 2.3, ledger, 2.6, 2.7, or all)", | |||||
| ) | ) | ||||
| } | } | ||||
| out.add(key) | out.add(key) | ||||
| @@ -180,10 +180,36 @@ open class StockLedgerFixQueryService( | |||||
| sl.inventoryLotLineId IS NULL | sl.inventoryLotLineId IS NULL | ||||
| AND COALESCE(sol.inventoryLotLineId, sil.inventoryLotLineId) IS NULL | AND COALESCE(sol.inventoryLotLineId, sil.inventoryLotLineId) IS NULL | ||||
| AND (sil.inventoryLotId IS NULL OR IFNULL(lc.lineCnt, 0) = 0) | AND (sil.inventoryLotId IS NULL OR IFNULL(lc.lineCnt, 0) = 0) | ||||
| ) AS cannotNoSource | |||||
| AND (sl.stockInLineId IS NOT NULL OR sl.stockOutLineId IS NOT NULL) | |||||
| ) AS cannotNoSource, | |||||
| SUM( | |||||
| sl.inventoryLotLineId IS NOT NULL | |||||
| AND (sl.stockInLineId IS NOT NULL OR sl.stockOutLineId IS NOT NULL) | |||||
| AND COALESCE(sol.inventoryLotLineId, sil.inventoryLotLineId) = sl.inventoryLotLineId | |||||
| ) AS existingLotMatch, | |||||
| SUM( | |||||
| sl.inventoryLotLineId IS NOT NULL | |||||
| AND (sl.stockInLineId IS NOT NULL OR sl.stockOutLineId IS NOT NULL) | |||||
| AND ( | |||||
| COALESCE(sol.inventoryLotLineId, sil.inventoryLotLineId) IS NULL | |||||
| OR COALESCE(sol.inventoryLotLineId, sil.inventoryLotLineId) <> sl.inventoryLotLineId | |||||
| ) | |||||
| ) AS existingLotMismatch, | |||||
| SUM( | |||||
| sl.inventoryLotLineId IS NOT NULL | |||||
| AND sol.id IS NOT NULL | |||||
| AND sol.inventoryLotLineId IS NULL | |||||
| ) AS outLineBlank, | |||||
| SUM( | |||||
| sl.inventoryLotLineId IS NOT NULL | |||||
| AND sol.inventoryLotLineId IS NOT NULL | |||||
| AND sol.inventoryLotLineId <> sl.inventoryLotLineId | |||||
| ) AS outLineDiffers | |||||
| FROM stock_ledger sl | 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 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 inventory_lot_line ill ON ill.id = sl.inventoryLotLineId | LEFT JOIN inventory_lot_line ill ON ill.id = sl.inventoryLotLineId | ||||
| LEFT JOIN item_uom iu ON iu.id = ill.stockItemUomId | LEFT JOIN item_uom iu ON iu.id = ill.stockItemUomId | ||||
| LEFT JOIN ( | LEFT JOIN ( | ||||
| @@ -315,6 +341,10 @@ open class StockLedgerFixQueryService( | |||||
| cannotNoSource = support.toInt(ledger["cannotNoSource"]), | cannotNoSource = support.toInt(ledger["cannotNoSource"]), | ||||
| earlyTkeOver = 0, | earlyTkeOver = 0, | ||||
| otherOver = 0, | otherOver = 0, | ||||
| existingLotMatch = support.toInt(ledger["existingLotMatch"]), | |||||
| existingLotMismatch = support.toInt(ledger["existingLotMismatch"]), | |||||
| outLineBlank = support.toInt(ledger["outLineBlank"]), | |||||
| outLineDiffers = support.toInt(ledger["outLineDiffers"]), | |||||
| extra = listOf( | extra = listOf( | ||||
| part("dayTable", "stock_lot_day", dayTableMiss, dayIncorrect, dayTableTotal), | part("dayTable", "stock_lot_day", dayTableMiss, dayIncorrect, dayTableTotal), | ||||
| ), | ), | ||||
| @@ -541,7 +571,31 @@ open class StockLedgerFixQueryService( | |||||
| sl.inventoryLotLineId IS NULL | sl.inventoryLotLineId IS NULL | ||||
| AND COALESCE(sol.inventoryLotLineId, sil.inventoryLotLineId) IS NULL | AND COALESCE(sol.inventoryLotLineId, sil.inventoryLotLineId) IS NULL | ||||
| AND (sil.inventoryLotId IS NULL OR IFNULL(lc.lineCnt, 0) = 0) | AND (sil.inventoryLotId IS NULL OR IFNULL(lc.lineCnt, 0) = 0) | ||||
| ) AS cannotNoSource | |||||
| AND (sl.stockInLineId IS NOT NULL OR sl.stockOutLineId IS NOT NULL) | |||||
| ) AS cannotNoSource, | |||||
| SUM( | |||||
| sl.inventoryLotLineId IS NOT NULL | |||||
| AND (sl.stockInLineId IS NOT NULL OR sl.stockOutLineId IS NOT NULL) | |||||
| AND COALESCE(sol.inventoryLotLineId, sil.inventoryLotLineId) = sl.inventoryLotLineId | |||||
| ) AS existingLotMatch, | |||||
| SUM( | |||||
| sl.inventoryLotLineId IS NOT NULL | |||||
| AND (sl.stockInLineId IS NOT NULL OR sl.stockOutLineId IS NOT NULL) | |||||
| AND ( | |||||
| COALESCE(sol.inventoryLotLineId, sil.inventoryLotLineId) IS NULL | |||||
| OR COALESCE(sol.inventoryLotLineId, sil.inventoryLotLineId) <> sl.inventoryLotLineId | |||||
| ) | |||||
| ) AS existingLotMismatch, | |||||
| SUM( | |||||
| sl.inventoryLotLineId IS NOT NULL | |||||
| AND sol.id IS NOT NULL | |||||
| AND sol.inventoryLotLineId IS NULL | |||||
| ) AS outLineBlank, | |||||
| SUM( | |||||
| sl.inventoryLotLineId IS NOT NULL | |||||
| AND sol.inventoryLotLineId IS NOT NULL | |||||
| AND sol.inventoryLotLineId <> sl.inventoryLotLineId | |||||
| ) AS outLineDiffers | |||||
| FROM stock_ledger sl | FROM stock_ledger sl | ||||
| LEFT JOIN stock_out_line sol | LEFT JOIN stock_out_line sol | ||||
| ON sol.id = sl.stockOutLineId AND IFNULL(sol.deleted, 0) = 0 | ON sol.id = sl.stockOutLineId AND IFNULL(sol.deleted, 0) = 0 | ||||
| @@ -599,6 +653,10 @@ open class StockLedgerFixQueryService( | |||||
| cannotNoSource = support.toInt(ledger["cannotNoSource"]), | cannotNoSource = support.toInt(ledger["cannotNoSource"]), | ||||
| earlyTkeOver = earlyTke, | earlyTkeOver = earlyTke, | ||||
| otherOver = (overIssue - earlyTke).coerceAtLeast(0), | otherOver = (overIssue - earlyTke).coerceAtLeast(0), | ||||
| existingLotMatch = support.toInt(ledger["existingLotMatch"]), | |||||
| existingLotMismatch = support.toInt(ledger["existingLotMismatch"]), | |||||
| outLineBlank = support.toInt(ledger["outLineBlank"]), | |||||
| outLineDiffers = support.toInt(ledger["outLineDiffers"]), | |||||
| ), | ), | ||||
| ) | ) | ||||
| } | } | ||||
| @@ -621,6 +679,10 @@ open class StockLedgerFixQueryService( | |||||
| cannotNoSource: Int, | cannotNoSource: Int, | ||||
| earlyTkeOver: Int, | earlyTkeOver: Int, | ||||
| otherOver: Int, | otherOver: Int, | ||||
| existingLotMatch: Int = 0, | |||||
| existingLotMismatch: Int = 0, | |||||
| outLineBlank: Int = 0, | |||||
| outLineDiffers: Int = 0, | |||||
| extra: List<StockLedgerFixCheckPart> = emptyList(), | extra: List<StockLedgerFixCheckPart> = emptyList(), | ||||
| ): List<StockLedgerFixCheckPart> { | ): List<StockLedgerFixCheckPart> { | ||||
| fun field(key: String, label: String, miss: Int, incorrect: Int) = | fun field(key: String, label: String, miss: Int, incorrect: Int) = | ||||
| @@ -643,12 +705,24 @@ open class StockLedgerFixQueryService( | |||||
| add(field("lotQty", "lotQtyBefore/After(lot 已有)", lotQtyMissWithLot, lotQtyIncorrect)) | add(field("lotQty", "lotQtyBefore/After(lot 已有)", lotQtyMissWithLot, lotQtyIncorrect)) | ||||
| add(field("overIssue", "over-issue (lotQty < 0)", 0, overIssue)) | add(field("overIssue", "over-issue (lotQty < 0)", 0, overIssue)) | ||||
| add(field("balance", "balance", balanceMiss, 0)) | add(field("balance", "balance", balanceMiss, 0)) | ||||
| add( | |||||
| StockLedgerFixCheckPart( | |||||
| key = "existingLotLine", | |||||
| label = "已有 lot line:入出倉列是否同一條", | |||||
| ok = existingLotMatch, | |||||
| miss = 0, | |||||
| incorrect = existingLotMismatch, | |||||
| group = "field", | |||||
| ), | |||||
| ) | |||||
| addAll(extra) | addAll(extra) | ||||
| add(canFix("outLineBlank", "2.1:出倉列批號是空,流水帳改空並軟刪", outLineBlank)) | |||||
| add(canFix("outLineDiffers", "2.1:出倉列批號不同,改出倉列跟流水帳", outLineDiffers)) | |||||
| add(canFix("fixable21a", "可修 2.1a:SIL/SOL 已有 lot line", fixable21a)) | add(canFix("fixable21a", "可修 2.1a:SIL/SOL 已有 lot line", fixable21a)) | ||||
| add(canFix("fixable21b", "可修 2.1b:inventoryLotId 僅一條 line", fixable21b)) | add(canFix("fixable21b", "可修 2.1b:inventoryLotId 僅一條 line", fixable21b)) | ||||
| add(canFix("fixableLotQty", "可修:lot 已有但缺 lotQty", lotQtyMissWithLot)) | add(canFix("fixableLotQty", "可修:lot 已有但缺 lotQty", lotQtyMissWithLot)) | ||||
| add(cannotFix("cannotNoSource", "不能修:沒有 SIL/SOL lot 來源", cannotNoSource)) | |||||
| add(cannotFix("cannotMultiLine", "不能修:同一 lot 有多條 warehouse line", cannotMultiLine)) | |||||
| add(cannotFix("cannotNoSource", "2.1 軟刪:入出倉列對不到批號列", cannotNoSource)) | |||||
| add(cannotFix("cannotMultiLine", "2.1 軟刪:同一 lot 有多條 warehouse line", cannotMultiLine)) | |||||
| add(cannotFix("earlyTke", "不能修:舊 TKE 開新批(sibling 盤盈)", earlyTkeOver)) | add(cannotFix("earlyTke", "不能修:舊 TKE 開新批(sibling 盤盈)", earlyTkeOver)) | ||||
| add(cannotFix("realOver", "不能修:over-issue 非舊 TKE(真超發/帳不一致)", otherOver)) | add(cannotFix("realOver", "不能修:over-issue 非舊 TKE(真超發/帳不一致)", otherOver)) | ||||
| } | } | ||||
| @@ -64,6 +64,18 @@ open class StockLedgerFixSupport( | |||||
| } | } | ||||
| } | } | ||||
| fun <T> timedValue(name: String, block: () -> T): T { | |||||
| val t0 = System.nanoTime() | |||||
| return try { | |||||
| val value = block() | |||||
| log.info("stock-ledger-fix step {} {}ms", name, (System.nanoTime() - t0) / 1_000_000) | |||||
| value | |||||
| } 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 { | fun parseDay(raw: String): LocalDate { | ||||
| val trimmed = raw.trim() | val trimmed = raw.trim() | ||||
| if (trimmed.isEmpty()) throw IllegalArgumentException("date is required (yyyy-MM-dd)") | if (trimmed.isEmpty()) throw IllegalArgumentException("date is required (yyyy-MM-dd)") | ||||
| @@ -39,6 +39,16 @@ data class StockLedgerFixRunResponse( | |||||
| val stillMissUom: Int, | val stillMissUom: Int, | ||||
| val stillMissInventoryId: Int, | val stillMissInventoryId: Int, | ||||
| val stillMissLotQty: Int, | val stillMissLotQty: Int, | ||||
| /** 2.1: related SIL/SOL cannot point at a lot line, so the ledger row is soft-deleted. */ | |||||
| val deletedUnlinkedLot: Int = 0, | |||||
| /** Ledger already had inventoryLotLineId and the live SIL/SOL lot line is the same. */ | |||||
| val existingLotMatch: Int = 0, | |||||
| /** Ledger already had inventoryLotLineId; the live SIL/SOL lot line is missing or different. Not deleted. */ | |||||
| val existingLotMismatch: Int = 0, | |||||
| /** Ledger had a lot line but the live stock-out line lot was null: lot cleared and ledger soft-deleted. */ | |||||
| val deletedBlankOutLine: Int = 0, | |||||
| /** Live stock-out line lot differed; the line was set to the ledger lot. Ledger lot was not changed. */ | |||||
| val alignedOutLine: Int = 0, | |||||
| ) | ) | ||||
| data class StockLedgerFixCheckPart( | data class StockLedgerFixCheckPart( | ||||
| @@ -128,6 +138,8 @@ data class StockLedgerFixAdjResponse( | |||||
| val filledLotQty: Int, | val filledLotQty: Int, | ||||
| val filledBalance: Int, | val filledBalance: Int, | ||||
| val dayRowsWritten: Int, | val dayRowsWritten: Int, | ||||
| /** Rows inserted into stock_adjustment_record for stock-ledger-fix ADJ lines, including ones already on the ledger. */ | |||||
| val adjustmentRecords: Int = 0, | |||||
| ) | ) | ||||
| data class StockLedgerFixScopeDetail( | data class StockLedgerFixScopeDetail( | ||||
| @@ -320,7 +320,10 @@ open class StockLedgerReportService( | |||||
| """ | """ | ||||
| SELECT | SELECT | ||||
| sol.id AS lineId, | sol.id AS lineId, | ||||
| COALESCE(TRIM(do.code), TRIM(jo_po.code)) AS orderRefNo, | |||||
| CASE | |||||
| WHEN po_out.type = 'consumable' THEN TRIM(po_out.code) | |||||
| ELSE COALESCE(TRIM(do.code), TRIM(jo_po.code)) | |||||
| END AS orderRefNo, | |||||
| CASE | CASE | ||||
| WHEN so.stockTakeId IS NOT NULL OR so.type = 'stockTake' THEN 1 | WHEN so.stockTakeId IS NOT NULL OR so.type = 'stockTake' THEN 1 | ||||
| ELSE 0 | ELSE 0 | ||||