From 04783a2e74105c5678890ae0d34b269aa24ef9d3 Mon Sep 17 00:00:00 2001 From: "CANCERYS\\kw093" Date: Fri, 9 Oct 2026 11:20:59 +0800 Subject: [PATCH] 2.1 fix and 2.7 fix improve report update --- .../ledgerfix/StockLedgerFixAdjService.kt | 56 +++- .../StockLedgerFixBackfillService.kt | 243 +++++++++++++++++- .../ledgerfix/StockLedgerFixExportService.kt | 196 +++++++++++++- .../ledgerfix/StockLedgerFixQueryService.kt | 86 ++++++- .../ledgerfix/StockLedgerFixSupport.kt | 12 + .../master/web/models/StockLedgerFixModels.kt | 12 + .../service/StockLedgerReportService.kt | 5 +- 7 files changed, 583 insertions(+), 27 deletions(-) diff --git a/src/main/java/com/ffii/fpsms/modules/master/service/ledgerfix/StockLedgerFixAdjService.kt b/src/main/java/com/ffii/fpsms/modules/master/service/ledgerfix/StockLedgerFixAdjService.kt index f30b6606..b5a2c61e 100644 --- a/src/main/java/com/ffii/fpsms/modules/master/service/ledgerfix/StockLedgerFixAdjService.kt +++ b/src/main/java/com/ffii/fpsms/modules/master/service/ledgerfix/StockLedgerFixAdjService.kt @@ -24,7 +24,8 @@ open class StockLedgerFixAdjService( * Quantity is onHandQty minus the bucket ledger net, including rows with no lot. * The row is attached to the lot line whose own net is furthest from its ledger. * Lot lines and inventory are not updated, so the lot-line trigger does not run. - * A bucket whose ledger net already equals onHandQty is skipped. A second run inserts nothing. + * 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). * 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() fun addLot(lotId: Long, invId: Long?) { 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) } } else 0 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, (System.nanoTime() - t0) / 1_000_000, ) @@ -135,6 +140,7 @@ open class StockLedgerFixAdjService( filledLotQty = filledLotQty, filledBalance = filledBalance, dayRowsWritten = dayRows, + adjustmentRecords = adjustmentRecords, ) } catch (e: Exception) { 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. */ private fun insertAdjStockInLine( g: AdjGap, diff --git a/src/main/java/com/ffii/fpsms/modules/master/service/ledgerfix/StockLedgerFixBackfillService.kt b/src/main/java/com/ffii/fpsms/modules/master/service/ledgerfix/StockLedgerFixBackfillService.kt index 38588957..bd586ccb 100644 --- a/src/main/java/com/ffii/fpsms/modules/master/service/ledgerfix/StockLedgerFixBackfillService.kt +++ b/src/main/java/com/ffii/fpsms/modules/master/service/ledgerfix/StockLedgerFixBackfillService.kt @@ -79,7 +79,8 @@ open class StockLedgerFixBackfillService( "uomId" to (inv["uomId"] ?: 0), "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 filledInventoryId = support.timed("inv 2.3") { fillInventoryIdScope(args, byInventory = true) } val filledLotQty = support.timed("inv 2.4") { fillLotQtyHistory(args, byInventory = true) } @@ -107,6 +108,11 @@ open class StockLedgerFixBackfillService( stillMissUom = still.missUom, stillMissInventoryId = still.missInventoryId, stillMissLotQty = still.missLotQty, + deletedUnlinkedLot = lotStep.deletedUnlinked, + existingLotMatch = lotStep.existingMatch, + existingLotMismatch = lotStep.existingMismatch, + deletedBlankOutLine = lotStep.deletedBlankOutLine, + alignedOutLine = lotStep.alignedOutLine, ) } catch (e: Exception) { 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") 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 filledInventoryId = support.timed("lot 2.3") { fillInventoryIdScope(args, byInventory = false) } val filledLotQty = support.timed("lot 2.4") { fillLotQtyHistory(args, byInventory = false) } @@ -156,6 +163,11 @@ open class StockLedgerFixBackfillService( stillMissUom = still.missUom, stillMissInventoryId = still.missInventoryId, stillMissLotQty = still.missLotQty, + deletedUnlinkedLot = lotStep.deletedUnlinked, + existingLotMatch = lotStep.existingMatch, + existingLotMismatch = lotStep.existingMismatch, + deletedBlankOutLine = lotStep.deletedBlankOutLine, + alignedOutLine = lotStep.alignedOutLine, ) } catch (e: Exception) { 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() try { 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 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 @@ -215,6 +228,11 @@ open class StockLedgerFixBackfillService( stillMissUom = still.missUom, stillMissInventoryId = still.missInventoryId, stillMissLotQty = still.missLotQty, + deletedUnlinkedLot = lotStep.deletedUnlinked, + existingLotMatch = lotStep.existingMatch, + existingLotMismatch = lotStep.existingMismatch, + deletedBlankOutLine = lotStep.deletedBlankOutLine, + alignedOutLine = lotStep.alignedOutLine, ) } catch (e: Exception) { log.warn( @@ -251,6 +269,11 @@ open class StockLedgerFixBackfillService( val t0 = System.nanoTime() try { var filledLotLineId = 0 + var deletedUnlinkedLot = 0 + var existingLotMatch = 0 + var existingLotMismatch = 0 + var deletedBlankOutLine = 0 + var alignedOutLine = 0 var filledUomId = 0 var filledInventoryId = 0 var filledLotQty = 0 @@ -259,7 +282,15 @@ open class StockLedgerFixBackfillService( 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.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.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) } @@ -290,6 +321,11 @@ open class StockLedgerFixBackfillService( stillMissUom = still.missUom, stillMissInventoryId = still.missInventoryId, stillMissLotQty = still.missLotQty, + deletedUnlinkedLot = deletedUnlinkedLot, + existingLotMatch = existingLotMatch, + existingLotMismatch = existingLotMismatch, + deletedBlankOutLine = deletedBlankOutLine, + alignedOutLine = alignedOutLine, ) } catch (e: Exception) { 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): 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. */ - private fun fillLotLineId(args: Map): Int { + private fun fillLotLineIdOnly(args: Map): Int { val fromSolSil = support.timed("2.1a solSil") { support.jdbcDao.executeUpdate( """ @@ -358,6 +408,136 @@ open class StockLedgerFixBackfillService( 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, + 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): Pair { + 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, + extraAnd: String = "1 = 1", + ): Pair { + 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). */ private fun fillUomId(args: Map): Int { return support.jdbcDao.executeUpdate( @@ -758,7 +938,7 @@ open class StockLedgerFixBackfillService( } ?: 0 } - private fun fillLotLineIdScope(args: Map, byInventory: Boolean): Int { + private fun fillLotLineIdScope(args: Map, byInventory: Boolean): LotLineStep { if (byInventory) return fillLotLineIdScopeByInventory(args) val lotFilter = """ AND ( @@ -818,12 +998,34 @@ open class StockLedgerFixBackfillService( 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 …. */ - private fun fillLotLineIdScopeByInventory(args: Map): Int { - return support.withTempTx { + private fun fillLotLineIdScopeByInventory(args: Map): 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_sil_lots") support.jdbcDao.executeUpdate("DROP TEMPORARY TABLE IF EXISTS tmp_sl_fix_one_line") @@ -927,13 +1129,34 @@ open class StockLedgerFixBackfillService( """.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 { 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") } } + return step } private fun fillUomIdScope(args: Map, byInventory: Boolean): 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 index 1c1dea20..10cf52f8 100644 --- 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 @@ -21,8 +21,9 @@ open class StockLedgerFixExportService( /** * 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. + * 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( from: LocalDate, @@ -44,12 +45,13 @@ open class StockLedgerFixExportService( } val parts = parseExportParts(partsRaw) val want10 = "1.0" in parts + val want21 = "2.1" 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") + 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 ledgerCnt = if (wantLedger || want23 || want27) { @@ -110,7 +112,7 @@ open class StockLedgerFixExportService( 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("-- 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") if (want23 && !want10) { 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) { writeExportInventory10(writer, jdbc) } + if (want21) { + writeExport21(writer, jdbc, args) + } if (wantLedger || want23) { writeExportLedgerPatch(writer, jdbc, args, wantLedger, want23) } @@ -209,6 +214,146 @@ open class StockLedgerFixExportService( } else { 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, + ) { + 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, + title: String, + selectSql: String, + tempName: String, + columnDefs: String, + columns: List, + 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( @@ -397,6 +542,42 @@ open class StockLedgerFixExportService( "modifiedBy=VALUES(modifiedBy), version=stock_out_line.version+1, deleted=0", 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( @@ -557,11 +738,12 @@ open class StockLedgerFixExportService( 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.1", "21" -> "2.1" + "ledger", "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)", + "unknown export part '$token' (use 1.0, 2.1, 2.3, ledger, 2.6, 2.7, or all)", ) } out.add(key) 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 index af138c99..38659152 100644 --- 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 @@ -180,10 +180,36 @@ open class StockLedgerFixQueryService( 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 + 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 - 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 item_uom iu ON iu.id = ill.stockItemUomId LEFT JOIN ( @@ -315,6 +341,10 @@ open class StockLedgerFixQueryService( cannotNoSource = support.toInt(ledger["cannotNoSource"]), earlyTkeOver = 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( part("dayTable", "stock_lot_day", dayTableMiss, dayIncorrect, dayTableTotal), ), @@ -541,7 +571,31 @@ open class StockLedgerFixQueryService( 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 + 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 LEFT JOIN stock_out_line sol ON sol.id = sl.stockOutLineId AND IFNULL(sol.deleted, 0) = 0 @@ -599,6 +653,10 @@ open class StockLedgerFixQueryService( cannotNoSource = support.toInt(ledger["cannotNoSource"]), earlyTkeOver = earlyTke, 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, earlyTkeOver: Int, otherOver: Int, + existingLotMatch: Int = 0, + existingLotMismatch: Int = 0, + outLineBlank: Int = 0, + outLineDiffers: Int = 0, extra: List = emptyList(), ): List { 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("overIssue", "over-issue (lotQty < 0)", 0, overIssue)) add(field("balance", "balance", balanceMiss, 0)) + add( + StockLedgerFixCheckPart( + key = "existingLotLine", + label = "已有 lot line:入出倉列是否同一條", + ok = existingLotMatch, + miss = 0, + incorrect = existingLotMismatch, + group = "field", + ), + ) 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("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("cannotNoSource", "2.1 軟刪:入出倉列對不到批號列", cannotNoSource)) + add(cannotFix("cannotMultiLine", "2.1 軟刪:同一 lot 有多條 warehouse line", cannotMultiLine)) add(cannotFix("earlyTke", "不能修:舊 TKE 開新批(sibling 盤盈)", earlyTkeOver)) add(cannotFix("realOver", "不能修:over-issue 非舊 TKE(真超發/帳不一致)", otherOver)) } 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 index cf5a3b6b..8d38a460 100644 --- 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 @@ -64,6 +64,18 @@ open class StockLedgerFixSupport( } } + fun 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 { val trimmed = raw.trim() if (trimmed.isEmpty()) throw IllegalArgumentException("date is required (yyyy-MM-dd)") diff --git a/src/main/java/com/ffii/fpsms/modules/master/web/models/StockLedgerFixModels.kt b/src/main/java/com/ffii/fpsms/modules/master/web/models/StockLedgerFixModels.kt index aecdac08..fd2a8a46 100644 --- a/src/main/java/com/ffii/fpsms/modules/master/web/models/StockLedgerFixModels.kt +++ b/src/main/java/com/ffii/fpsms/modules/master/web/models/StockLedgerFixModels.kt @@ -39,6 +39,16 @@ data class StockLedgerFixRunResponse( val stillMissUom: Int, val stillMissInventoryId: 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( @@ -128,6 +138,8 @@ data class StockLedgerFixAdjResponse( val filledLotQty: Int, val filledBalance: 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( diff --git a/src/main/java/com/ffii/fpsms/modules/report/service/StockLedgerReportService.kt b/src/main/java/com/ffii/fpsms/modules/report/service/StockLedgerReportService.kt index 5d3a83e2..085677ef 100644 --- a/src/main/java/com/ffii/fpsms/modules/report/service/StockLedgerReportService.kt +++ b/src/main/java/com/ffii/fpsms/modules/report/service/StockLedgerReportService.kt @@ -320,7 +320,10 @@ open class StockLedgerReportService( """ SELECT 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 WHEN so.stockTakeId IS NOT NULL OR so.type = 'stockTake' THEN 1 ELSE 0