From 8e30fd7e0ddd58bf08e010cc283117aec25b22cc Mon Sep 17 00:00:00 2001 From: "CANCERYS\\kw093" Date: Tue, 1 Sep 2026 11:13:57 +0800 Subject: [PATCH] stock ledger fix --- .../20260831_stock_ledger_fix_timeline.xlsx | Bin 0 -> 12849 bytes scripts/export_stock_ledger_fix_timeline.py | 418 +++ .../fpsms/modules/common/SettingNames.java | 3 + .../scheduler/service/SchedulerService.kt | 83 +- .../scheduler/web/SchedulerController.kt | 11 + .../master/service/StockLedgerFixService.kt | 3078 +++++++++++++++++ .../modules/master/web/ItemsController.kt | 14 +- .../master/web/StockLedgerFixController.kt | 126 + .../master/web/models/StockLedgerFixModels.kt | 138 + .../modules/report/service/ReportService.kt | 44 +- .../service/StockLotBalanceReportService.kt | 372 ++ .../service/StockLotLedgerReportService.kt | 492 +++ .../modules/report/web/ReportController.kt | 56 +- .../web/StockLotBalanceReportController.kt | 330 ++ .../web/StockLotLedgerReportController.kt | 326 ++ .../stock/service/StockLotDayCloseService.kt | 260 ++ src/main/resources/application-prod.yml | 3 + src/main/resources/application.yml | 4 + .../01_ledger_lot_date_index.sql | 7 + .../01_create_stock_lot_day.sql | 30 + .../01_insert_schedule_setting.sql | 10 + .../01_add_sl_date_deleted_item_uom_index.sql | 8 + .../db/scripts/20260814_inventory_1_0.sql | 87 + 23 files changed, 5887 insertions(+), 13 deletions(-) create mode 100644 docs/deploy/20260831_stock_ledger_fix_timeline.xlsx create mode 100644 scripts/export_stock_ledger_fix_timeline.py create mode 100644 src/main/java/com/ffii/fpsms/modules/master/service/StockLedgerFixService.kt create mode 100644 src/main/java/com/ffii/fpsms/modules/master/web/StockLedgerFixController.kt create mode 100644 src/main/java/com/ffii/fpsms/modules/master/web/models/StockLedgerFixModels.kt create mode 100644 src/main/java/com/ffii/fpsms/modules/report/service/StockLotBalanceReportService.kt create mode 100644 src/main/java/com/ffii/fpsms/modules/report/service/StockLotLedgerReportService.kt create mode 100644 src/main/java/com/ffii/fpsms/modules/report/web/StockLotBalanceReportController.kt create mode 100644 src/main/java/com/ffii/fpsms/modules/report/web/StockLotLedgerReportController.kt create mode 100644 src/main/java/com/ffii/fpsms/modules/stock/service/StockLotDayCloseService.kt create mode 100644 src/main/resources/db/changelog/changes/20260812_stock_lot_balance_perf/01_ledger_lot_date_index.sql create mode 100644 src/main/resources/db/changelog/changes/20260814_stock_lot_day/01_create_stock_lot_day.sql create mode 100644 src/main/resources/db/changelog/changes/20260821_stock_lot_day_close/01_insert_schedule_setting.sql create mode 100644 src/main/resources/db/changelog/changes/20260824_stock_ledger_fix_perf/01_add_sl_date_deleted_item_uom_index.sql create mode 100644 src/main/resources/db/scripts/20260814_inventory_1_0.sql diff --git a/docs/deploy/20260831_stock_ledger_fix_timeline.xlsx b/docs/deploy/20260831_stock_ledger_fix_timeline.xlsx new file mode 100644 index 0000000000000000000000000000000000000000..a54f84c9b91141065cc8b99732ae5c7796cb1c0e GIT binary patch literal 12849 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 None: + for col, w in widths.items(): + ws.column_dimensions[col].width = w + + +def paint(cell, value, fill=None, font=None, align=WRAP, merge=None) -> None: + cell.value = value + cell.alignment = align + cell.border = THIN + if fill: + cell.fill = fill + if font: + cell.font = font + + +def write_table(ws: Worksheet, start_row: int, start_col: int, headers: list[str], rows: list[list[str]], header_fill: PatternFill) -> int: + for i, h in enumerate(headers): + c = ws.cell(start_row, start_col + i) + paint(c, h, header_fill, BOLD, WRAP_CENTER) + r = start_row + 1 + for row in rows: + row_fill = None + row_font = NORMAL + cells: list[str] = [] + for val in row: + if val.startswith("GATE:"): + row_fill, row_font = FILL_GATE, BOLD + cells.append(val[5:].strip()) + elif val.startswith("WAIT:"): + row_fill = FILL_WAIT + cells.append(val[5:].strip()) + elif val.startswith("STOP:"): + row_fill, row_font = FILL_STOP, BOLD + cells.append(val[5:].strip()) + else: + cells.append(val) + for i, text in enumerate(cells): + c = ws.cell(r, start_col + i) + font = row_font if i == 0 or row_fill in (FILL_GATE, FILL_STOP) else NORMAL + paint(c, text, row_fill, font) + if i == 0: + c.alignment = WRAP_CENTER + r += 1 + return r - 1 + + +def sheet_main(wb: Workbook) -> None: + ws = wb.active + ws.title = "31-8夜_主流程" + ws.sheet_view.showGridLines = False + ws.freeze_panes = "A8" + ws.row_dimensions[1].height = 24 + ws.page_setup.orientation = "landscape" + ws.page_setup.fitToPage = True + ws.page_setup.fitToWidth = 1 + ws.page_setup.fitToHeight = 1 + ws.page_setup.paperSize = ws.PAPERSIZE_A4 + ws.print_title_rows = "1:7" + ws.page_setup.horizontalCentered = True + ws.sheet_properties.pageSetUpPr.fitToPage = True + + ws.merge_cells("A1:I1") + paint(ws["A1"], "31/8 夜 stock-ledger-fix:兩條流程(InventoryId match / not match)", FILL_TITLE, WHITE, WRAP_CENTER) + for col in range(2, 10): + ws.cell(1, col).fill = FILL_TITLE + ws.cell(1, col).border = THIN + + ws.merge_cells("A2:I2") + paint( + ws["A2"], + "前提:白天鏈已備好 0315–0830 SQL;dump 31/8 22:30 起 freeze。" + " 程式已支援修/匯出「今天」與 2.7 adjDate=今天(UI 選今天或 API 傳 adjDate)。" + " 23:05 Preview 決定走左欄或右欄,不要兩條一起做。" + " 時間由預計分鐘累加(樂觀);逾時整串延後。", + FILL_NOTE, + ITALIC, + ) + ws.row_dimensions[2].height = 48 + for col in range(2, 10): + ws.cell(2, col).fill = FILL_NOTE + ws.cell(2, col).border = THIN + + ws.merge_cells("A3:D3") + paint( + ws["A3"], + "流程 A MATCH(31/8 dump 套檔前 inventory 頂 = 30/8 修帳前的頂)", + FILL_MATCH, + WHITE11, + WRAP_CENTER, + ) + for col in range(2, 5): + ws.cell(3, col).fill = FILL_MATCH + ws.cell(3, col).border = THIN + + ws.merge_cells("F3:I3") + paint( + ws["F3"], + "流程 B NOT MATCH(31/8 dump 套檔前 inventory 頂 ≠ 30/8 修帳前)", + FILL_NOMATCH, + WHITE11, + WRAP_CENTER, + ) + for col in range(7, 10): + ws.cell(3, col).fill = FILL_NOMATCH + ws.cell(3, col).border = THIN + + ws.merge_cells("A4:D4") + paint( + ws["A4"], + "套 0315–0830 完整 SQL(含 inventory + inventoryId)。不要長 2.3。約 23:30 套完正式庫。", + FILL_HEAD_M, + NORMAL, + ) + ws.row_dimensions[4].height = 36 + for col in range(2, 5): + ws.cell(4, col).fill = FILL_HEAD_M + ws.cell(4, col).border = THIN + + ws.merge_cells("F4:I4") + paint( + ws["F4"], + "套 0315–0830 不完整 SQL(無 inventory/inventoryId)→ 本 dump 1.0 → 長 2.3。" + " 禁止套舊 1.0 INSERT。約 23:53 套完正式庫。", + FILL_HEAD_N, + NORMAL, + ) + for col in range(7, 10): + ws.cell(4, col).fill = FILL_HEAD_N + ws.cell(4, col).border = THIN + + match_rows = [ + ["22:30", "1", "dump + freeze", "停 DN/GRN、lot expiry、日結、GRN sync。記下 I0=MAX(inventory.id)、N=MAX(stock_ledger.id)"], + ["22:31", "5", "zip/下載完", "逾 15 分當異常"], + ["22:36", "25", "import 完", "最易爆;爆了後面全停"], + ["GATE:23:05", "—", "Preview:InventoryId MATCH", "I0 = 30/8 記住的頂 → 走本欄。新 UOM 仍可 MATCH"], + ["23:05", "10", "套 0315–0830 完整 SQL", "inventory(寫死 id)→ ledger(含 inventoryId)→ 日結。舊 qty 非最終"], + ["23:15", "<10s", "再跑 1.0", "新 UOM 才 INSERT(例 1051);qty 刷成 31/8 dump 當下"], + ["23:15", "2", "短區間 2.1–2.6:0830–0831", "可修今天。不要重跑 0315–0829,不要長 2.3"], + ["23:17", "3", "2.7 ADJ(adjDate=31/8)", "UI 選「今天」或 API adjDate=今天。勿用預設昨天(會 ADJ 到 30/8)"], + ["23:20", "10", "匯出完整檔 0315–0831", "inventory → ledger.inventoryId → 日結;2.7 新列(id>N)要 INSERT"], + ["WAIT:23:25", "5", "正式庫 pull code + 開後端", "freeze 仍在;I0/N 未變"], + ["23:30", "5", "正式庫套 0315–0831 完整 SQL", "先 inventory、再 ledger、再日結。套完再開 expiry/日結"], + ] + nomatch_rows = [ + ["22:30", "1", "dump + freeze", "同左。記下實際 I0(已不是舊頂)、N"], + ["22:31", "5", "zip/下載完", "同左"], + ["22:36", "25", "import 完", "同左"], + ["GATE:23:05", "—", "Preview:NOT MATCH", "禁止套 0830 inventory/inventoryId"], + ["23:05", "10", "套 0315–0830 不完整 SQL", "只套 ledger 計算欄+日結(若有)。不要套舊 1.0 INSERT"], + ["23:15", "1", "再跑 1.0", "本 dump 新 id 從 I0+1 起;與 0830 檔不是同一套"], + ["23:16", "15", "長區間 2.3:0315–0830", "本夜最長段。逾 23:50 則本夜不套正式庫"], + ["23:31", "2", "短區間 2.1–2.6:0830–0831", "同左:可修今天"], + ["23:33", "3", "2.7 ADJ(adjDate=31/8)", "同左:選今天"], + ["23:43", "10", "匯出完整檔 0315–0831", "id 同源於本 dump 1.0,不是 0830 檔"], + ["WAIT:23:48", "5", "正式庫 pull code + 開後端", "1:00 master 維持停到套完"], + ["23:53", "5", "正式庫套完整 SQL", "先 inventory、再 ledger、再日結"], + ["STOP:(風險)", "—", "長 2.3 拖過", "優先本夜 freeze 修完再上,或取消套檔改白天"], + ] + + write_table(ws, 6, 1, ["時間", "預計(分)", "動作", "備註"], match_rows, FILL_HEAD_M) + write_table(ws, 6, 6, ["時間", "預計(分)", "動作", "備註"], nomatch_rows, FILL_HEAD_N) + + ws.column_dimensions["E"].width = 2.5 + note_row = 21 + ws.merge_cells(start_row=note_row, start_column=1, end_row=note_row, end_column=9) + paint( + ws.cell(note_row, 1), + "顏色:黃=Preview;藍=等待;橙=風險。" + " MATCH 不要跑長 2.3。" + " NOT MATCH 不要套 0830 inventory/inventoryId。" + " 2.7 必須 adjDate=dump 日(今天)。" + " writeExportSql 完整檔(含 inventory/inventoryId/2.7 INSERT)若尚未改完,匯出前先確認。", + FILL_NOTE, + ITALIC, + ) + ws.row_dimensions[note_row].height = 48 + for col in range(2, 10): + ws.cell(note_row, col).fill = FILL_NOTE + ws.cell(note_row, col).border = THIN + + set_col_widths( + ws, + { + "A": 12, + "B": 10, + "C": 28, + "D": 38, + "E": 2.5, + "F": 12, + "G": 10, + "H": 30, + "I": 38, + }, + ) + for r in range(7, 20): + ws.row_dimensions[r].height = 44 + ws.row_dimensions[6].height = 22 + ws.print_area = "A1:I21" + + +def sheet_gate(wb: Workbook) -> None: + ws = wb.create_sheet("InventoryId_match判定") + ws.sheet_view.showGridLines = False + ws.page_setup.orientation = "landscape" + ws.page_setup.fitToPage = True + ws.page_setup.fitToWidth = 1 + ws.page_setup.fitToHeight = 1 + ws.page_setup.paperSize = ws.PAPERSIZE_A4 + + ws.merge_cells("A1:E1") + paint(ws["A1"], "InventoryId match 判定(數字過了才往下,不要用「時間靠近」)", FILL_TITLE, WHITE, WRAP_CENTER) + for col in range(2, 6): + ws.cell(1, col).fill = FILL_TITLE + ws.cell(1, col).border = THIN + + ws.merge_cells("A2:E2") + paint( + ws["A2"], + "A 在套任何 0830 SQL 之前(dump 原樣)。B 在套完 0830 SQL+再跑 1.0 之後(僅 MATCH)。" + " C 在同夜修完 0830–0831+2.7(adjDate=今天)之後,才能完整匯出。", + FILL_NOTE, + ITALIC, + ) + ws.row_dimensions[2].height = 36 + for col in range(2, 6): + ws.cell(2, col).fill = FILL_NOTE + ws.cell(2, col).border = THIN + + a_rows = [ + ["A1", "inventory 頂 I0", "套檔前 MAX(inventory.id)", "= 0830 修帳前記住的頂(例 1000)", "≠ → 流程 B(NOT MATCH)。禁止套舊 1.0 INSERT"], + ["A2", "新 UOM pair", "lot 有 (itemId,uomId) 但 inventory 還沒列", "0 或 >0 都可以", ">0 不是失敗:MATCH 時套完舊檔再 1.0;NOT MATCH 時本 dump 1.0 一併補"], + ["A3", "ledger 頂 N", "套檔前 MAX(stock_ledger.id)", "記下,給 2.7 INSERT 用", "—"], + ["A4", "0830 SQL inventory 段", "要不要套", "僅 A1 通過才套", "A1 失敗:inventory / inventoryId 都不要套"], + ] + write_table(ws, 4, 1, ["#", "檢查", "怎麼算", "MATCH 通過", "NOT MATCH/失敗"], a_rows, FILL_HEAD) + + ws.merge_cells("A10:E10") + paint(ws["A10"], "B 僅流程 A(MATCH)套完 0830 SQL、再跑 1.0 之後", FILL_MATCH, WHITE11, WRAP_CENTER) + for col in range(2, 6): + ws.cell(10, col).fill = FILL_MATCH + ws.cell(10, col).border = THIN + + b_rows = [ + ["B1", "needInventoryInsert", "1.0 後還缺的 (item,uom)", "= 0", ">0 → 1.0 沒補完,不要匯出"], + ["B2", "inventory 頂", "1.0 後 MAX(id)", "≥ I0;有新 UOM 會 > 舊頂(例 1051)", "仍 = dump 的 I0 且 A2>0 → 1.0 沒跑或沒 INSERT"], + ["B3", "0830 區間 inventoryId", "miss / wrong vs MIN(inventory.id) per (item,uom)", "= 0", "≠0 → 再跑 0830 的 2.3(短區間)"], + ] + write_table(ws, 11, 1, ["#", "檢查", "怎麼算", "通過", "失敗"], b_rows, FILL_HEAD_M) + + ws.merge_cells("A16:E16") + paint(ws["A16"], "C 兩條流程都要:同夜修完 0831+2.7(adjDate=今天)之後(完整匯出前)", FILL_TITLE, WHITE11, WRAP_CENTER) + for col in range(2, 6): + ws.cell(16, col).fill = FILL_TITLE + ws.cell(16, col).border = THIN + + c_rows = [ + ["C1", "0831 inventoryId", "miss / wrong", "= 0", "再跑 0831 的 2.3"], + ["C2", "2.7 新列", "id > N 且 type=ADJ、createdBy=stock-ledger-fix", "匯出必須 INSERT 這些 id", "現況 writeExportSql 只 UPDATE JOIN id → 正式庫會丟 ADJ"], + ["C3", "正式庫套檔前", "freeze 仍在", "MATCH:I0 仍為記住的頂、ledger 頂仍 N。NOT MATCH:I0 仍為本 dump 的頂", "任一變了 → 不要套完整檔"], + ] + write_table(ws, 17, 1, ["#", "檢查", "怎麼算", "通過", "失敗"], c_rows, FILL_HEAD) + + ws.merge_cells("A22:E22") + paint(ws["A22"], "分支速查(給 23:05)", FILL_GATE, BOLD, WRAP_CENTER) + for col in range(2, 6): + ws.cell(22, col).fill = FILL_GATE + ws.cell(22, col).border = THIN + + branch = [ + ["MATCH", "無新 UOM", "流程 A", "套完整 SQL → 1.0 刷 qty → 修 0830–0831 → 2.7 adjDate=今天 → 匯出 → 套正式庫", "正式庫不必再跑 1.0/2.3"], + ["MATCH", "有新 UOM", "流程 A(同一條)", "套完整 SQL → 1.0 INSERT 新 id → 修 0830–0831 → 2.7 → 完整檔必含 inventory", "不要因 max 仍舊頂就跳過第二次 1.0"], + ["NOT MATCH", "—", "流程 B", "不完整 SQL → 本 dump 1.0 → 長 2.3 → 修 0830–0831 → 2.7 adjDate=今天", "2.3 逾時則本夜不套正式庫"], + ] + write_table(ws, 23, 1, ["A1", "A2 新 UOM", "走哪條", "本夜做什麼", "結果"], branch, FILL_HEAD) + + set_col_widths(ws, {"A": 14, "B": 22, "C": 36, "D": 42, "E": 48}) + for r in list(range(5, 9)) + list(range(12, 15)) + list(range(18, 21)) + list(range(24, 27)): + ws.row_dimensions[r].height = 42 + ws.row_dimensions[1].height = 24 + ws.print_area = "A1:E26" + + +def sheet_notes(wb: Workbook) -> None: + ws = wb.create_sheet("注意與對照") + ws.sheet_view.showGridLines = False + ws.page_setup.orientation = "landscape" + ws.page_setup.fitToPage = True + ws.page_setup.fitToWidth = 1 + ws.page_setup.fitToHeight = 1 + ws.page_setup.paperSize = ws.PAPERSIZE_A4 + + ws.merge_cells("A1:C1") + paint(ws["A1"], "和舊圖的對照、套檔順序、程式缺口", FILL_TITLE, WHITE, WRAP_CENTER) + ws["B1"].fill = FILL_TITLE + ws["C1"].fill = FILL_TITLE + ws["B1"].border = THIN + ws["C1"].border = THIN + + vs_rows = [ + ["舊圖等 0:00 才能修 0831/2.7", "已改:可修今天;2.7 傳 adjDate=今天", "同左"], + ["舊圖 23:11–23:40 1.0+長 2.3", "MATCH:刪長 2.3;套檔後 1.0 很快", "NOT MATCH:長 2.3 約 15 分(23:16)"], + ["舊圖 0:40 才套正式庫", "MATCH:約 23:30", "NOT MATCH:約 23:53"], + ] + write_table(ws, 3, 1, ["舊圖", "流程 A MATCH", "流程 B NOT MATCH"], vs_rows, FILL_HEAD) + + ws.merge_cells("A9:C9") + paint(ws["A9"], "套檔順序(完整檔,兩條流程相同)", FILL_MATCH, WHITE11, WRAP_CENTER) + ws["B9"].fill = FILL_MATCH + ws["C9"].fill = FILL_MATCH + ws["B9"].border = THIN + ws["C9"].border = THIN + + order_rows = [ + ["1", "inventory", "INSERT 寫死新 id + UPDATE 既有列 qty/status(MATCH 的 1001+ 來自 0830 檔;NOT MATCH 來自本 dump 1.0)"], + ["2", "stock_ledger", "id ≤ N → UPDATE(含 inventoryId、lotQty、balance…);id > N(2.7 ADJ)→ INSERT 寫死 id"], + ["3", "stock_lot_day", "刪區間再 INSERT/ON DUPLICATE。在 ledger 之後"], + ["—", "正式庫不要再跑", "Preview C 通過則不必 1.0、不必 2.3。套完再開 expiry/日結"], + ] + write_table(ws, 10, 1, ["#", "對象", "做法"], order_rows, FILL_HEAD_M) + + ws.merge_cells("A16:C16") + paint(ws["A16"], "現有程式缺口(上線前)", FILL_NOMATCH, WHITE11, WRAP_CENTER) + ws["B16"].fill = FILL_NOMATCH + ws["C16"].fill = FILL_NOMATCH + ws["B16"].border = THIN + ws["C16"].border = THIN + + gap_rows = [ + ["fixDay / fixRange / upsertDayForFix", "已允許修/日結「今天」(拒未來)", "freeze 夜可修 0831"], + ["2.7 ADJ", "可傳 adjDate(預設昨天;freeze 夜傳今天)", "UI 選「今天」或 API ?adjDate= / body.adjDate"], + ["writeExportSql", "可匯出到今天;仍可能缺 inventory/inventoryId/2.7 INSERT", "完整檔選項若未做完需手動補"], + ["排程 closeDay", "仍拒今天", "正式庫開站後日結仍只關昨天"], + ] + write_table(ws, 17, 1, ["位置", "現況", "要補"], gap_rows, FILL_HEAD_N) + + ws.merge_cells("A24:C24") + paint( + ws["A24"], + "記住:inventory 頂沒變 ≠ 1.0 做完。新 UOM lot line 時常仍是 max=1000,但一定要再跑 1.0。" + " MATCH 時順序必須是:先套 0830 SQL,再 1.0(否則新 pair 會把 1001+ 編錯)。", + FILL_NOTE, + ITALIC, + ) + ws.row_dimensions[24].height = 42 + ws["B24"].fill = FILL_NOTE + ws["C24"].fill = FILL_NOTE + ws["B24"].border = THIN + ws["C24"].border = THIN + + set_col_widths(ws, {"A": 36, "B": 48, "C": 56}) + for r in list(range(4, 8)) + list(range(11, 15)) + list(range(18, 23)): + ws.row_dimensions[r].height = 36 + ws.row_dimensions[1].height = 24 + ws.print_area = "A1:C24" + + +def main() -> None: + OUT.parent.mkdir(parents=True, exist_ok=True) + wb = Workbook() + sheet_main(wb) + sheet_gate(wb) + sheet_notes(wb) + wb.save(OUT) + print(f"Wrote {OUT}") + + +if __name__ == "__main__": + main() diff --git a/src/main/java/com/ffii/fpsms/modules/common/SettingNames.java b/src/main/java/com/ffii/fpsms/modules/common/SettingNames.java index 3adac3b..8171021 100644 --- a/src/main/java/com/ffii/fpsms/modules/common/SettingNames.java +++ b/src/main/java/com/ffii/fpsms/modules/common/SettingNames.java @@ -59,6 +59,9 @@ public abstract class SettingNames { /** Mark expired inventory lot lines as unavailable (default 00:05 daily) */ public static final String SCHEDULE_INVENTORY_LOT_EXPIRY = "SCHEDULE.inventoryLot.expiry"; + /** Daily stock_lot_day close for yesterday (default 00:15; moves + carry) */ + public static final String SCHEDULE_STOCK_LOT_DAY_CLOSE = "SCHEDULE.stockLotDay.close"; + public static final String SCHEDULE_PROD_ROUGH = "SCHEDULE.prod.rough"; public static final String SCHEDULE_PROD_DETAILED = "SCHEDULE.prod.detailed"; diff --git a/src/main/java/com/ffii/fpsms/modules/common/scheduler/service/SchedulerService.kt b/src/main/java/com/ffii/fpsms/modules/common/scheduler/service/SchedulerService.kt index 875ce4b..efc1aff 100644 --- a/src/main/java/com/ffii/fpsms/modules/common/scheduler/service/SchedulerService.kt +++ b/src/main/java/com/ffii/fpsms/modules/common/scheduler/service/SchedulerService.kt @@ -13,8 +13,9 @@ import com.ffii.fpsms.modules.common.alert.SchedulerSyncAlertService import com.ffii.fpsms.modules.jobOrder.service.JobOrderPlanStartAutoService import com.ffii.fpsms.modules.master.service.BomM18ShopBulkPushService import com.ffii.fpsms.modules.master.service.ProductionScheduleService -import com.ffii.fpsms.modules.stock.service.SearchCompletedDnService import com.ffii.fpsms.modules.stock.service.InventoryLotLineService +import com.ffii.fpsms.modules.stock.service.SearchCompletedDnService +import com.ffii.fpsms.modules.stock.service.StockLotDayCloseService import com.ffii.fpsms.modules.settings.entity.Settings import com.ffii.fpsms.modules.settings.service.SettingsService import jakarta.annotation.PostConstruct @@ -43,6 +44,8 @@ open class SchedulerService( @Value("\${scheduler.m18Units.enabled:true}") val m18UnitsSchedulerEnabled: Boolean, @Value("\${scheduler.m18Units.incrementalLookbackDays:7}") val m18UnitsIncrementalLookbackDays: Int, @Value("\${scheduler.inventoryLotExpiry.enabled:true}") val inventoryLotExpiryEnabled: Boolean, + @Value("\${scheduler.stockLotDayClose.enabled:true}") val stockLotDayCloseEnabled: Boolean, + @Value("\${scheduler.stockLotDayClose.catchUpDays:7}") val stockLotDayCloseCatchUpDays: Int, /** When false (default), M18 PO / DO1 / DO2 / master-data cron jobs are not registered — use true in production only. */ @Value("\${scheduler.m18Sync.enabled:false}") val m18SyncEnabled: Boolean, @Value("\${scheduler.jo.planStart.enabled:true}") val jobOrderPlanStartAutoEnabled: Boolean, @@ -71,6 +74,7 @@ open class SchedulerService( val jobOrderPlanStartAutoService: JobOrderPlanStartAutoService, private val bomM18ShopBulkPushService: BomM18ShopBulkPushService, private val schedulerSyncAlertService: SchedulerSyncAlertService, + private val stockLotDayCloseService: StockLotDayCloseService, @Value("\${scheduler.sync-alert.check-cron:0 */15 * * * *}") private val syncAlertCheckCron: String, @Value("\${scheduler.sync-alert.enabled:false}") private val syncAlertEnabled: Boolean, ) { @@ -82,6 +86,8 @@ open class SchedulerService( const val M18_BOM_SHOP_DEFAULT_CRON: String = "0 0 23 * * *" /** Daily 00:00:15 — process job orders whose planStart was yesterday. */ const val JO_PLAN_START_DEFAULT_CRON: String = "15 0 0 * * *" + /** Daily 00:15 — stock_lot_day close for yesterday (moves + carry). */ + const val STOCK_LOT_DAY_CLOSE_DEFAULT_CRON: String = "0 15 0 * * *" } /** Class logger (was incorrectly wired to JwtTokenUtil, so all scheduler lines showed under that category). */ @@ -111,6 +117,8 @@ open class SchedulerService( var scheduledGrnCodeSync: ScheduledFuture<*>? = null var scheduledInventoryLotExpiry: ScheduledFuture<*>? = null + var scheduledStockLotDayClose: ScheduledFuture<*>? = null + var scheduledJobOrderPlanStart: ScheduledFuture<*>? = null var scheduledDo1CatchUp: ScheduledFuture<*>? = null @@ -208,6 +216,7 @@ open class SchedulerService( schedulePostCompletedDnGrn(); scheduleGrnCodeSync(); scheduleInventoryLotExpiry(); + scheduleStockLotDayClose(); scheduleJobOrderPlanStartAuto(); scheduleDo1CatchUpOnce(); scheduleSyncAlertWatchdog(); @@ -581,6 +590,78 @@ open class SchedulerService( } } + /** + * Daily stock_lot_day close at 00:15 (yesterday + catch-up). + * Set scheduler.stockLotDayClose.enabled=false to disable. + */ + fun scheduleStockLotDayClose() { + if (!stockLotDayCloseEnabled) { + scheduledStockLotDayClose?.cancel(false) + scheduledStockLotDayClose = null + logger.info("stock_lot_day close scheduler disabled (scheduler.stockLotDayClose.enabled=false)") + return + } + scheduledStockLotDayClose = commonSchedule( + scheduledStockLotDayClose, + SettingNames.SCHEDULE_STOCK_LOT_DAY_CLOSE, + STOCK_LOT_DAY_CLOSE_DEFAULT_CRON, + { runStockLotDayClose() }, + ) + logger.info( + "Scheduled stock_lot_day close (default cron={}, catchUpDays={})", + STOCK_LOT_DAY_CLOSE_DEFAULT_CRON, + stockLotDayCloseCatchUpDays, + ) + } + + /** + * Close through [date] (default yesterday). Catch-up from last closed day, capped by catchUpDays. + * Rejects date >= today. + */ + open fun runStockLotDayClose(date: LocalDate? = null): String { + val currentTime = LocalDateTime.now() + return try { + val result = stockLotDayCloseService.closeThrough( + target = date, + catchUpDays = stockLotDayCloseCatchUpDays, + ) + if (result.skipped) { + val msg = "stock_lot_day close skipped: ${result.skipReason}" + logger.warn("Scheduler - {}", msg) + saveSyncLog( + type = "STOCK_LOT_DAY", + status = "SKIPPED", + result = SyncResult(0, 0, 0, msg), + start = currentTime, + ) + return msg + } + val query = "from=${result.dateFrom} to=${result.dateTo} days=${result.days.size} catchUpDays=$stockLotDayCloseCatchUpDays" + saveSyncLog( + type = "STOCK_LOT_DAY", + status = "SUCCESS", + result = SyncResult( + totalProcessed = result.days.size, + totalSuccess = result.totalRows, + totalFail = 0, + query = query, + ), + start = currentTime, + ) + logger.info("Scheduler - stock_lot_day close done {}", query) + "stock_lot_day close ok $query rows=${result.totalRows}" + } catch (e: Exception) { + logger.error("Scheduler - stock_lot_day close failed: ${e.message}", e) + saveSyncLog( + type = "STOCK_LOT_DAY", + status = "FAILED", + error = e.message, + start = currentTime, + ) + throw e + } + } + // Function for schedule // --------------------------- FP-MTMS --------------------------- // diff --git a/src/main/java/com/ffii/fpsms/modules/common/scheduler/web/SchedulerController.kt b/src/main/java/com/ffii/fpsms/modules/common/scheduler/web/SchedulerController.kt index 23e5568..073beda 100644 --- a/src/main/java/com/ffii/fpsms/modules/common/scheduler/web/SchedulerController.kt +++ b/src/main/java/com/ffii/fpsms/modules/common/scheduler/web/SchedulerController.kt @@ -96,6 +96,17 @@ class SchedulerController( return "Inventory lot expiry status update triggered" } + /** + * Manual stock_lot_day close (moves + carry). Omit [date] → through yesterday (with catch-up). + * [date] must be before today. Example: GET /scheduler/trigger/stock-lot-day-close?date=2026-08-20 + */ + @GetMapping("/trigger/stock-lot-day-close") + fun triggerStockLotDayClose( + @RequestParam(required = false) @DateTimeFormat(iso = DateTimeFormat.ISO.DATE) date: LocalDate? = null, + ): String { + return schedulerService.runStockLotDayClose(date) + } + @GetMapping("/trigger/post-completed-dn-grn") fun triggerPostCompletedDnGrn( @RequestParam(required = false) @DateTimeFormat(iso = DateTimeFormat.ISO.DATE) receiptDate: LocalDate? = null, 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 new file mode 100644 index 0000000..7ef5a34 --- /dev/null +++ b/src/main/java/com/ffii/fpsms/modules/master/service/StockLedgerFixService.kt @@ -0,0 +1,3078 @@ +package com.ffii.fpsms.modules.master.service + +import com.ffii.core.support.JdbcDao +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.NamedParameterJdbcTemplate +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 ADJ aligns lot-line in/out to ledger sums (yesterday, new ids; reverse when ledger over). + * + * SIL/SOL have no inventoryId; lot is inventoryLotLineId / inventoryLotId only. + */ +@Service +open class StockLedgerFixService( + private val jdbcDao: JdbcDao, + private val transactionManager: PlatformTransactionManager, + private val dataSource: DataSource, + private val stockLotDayCloseService: StockLotDayCloseService, +) { + private val log = LoggerFactory.getLogger(StockLedgerFixService::class.java) + 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 { + 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, + ) + } + + /** + * A: keep existing inventory ids; INSERT missing (itemId, uomId) from lot lines; + * recalc onHand/onHold/unavailable from Σ lot qty by item+UOM (0 if no lots). + * Does not UPDATE inventory_lot_line. + */ + 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"), + ) + return StockLedgerFixInventoryPreview( + inventoryRows = inventoryRows, + lotUomPairs = lotUomPairs, + missingUomPairs = missingUomPairs, + ) + } + + @Transactional(timeout = 300) + open fun fixInventory10(): StockLedgerFixInventoryResponse { + if (!inFlight.add(inventoryLock)) { + throw IllegalStateException("inventory 1.0 already running") + } + try { + val inserted = jdbcDao.executeUpdate(INSERT_MISSING_INVENTORY_SQL) + val updated = jdbcDao.executeUpdate(UPDATE_INVENTORY_QTY_SQL) + val missingAfter = toInt( + jdbcDao.queryForList(MISSING_UOM_PAIR_COUNT_SQL).firstOrNull()?.get("c"), + ) + log.info( + "stock-ledger-fix inventory-1.0 inserted={} updated={} missingAfter={}", + inserted, + updated, + missingAfter, + ) + return StockLedgerFixInventoryResponse( + inserted = inserted, + updated = updated, + missingUomPairsAfter = missingAfter, + ) + } finally { + inFlight.remove(inventoryLock) + } + } + + 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 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 = 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"]) + + 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 + + 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 = 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 + + 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 = 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 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 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 inventoryScopeDetail(inventoryId: Long): StockLedgerFixScopeDetail { + val today = LocalDate.now() + val args = mapOf("inventoryId" to inventoryId, "today" to today) + 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, + ) + } + + open fun fixInventoryScope(inventoryId: Long): StockLedgerFixRunResponse { + if (inventoryId <= 0) throw IllegalArgumentException("inventoryId is required") + if (!inFlight.add(runLock)) throw IllegalStateException("fix already running") + val today = LocalDate.now() + 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 today, + ) + 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, today) + val range = dateRangeForFilter("inventoryId = :inventoryId", mapOf("inventoryId" to inventoryId, "today" to today)) + 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 today)) + 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() ?: today.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). + * Positive miss → ADJ in/out; negative miss → reverse ADJ out/in. + * Goal: last lotQtyAfter = line remain (negative allowed for true over-issue). + * Ledger gap sum uses `date < adjDate+1`. + */ + open fun adjPreview(rowLimit: Int = 20, adjDateRaw: String? = null): StockLedgerFixAdjPreview { + val (adjDate, asOfExclusive) = resolveAdjWindow(adjDateRaw) + val t0 = System.nanoTime() + val gaps = loadAdjGaps(asOfExclusive) + val auto = gaps.filter { it.missIn.compareTo(BigDecimal.ZERO) != 0 || it.missOut.compareTo(BigDecimal.ZERO) != 0 } + val sumIn = auto.fold(BigDecimal.ZERO) { a, g -> a + g.missIn } + val sumOut = auto.fold(BigDecimal.ZERO) { a, g -> a + g.missOut } + val limit = rowLimit.coerceIn(0, 50) + log.info( + "stock-ledger-fix adj preview lots={} in={} out={} revIn={} revOut={} {}ms", + auto.size, + auto.count { it.missIn > BigDecimal.ZERO }, + auto.count { it.missOut > BigDecimal.ZERO }, + auto.count { it.missIn < BigDecimal.ZERO }, + auto.count { it.missOut < BigDecimal.ZERO }, + (System.nanoTime() - t0) / 1_000_000, + ) + return StockLedgerFixAdjPreview( + adjDate = adjDate.toString(), + lotCount = auto.size, + adjInCount = auto.count { it.missIn > BigDecimal.ZERO }, + adjOutCount = auto.count { it.missOut > BigDecimal.ZERO }, + skippedNegCount = auto.count { it.missIn < BigDecimal.ZERO || it.missOut < BigDecimal.ZERO }, + sumMissIn = sumIn.stripTrailingZeros().toPlainString(), + sumMissOut = sumOut.stripTrailingZeros().toPlainString(), + skuNet = (sumIn - sumOut).stripTrailingZeros().toPlainString(), + rows = auto.take(limit).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 auto = gaps.filter { it.missIn.compareTo(BigDecimal.ZERO) != 0 || it.missOut.compareTo(BigDecimal.ZERO) != 0 } + var insertedIn = 0 + var insertedOut = 0 + if (auto.isNotEmpty()) { + val insTx = TransactionTemplate(transactionManager) + insTx.timeout = 300 + insTx.execute { + auto.forEach { g -> + if (g.missIn.compareTo(BigDecimal.ZERO) > 0) { + insertAdjLedger(g, adjDate, inQty = g.missIn, outQty = null) + insertedIn++ + } else if (g.missIn.compareTo(BigDecimal.ZERO) < 0) { + insertAdjLedger(g, adjDate, inQty = null, outQty = g.missIn.negate()) + insertedOut++ + } + if (g.missOut.compareTo(BigDecimal.ZERO) > 0) { + insertAdjLedger(g, adjDate, inQty = null, outQty = g.missOut) + insertedOut++ + } else if (g.missOut.compareTo(BigDecimal.ZERO) < 0) { + insertAdjLedger(g, adjDate, inQty = g.missOut.negate(), outQty = null) + insertedIn++ + } + } + } + } + val lotIds = linkedSetOf() + fun addLot(lotId: Long, invId: Long?) { + if (lotId > 0) lotIds.add(lotId) + } + auto.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 {} in={} out={} lotQty={} bal={} day={} total={}ms", + adjDate, insertedIn, insertedOut, filledLotQty, filledBalance, dayRows, + (System.nanoTime() - t0) / 1_000_000, + ) + return StockLedgerFixAdjResponse( + adjDate = adjDate.toString(), + insertedIn = insertedIn, + insertedOut = insertedOut, + 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 (before today). + * 2.1–2.5 use a single date window (same SQL as [fixDay]); 2.6 still walks days + * because stock_lot_day carry depends on the previous close. + */ + 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 { + val args = rangeArgs(from, to) + val filledLotLineId = if ("2.1" in steps) timed("range 2.1 fillLotLineId") { fillLotLineId(args) } else 0 + val filledUomId = if ("2.2" in steps) timed("range 2.2 fillUomId") { fillUomId(args) } else 0 + val filledInventoryId = if ("2.3" in steps) timed("range 2.3 fillInventoryId") { fillInventoryId(args) } else 0 + val filledLotQty = if ("2.4" in steps) timed("range 2.4 fillLotQty") { fillLotQty(args) } else 0 + val filledBalance = if ("2.5" in steps) timed("range 2.5 fillBalance") { fillBalance(args) } else 0 + val dayRowsWritten = if ("2.6" in steps) { + timed("range 2.6 upsertStockLotDay") { + var n = 0 + var d = from + while (!d.isAfter(to)) { + n += stockLotDayCloseService.upsertDayForFix(d).rowsWritten + d = d.plusDays(1) + } + n + } + } 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) + } + } + + /** 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 inventory by (itemId, uomId) from 1.0 MySQL step. */ + private fun fillInventoryId(args: Map): Int { + return jdbcDao.executeUpdate( + """ + UPDATE stock_ledger sl + INNER JOIN ( + SELECT i.itemId, i.uomId, MIN(i.id) AS inventoryId + FROM inventory i + INNER JOIN ( + SELECT DISTINCT itemId, uomId + FROM stock_ledger + WHERE deleted = 0 + AND date >= :day AND date < :dayNext + AND itemId IS NOT NULL + AND uomId IS NOT NULL + ) t ON t.itemId = i.itemId AND t.uomId = i.uomId + WHERE IFNULL(i.deleted, 0) = 0 + GROUP BY i.itemId, i.uomId + ) inv ON inv.itemId = sl.itemId AND inv.uomId = sl.uomId + SET sl.inventoryId = inv.inventoryId + WHERE sl.deleted = 0 + AND sl.date >= :day AND sl.date < :dayNext + AND sl.itemId IS NOT NULL + AND sl.uomId IS NOT NULL + """.trimIndent(), + args, + ) + } + + /** + * 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 jdbcDao.executeUpdate( + """ + UPDATE stock_ledger sl + INNER JOIN ( + SELECT i.itemId, i.uomId, MIN(i.id) AS inventoryId + FROM inventory i + WHERE IFNULL(i.deleted, 0) = 0 + GROUP BY i.itemId, i.uomId + ) inv ON inv.itemId = sl.itemId AND inv.uomId = sl.uomId + SET sl.inventoryId = inv.inventoryId + WHERE sl.deleted = 0 + AND sl.date < :today + AND sl.itemId IS NOT NULL + AND sl.uomId IS NOT NULL + AND sl.inventoryLotLineId = :lotId + """.trimIndent(), + args, + ) + } + 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, + ) + jdbcDao.executeUpdate( + """ + UPDATE stock_ledger sl + INNER JOIN tmp_sl_fix_inv_ids t ON t.id = sl.id + INNER JOIN ( + SELECT i.itemId, i.uomId, MIN(i.id) AS inventoryId + FROM inventory i + WHERE IFNULL(i.deleted, 0) = 0 + GROUP BY i.itemId, i.uomId + ) inv ON inv.itemId = sl.itemId AND inv.uomId = sl.uomId + SET sl.inventoryId = inv.inventoryId + """.trimIndent(), + ) + } 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, + ) + } + + 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 ADJ INSERT. + * Apply order in file: 1.0 → ledger(+2.3) → 2.7 → 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 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) { + 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, 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, 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, "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), " + + "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), " + + "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 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), " + + "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), " + + "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 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] so 2.1–2.5 SQL can reuse the day window. */ + 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, uomId, MIN(id) AS inventoryId + FROM inventory + WHERE IFNULL(deleted, 0) = 0 + GROUP BY itemId, uomId + ) inv ON inv.itemId = il.itemId AND inv.uomId = iu.uomId + 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)) + """.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?) { + val args = hashMapOf( + "adjDate" to adjDate, + "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, NULL, NULL, :lotLineId, :inventoryId, + :itemId, :itemCode, :inQty, :outQty, NULL, NULL, NULL, + :uomId, 'ADJ' + ) + """.trimIndent(), + args, + ) + } + + 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, + ) { + 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(), + ) + } + + 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 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 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 INSERT_MISSING_INVENTORY_SQL = """ + INSERT INTO inventory ( + itemId, uomId, onHandQty, onHoldQty, unavailableQty, + price, currencyId, cpu, cpuUnit, cpm, cpmUnit, status, + created, createdBy, modified, modifiedBy, version, deleted + ) + SELECT + src.itemId, + 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 inventory existing + ON existing.itemId = src.itemId + AND existing.uomId = src.uomId + AND IFNULL(existing.deleted, 0) = 0 + 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 + """ + + 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(COALESCE(ill.holdQty, 0)) AS onHold, + 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 = i.uomId + SET + i.onHandQty = COALESCE(agg.onHand, 0), + i.onHoldQty = COALESCE(agg.onHold, 0), + i.unavailableQty = COALESCE(agg.unavailable, 0), + i.status = IF( + COALESCE(agg.onHand, 0) - COALESCE(agg.onHold, 0) - COALESCE(agg.unavailable, 0) > 0, + '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/web/ItemsController.kt b/src/main/java/com/ffii/fpsms/modules/master/web/ItemsController.kt index 5b83ade..ea5d4dc 100644 --- a/src/main/java/com/ffii/fpsms/modules/master/web/ItemsController.kt +++ b/src/main/java/com/ffii/fpsms/modules/master/web/ItemsController.kt @@ -89,8 +89,8 @@ class ItemsController( @GetMapping("/pickOrderItems") fun getPickOrderItemsByPage(request: HttpServletRequest): RecordsRes> { try { - println("=== Debug: getPickOrderItemsByPage ===") - println("Request parameters: ${request.parameterMap}") + //println("=== Debug: getPickOrderItemsByPage ===") + //println("Request parameters: ${request.parameterMap}") val criteriaArgs = CriteriaArgsBuilder.withRequest(request) .addStringLike("itemCode") @@ -100,18 +100,18 @@ class ItemsController( .addString("targetDateTo") .build() - println("Criteria args: $criteriaArgs") - + //println("Criteria args: $criteriaArgs") + val pageSize = request.getParameter("pageSize")?.toIntOrNull() ?: 10 val pageNum = request.getParameter("pageNum")?.toIntOrNull() ?: 1 - println("Page size: $pageSize, Page num: $pageNum") + //println("Page size: $pageSize, Page num: $pageNum") val fullList = itemsService.getPickOrderItemsByPage(criteriaArgs) - println("Full list size: ${fullList.size}") + //println("Full list size: ${fullList.size}") val paginatedList = PagingUtils.getPaginatedList(fullList, pageSize, pageNum) - println("Paginated list size: ${paginatedList.size}") + //println("Paginated list size: ${paginatedList.size}") return RecordsRes(paginatedList, fullList.size) } catch (e: Exception) { diff --git a/src/main/java/com/ffii/fpsms/modules/master/web/StockLedgerFixController.kt b/src/main/java/com/ffii/fpsms/modules/master/web/StockLedgerFixController.kt new file mode 100644 index 0000000..b255f47 --- /dev/null +++ b/src/main/java/com/ffii/fpsms/modules/master/web/StockLedgerFixController.kt @@ -0,0 +1,126 @@ +package com.ffii.fpsms.modules.master.web + +import com.ffii.fpsms.modules.master.service.StockLedgerFixService +import com.ffii.fpsms.modules.master.web.models.StockLedgerFixAdjRequest +import com.ffii.fpsms.modules.master.web.models.StockLedgerFixCalendarResponse +import com.ffii.fpsms.modules.master.web.models.StockLedgerFixDayDetail +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.StockLedgerFixRunRequest +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 org.springframework.format.annotation.DateTimeFormat +import org.springframework.http.HttpHeaders +import org.springframework.security.access.prepost.PreAuthorize +import org.springframework.web.bind.annotation.GetMapping +import org.springframework.web.bind.annotation.PostMapping +import org.springframework.web.bind.annotation.RequestBody +import org.springframework.web.bind.annotation.RequestMapping +import org.springframework.web.bind.annotation.RequestParam +import org.springframework.web.bind.annotation.RestController +import jakarta.servlet.http.HttpServletResponse +import java.time.LocalDate + +/** + * Admin data-fix: inventory 1.0 (A) then ledger days (2.1–2.6), including date-range mode. + */ +@RestController +@RequestMapping("/stock-ledger-fix") +open class StockLedgerFixController( + private val stockLedgerFixService: StockLedgerFixService, +) { + @PreAuthorize("hasAnyAuthority('ADMIN','TESTING')") + @GetMapping("/calendar") + open fun calendar( + @RequestParam @DateTimeFormat(iso = DateTimeFormat.ISO.DATE) from: LocalDate, + @RequestParam @DateTimeFormat(iso = DateTimeFormat.ISO.DATE) to: LocalDate, + ): StockLedgerFixCalendarResponse = stockLedgerFixService.calendar(from, to) + + @PreAuthorize("hasAnyAuthority('ADMIN','TESTING')") + @GetMapping("/day") + open fun day( + @RequestParam @DateTimeFormat(iso = DateTimeFormat.ISO.DATE) date: LocalDate, + ): StockLedgerFixDayDetail = stockLedgerFixService.dayDetail(date) + + @PreAuthorize("hasAnyAuthority('ADMIN','TESTING')") + @GetMapping("/inventory") + open fun inventoryPreview(): StockLedgerFixInventoryPreview = + stockLedgerFixService.inventoryPreview() + + @PreAuthorize("hasAnyAuthority('ADMIN','TESTING')") + @PostMapping("/inventory") + open fun inventory(): StockLedgerFixInventoryResponse = + stockLedgerFixService.fixInventory10() + + @PreAuthorize("hasAnyAuthority('ADMIN','TESTING')") + @GetMapping("/lookup/inventory") + open fun lookupInventory(@RequestParam q: String): List = + stockLedgerFixService.searchInventory(q) + + @PreAuthorize("hasAnyAuthority('ADMIN','TESTING')") + @GetMapping("/lookup/lot") + open fun lookupLot(@RequestParam q: String): List = + stockLedgerFixService.searchLot(q) + + @PreAuthorize("hasAnyAuthority('ADMIN','TESTING')") + @GetMapping("/scope/inventory") + open fun inventoryScope(@RequestParam id: Long): StockLedgerFixScopeDetail = + stockLedgerFixService.inventoryScopeDetail(id) + + @PreAuthorize("hasAnyAuthority('ADMIN','TESTING')") + @GetMapping("/scope/lot") + open fun lotScope(@RequestParam id: Long): StockLedgerFixScopeDetail = + stockLedgerFixService.lotScopeDetail(id) + + @PreAuthorize("hasAnyAuthority('ADMIN','TESTING')") + @GetMapping("/export") + open fun export( + @RequestParam @DateTimeFormat(iso = DateTimeFormat.ISO.DATE) from: LocalDate, + @RequestParam @DateTimeFormat(iso = DateTimeFormat.ISO.DATE) to: LocalDate, + @RequestParam(required = false) parts: List?, + response: HttpServletResponse, + ) { + val name = "stock_ledger_fix_${from.toString().replace("-", "")}_${to.toString().replace("-", "")}.sql" + response.contentType = "application/sql;charset=UTF-8" + response.setHeader(HttpHeaders.CONTENT_DISPOSITION, "attachment; filename=\"$name\"") + stockLedgerFixService.writeExportSql(from, to, response.outputStream, parts) + } + + @PreAuthorize("hasAnyAuthority('ADMIN','TESTING')") + @GetMapping("/adj") + open fun adjPreview( + @RequestParam(defaultValue = "20") rowLimit: Int, + @RequestParam(required = false) adjDate: String?, + ) = stockLedgerFixService.adjPreview(rowLimit, adjDate) + + @PreAuthorize("hasAnyAuthority('ADMIN','TESTING')") + @PostMapping("/adj") + open fun adjApply(@RequestBody(required = false) body: StockLedgerFixAdjRequest?) = + stockLedgerFixService.applyAdjAlign(body?.adjDate) + + @PreAuthorize("hasAnyAuthority('ADMIN','TESTING')") + @PostMapping("/run") + open fun run(@RequestBody request: StockLedgerFixRunRequest): StockLedgerFixRunResponse { + val mode = request.mode?.trim()?.lowercase().orEmpty().ifEmpty { "day" } + return when (mode) { + "day" -> stockLedgerFixService.fixDay( + request.date ?: throw IllegalArgumentException("date is required"), + request.steps, + ) + "range" -> stockLedgerFixService.fixRange( + request.from ?: throw IllegalArgumentException("from is required"), + request.to ?: throw IllegalArgumentException("to is required"), + request.steps, + ) + "inventory" -> stockLedgerFixService.fixInventoryScope( + request.inventoryId ?: throw IllegalArgumentException("inventoryId is required"), + ) + "lot" -> stockLedgerFixService.fixLotScope( + request.lotLineId ?: throw IllegalArgumentException("lotLineId is required"), + ) + else -> throw IllegalArgumentException("mode must be day, range, inventory, or lot") + } + } +} 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 new file mode 100644 index 0000000..eb9f959 --- /dev/null +++ b/src/main/java/com/ffii/fpsms/modules/master/web/models/StockLedgerFixModels.kt @@ -0,0 +1,138 @@ +package com.ffii.fpsms.modules.master.web.models + +data class StockLedgerFixRunRequest( + val date: String? = null, + val from: String? = null, + val to: String? = null, + val mode: String? = null, + val inventoryId: Long? = null, + val lotLineId: Long? = null, + /** Day / range. Omit / empty / ["all"] = 2.1–2.6. Else subset e.g. ["2.3"]. */ + val steps: List? = null, +) + +data class StockLedgerFixDayStatus( + val date: String, + val cnt: Int, + val missLot: Int, + val missUom: Int, + val missInventoryId: Int, + val missLotQty: Int, + val dayTableLots: Int, +) + +data class StockLedgerFixCalendarResponse( + val from: String, + val to: String, + val days: List, +) + +data class StockLedgerFixRunResponse( + val date: String, + val filledLotLineId: Int, + val filledUomId: Int, + val filledInventoryId: Int, + val filledLotQty: Int, + val filledBalance: Int, + val dayRowsWritten: Int, + val stillMissLot: Int, + val stillMissUom: Int, + val stillMissInventoryId: Int, + val stillMissLotQty: Int, +) + +data class StockLedgerFixCheckPart( + val key: String, + val label: String, + val ok: Int, + val miss: Int, + val incorrect: Int, + val group: String = "field", +) + +data class StockLedgerFixDayDetail( + val date: String, + val cnt: Int, + val parts: List, +) + +data class StockLedgerFixInventoryPreview( + val inventoryRows: Int, + val lotUomPairs: Int, + val missingUomPairs: Int, +) + +data class StockLedgerFixInventoryResponse( + val inserted: Int, + val updated: Int, + val missingUomPairsAfter: Int, +) + +data class StockLedgerFixSearchInventoryHit( + val inventoryId: Long, + val itemId: Long?, + val itemCode: String?, + val uomId: Long?, + val ledgerCnt: Int, +) + +data class StockLedgerFixSearchLotHit( + val inventoryLotLineId: Long, + val lotNo: String?, + val itemCode: String?, + val inventoryId: Long?, + val ledgerCnt: Int, +) + +data class StockLedgerFixAdjRequest( + /** Optional ADJ ledger date (yyyy-MM-dd). Default = yesterday. Use today on freeze-night dump. */ + val adjDate: String? = null, +) + +data class StockLedgerFixAdjRow( + val lotLineId: Long, + val inventoryId: Long?, + val itemCode: String?, + val lineIn: String, + val lineOut: String, + val ledgerIn: String, + val ledgerOut: String, + val missIn: String, + val missOut: String, +) + +data class StockLedgerFixAdjPreview( + val adjDate: String, + val lotCount: Int, + val adjInCount: Int, + val adjOutCount: Int, + val skippedNegCount: Int, + val sumMissIn: String, + val sumMissOut: String, + val skuNet: String, + val rows: List, +) + +data class StockLedgerFixAdjResponse( + val adjDate: String, + val insertedIn: Int, + val insertedOut: Int, + val filledLotQty: Int, + val filledBalance: Int, + val dayRowsWritten: Int, +) + +data class StockLedgerFixScopeDetail( + val kind: String, + val id: Long, + val itemCode: String?, + val lotNo: String?, + val inventoryId: Long?, + val uomId: Long?, + val firstDate: String?, + val lastDate: String?, + val cnt: Int, + val lastBalance: String?, + val lastLotQtyAfter: String?, + val parts: List, +) diff --git a/src/main/java/com/ffii/fpsms/modules/report/service/ReportService.kt b/src/main/java/com/ffii/fpsms/modules/report/service/ReportService.kt index 845af94..392733c 100644 --- a/src/main/java/com/ffii/fpsms/modules/report/service/ReportService.kt +++ b/src/main/java/com/ffii/fpsms/modules/report/service/ReportService.kt @@ -641,12 +641,19 @@ return result * Joins stock_in_line, stock_in, items, qc_result, inventory_lot, inventory_lot_line, warehouse, and shop tables. * Supports comma-separated values for stockCategory (items.type) and itemCode. * Date range [lastInDateStart, lastInDateEnd] filters on stock_in_line.productionDate (完成生產日期), same basis as 成品/半成品生產分析報告. + * Always limited to NOR stock-in (excludes TRF). PP/PF uses this line's PO code prefix, not lot-origin TRF walk. */ fun searchStockInTraceabilityReport( stockCategory: String?, itemCode: String?, lastInDateStart: String?, - lastInDateEnd: String? + lastInDateEnd: String?, + storeId: String? = null, + warehouse: String? = null, + area: String? = null, + slot: String? = null, + lotNo: String? = null, + poPrefix: String? = null, ): List> { val args = mutableMapOf() @@ -671,6 +678,34 @@ return result "AND sil.productionDate IS NOT NULL AND DATE(sil.productionDate) <= DATE(:lastInDateEnd)" } else "" + val storeIdSql = if (!storeId.isNullOrBlank() && storeId.trim() != "All") { + args["storeId"] = storeId.trim() + "AND REPLACE(COALESCE(wh.store_id, ''), '/', '') = REPLACE(:storeId, '/', '')" + } else { + "" + } + val warehouseSql = if (!warehouse.isNullOrBlank() && warehouse.trim() != "All") { + buildMultiValueLikeClause(warehouse, "wh.warehouse", "warehousePart", args) + } else { + "" + } + val areaSql = if (!area.isNullOrBlank() && area.trim() != "All") { + buildMultiValueLikeClause(area, "wh.area", "areaPart", args) + } else { + "" + } + val slotSql = if (!slot.isNullOrBlank() && slot.trim() != "All") { + buildMultiValueLikeClause(slot, "wh.slot", "slotPart", args) + } else { + "" + } + val lotNoSql = buildMultiValueLikeClause(lotNo, "COALESCE(sil.lotNo, il.lotNo)", "lotNo", args) + val poPrefixSql = when (poPrefix?.trim()?.uppercase()) { + "PP" -> "AND UPPER(LEFT(TRIM(COALESCE(po.code, '')), 2)) = 'PP'" + "PF" -> "AND UPPER(LEFT(TRIM(COALESCE(po.code, '')), 2)) = 'PF'" + else -> "" + } + val sql = """ SELECT COALESCE(it.code, '') as itemNo, @@ -728,10 +763,17 @@ return result GROUP BY qr.stockInLineId ) qr_agg ON qr_agg.stockInLineId = sil.id WHERE sil.deleted = false + AND UPPER(TRIM(COALESCE(sil.type, ''))) = 'NOR' $stockCategorySql $itemCodeSql $lastInDateStartSql $lastInDateEndSql + $storeIdSql + $warehouseSql + $areaSql + $slotSql + $lotNoSql + $poPrefixSql ORDER BY it.code, sil.lotNo """.trimIndent() diff --git a/src/main/java/com/ffii/fpsms/modules/report/service/StockLotBalanceReportService.kt b/src/main/java/com/ffii/fpsms/modules/report/service/StockLotBalanceReportService.kt new file mode 100644 index 0000000..9bab7f8 --- /dev/null +++ b/src/main/java/com/ffii/fpsms/modules/report/service/StockLotBalanceReportService.kt @@ -0,0 +1,372 @@ +package com.ffii.fpsms.modules.report.service + +import com.ffii.core.support.JdbcDao +import org.slf4j.LoggerFactory +import org.springframework.stereotype.Service +import java.time.LocalDate +import java.time.format.DateTimeFormatter +import java.util.Locale + +/** + * 庫存批次結餘:永遠今天。現存讀 [inventory_lot_line](inQty-outQty), + * 當天入/出/MISS/BAD/過期/盤盈虧讀當天 [stock_ledger]。 + */ +@Service +open class StockLotBalanceReportService( + private val jdbcDao: JdbcDao, +) { + private val log = LoggerFactory.getLogger(StockLotBalanceReportService::class.java) + private val dateFmt = DateTimeFormatter.ofPattern("yyyy-MM-dd") + + data class TimedResult( + val rows: List>, + val timings: List, + val stockDate: String, + ) + + fun searchStockLotBalanceReport( + stockDate: String?, + itemCode: String?, + warehouseCode: String?, + storeId: String?, + lotNo: String?, + ): List> = searchStockLotBalanceReportTimed( + stockDate, itemCode, warehouseCode, storeId, lotNo, + ).rows + + fun searchStockLotBalanceReportTimed( + stockDate: String?, + itemCode: String?, + warehouseCode: String?, + storeId: String?, + lotNo: String?, + ): TimedResult { + val asOfDate = LocalDate.now() + val asOfDateStr = asOfDate.format(dateFmt) + if (!stockDate.isNullOrBlank() && stockDate.replace("/", "-") != asOfDateStr) { + log.info("stock-lot-balance ignore stockDate={} use today={}", stockDate, asOfDateStr) + } + + val timings = mutableListOf() + val tAll = System.nanoTime() + fun timed(name: String, extra: (T) -> String, block: () -> T): T { + val t0 = System.nanoTime() + return try { + val r = block() + val ms = (System.nanoTime() - t0) / 1_000_000 + val line = "$name ${extra(r)} ${ms}ms" + timings.add(line) + log.info("stock-lot-balance {}", line) + r + } catch (e: Exception) { + val ms = (System.nanoTime() - t0) / 1_000_000 + log.warn("stock-lot-balance {} FAILED {}ms: {}", name, ms, e.message) + timings.add("$name FAILED ${ms}ms") + throw e + } + } + + val args = mutableMapOf( + "d0Start" to asOfDate.atStartOfDay(), + "d0EndExclusive" to asOfDate.plusDays(1).atStartOfDay(), + ) + val itemCodeSqlSl = buildMultiValueLikeClause(itemCode, "sl.itemCode", "itemCodeSl", args) + val forceLedgerIndex = if (itemCode.isNullOrBlank()) { + "FORCE INDEX (idx_sl_deleted_date)" + } else { + "" + } + + val masterArgs = HashMap() + val itemCodeSqlIt = buildMultiValueLikeClause(itemCode, "it.code", "itemCodeIt", masterArgs) + val lotNoSql = buildMultiValueLikeClause(lotNo, "il.lotNo", "lotNo", masterArgs) + val warehouseCodeSql = if (!warehouseCode.isNullOrBlank() && warehouseCode.trim() != "All") { + buildMultiValueLikeClause(warehouseCode, "wh.code", "warehouseCode", masterArgs) + } else { + "" + } + val storeIdSql = if (!storeId.isNullOrBlank() && storeId.trim() != "All") { + masterArgs["storeId"] = storeId.trim() + "AND REPLACE(COALESCE(wh.store_id, ''), '/', '') = REPLACE(:storeId, '/', '')" + } else { + "" + } + val masterSelectSql = """ + SELECT + ill.id AS inventoryLotLineId, + COALESCE(it.code, '') AS itemNo, + COALESCE(it.name, '') AS itemName, + COALESCE(uc.udfudesc, uc.code, '') AS unitOfMeasure, + COALESCE(il.lotNo, '') AS lotNo, + COALESCE(DATE_FORMAT(il.expiryDate, '%Y-%m-%d'), '') AS expiryDate, + COALESCE(wh.code, '') AS warehouseCode, + COALESCE(wh.store_id, '') AS storeId, + (COALESCE(ill.inQty, 0) - COALESCE(ill.outQty, 0)) AS currentQty, + COALESCE(CAST(NULLIF(it.AverageUnitPrice, '') AS DECIMAL(14, 4)), 0) AS avgUnitPriceRaw + FROM inventory_lot_line ill + INNER JOIN inventory_lot il + ON il.id = ill.inventoryLotId AND il.deleted = 0 + INNER JOIN items it + ON it.id = il.itemId AND it.deleted = 0 + INNER JOIN warehouse wh + ON wh.id = ill.warehouseId AND wh.deleted = 0 + LEFT JOIN item_uom iu + ON iu.id = ill.stockItemUomId AND iu.deleted = 0 + LEFT JOIN uom_conversion uc + ON uc.id = iu.uomId + WHERE ill.deleted = 0 + AND it.code IS NOT NULL AND it.code <> '' + $itemCodeSqlIt + $warehouseCodeSql + $storeIdSql + $lotNoSql + """.trimIndent() + + val liveLots = timed("1.live_lots", { rows: List> -> "rows=${rows.size}" }) { + jdbcDao.queryForList( + """ + $masterSelectSql + AND (COALESCE(ill.inQty, 0) - COALESCE(ill.outQty, 0)) <> 0 + """.trimIndent(), + masterArgs, + ) + } + val masterByLot = HashMap>(liveLots.size * 2) + for (r in liveLots) { + masterByLot[toLong(r["inventoryLotLineId"])] = r + } + + val ledgerRows = timed("2.day_ledger", { rows: List> -> "rows=${rows.size}" }) { + jdbcDao.queryForList( + """ + SELECT + sl.inventoryLotLineId, + sl.id AS slId, + sl.type, + COALESCE(sl.inQty, 0) AS inQty, + COALESCE(sl.outQty, 0) AS outQty, + sl.lotQtyBefore, + sl.lotQtyAfter + FROM stock_ledger sl $forceLedgerIndex + WHERE sl.deleted = 0 + AND sl.date >= :d0Start + AND sl.date < :d0EndExclusive + AND sl.inventoryLotLineId IS NOT NULL + $itemCodeSqlSl + """.trimIndent(), + args, + ) + } + + val movesByLot = timed("3.agg_types", { map: Map -> "lots=${map.size}" }) { + aggregateDayMoves(ledgerRows) + } + + val missingMoveIds = movesByLot.keys.filter { it !in masterByLot } + timed("4.zero_lots", { rows: List> -> "rows=${rows.size} missing=${missingMoveIds.size}" }) { + if (missingMoveIds.isEmpty()) emptyList() + else queryLotIdChunks(missingMoveIds, masterArgs) { chunkArgs -> + jdbcDao.queryForList( + """ + $masterSelectSql + AND ill.id IN (:lotIds) + """.trimIndent(), + chunkArgs, + ) + } + }.forEach { r -> + masterByLot[toLong(r["inventoryLotLineId"])] = r + } + + val rows = timed("5.assemble", { list: List> -> "rows=${list.size}" }) { + assembleRows(movesByLot, masterByLot) + } + + val totalMs = (System.nanoTime() - tAll) / 1_000_000 + timings.add("search.total ${totalMs}ms") + log.info( + "stock-lot-balance DONE stockDate={} itemCode={} {}", + asOfDateStr, + itemCode ?: "", + timings.joinToString(" | "), + ) + return TimedResult(rows = rows, timings = timings, stockDate = asOfDateStr) + } + + private data class LotDayAgg( + var cumStockIn: Double = 0.0, + var cumStockOut: Double = 0.0, + var misInputAndLost: Double = 0.0, + var variance: Double = 0.0, + var defectiveGoods: Double = 0.0, + var expiredDisposed: Double = 0.0, + var firstSlId: Long = Long.MAX_VALUE, + var lastSlId: Long = Long.MIN_VALUE, + var firstQtyBefore: Double? = null, + var lastQtyAfter: Double? = null, + ) { + fun hasMovement(): Boolean = + cumStockIn != 0.0 || + cumStockOut != 0.0 || + misInputAndLost != 0.0 || + variance != 0.0 || + defectiveGoods != 0.0 || + expiredDisposed != 0.0 + } + + private fun aggregateDayMoves(ledgerRows: List>): Map { + val byLot = HashMap() + for (r in ledgerRows) { + val lotId = toLong(r["inventoryLotLineId"]) + if (lotId <= 0) continue + val slId = toLong(r["slId"]) + val type = r["type"]?.toString()?.trim()?.uppercase().orEmpty() + val inQty = toDouble(r["inQty"]) + val outQty = toDouble(r["outQty"]) + val agg = byLot.getOrPut(lotId) { LotDayAgg() } + when (type) { + "NOR", "ADJ", "TRF", "OPEN" -> + if (inQty > 0) agg.cumStockIn += inQty + } + when (type) { + "NOR", "ADJ", "TRF" -> + if (outQty > 0) agg.cumStockOut += outQty + } + if (type == "MISS" && outQty > 0) agg.misInputAndLost += outQty + if (type == "BAD" && outQty > 0) agg.defectiveGoods += outQty + if (type == "EXPIRY" && outQty > 0) agg.expiredDisposed += outQty + if (type == "TKE") agg.variance += inQty - outQty + if (slId > 0 && slId < agg.firstSlId) { + agg.firstSlId = slId + agg.firstQtyBefore = r["lotQtyBefore"]?.let { toDouble(it) } + } + if (slId > 0 && slId > agg.lastSlId) { + agg.lastSlId = slId + agg.lastQtyAfter = r["lotQtyAfter"]?.let { toDouble(it) } + } + } + return byLot + } + + private fun assembleRows( + movesByLot: Map, + masterByLot: Map>, + ): List> { + val lotIds = LinkedHashSet(masterByLot.size) + lotIds.addAll(masterByLot.keys) + val out = ArrayList>(lotIds.size) + for (lotId in lotIds) { + val master = masterByLot[lotId] ?: continue + val move = movesByLot[lotId] + val current = toDouble(master["currentQty"]) + val opening = move?.firstQtyBefore ?: current + val keep = current != 0.0 || (move?.hasMovement() == true) + if (!keep) continue + out.add(buildRow(lotId, master, opening, current, move)) + } + out.sortWith( + compareBy> { it["itemNo"]?.toString().orEmpty() } + .thenBy { it["lotNo"]?.toString().orEmpty() } + .thenBy { it["warehouseCode"]?.toString().orEmpty() } + .thenBy { it["inventoryLotLineId"]?.toString().orEmpty() }, + ) + return out + } + + private fun buildRow( + lotId: Long, + master: Map, + opening: Double, + current: Double, + move: LotDayAgg?, + ): Map { + val cumIn = move?.cumStockIn ?: 0.0 + val cumOut = move?.cumStockOut ?: 0.0 + val miss = move?.misInputAndLost ?: 0.0 + val variance = move?.variance ?: 0.0 + val defective = move?.defectiveGoods ?: 0.0 + val expired = move?.expiredDisposed ?: 0.0 + val avg = toDouble(master["avgUnitPriceRaw"]) + val stockValue = avg * current + val row = HashMap(24) + row["inventoryLotLineId"] = lotId.toString() + row["itemNo"] = master["itemNo"] ?: "" + row["itemName"] = master["itemName"] ?: "" + row["unitOfMeasure"] = master["unitOfMeasure"] ?: "" + row["lotNo"] = master["lotNo"] ?: "" + row["expiryDate"] = master["expiryDate"] ?: "" + row["warehouseCode"] = master["warehouseCode"] ?: "" + row["storeId"] = master["storeId"] ?: "" + row["storeLocation"] = master["warehouseCode"] ?: "" + row["openingBalanceRaw"] = opening + row["cumStockInRaw"] = cumIn + row["cumStockOutRaw"] = cumOut + row["misInputAndLostRaw"] = miss + row["varianceRaw"] = variance + row["defectiveGoodsRaw"] = defective + row["expiredDisposedRaw"] = expired + row["currentBalanceRaw"] = current + row["avgUnitPriceRaw"] = avg + row["totalOpeningBalance"] = formatQty(opening) + row["totalCumStockIn"] = formatQty(cumIn) + row["totalCumStockOut"] = formatQty(cumOut) + row["totalMisInputAndLost"] = formatQty(miss) + row["totalVariance"] = formatQty(variance) + row["totalDefectiveGoods"] = formatQty(defective) + row["totalExpiredDisposed"] = formatQty(expired) + row["totalCurrentBalance"] = formatQty(current) + row["avgUnitPrice"] = formatQty(avg) + row["totalStockBalance"] = formatQty(stockValue) + return row + } + + private fun queryLotIdChunks( + lotIds: Collection, + baseArgs: Map, + query: (Map) -> List>, + ): List> { + val ids = lotIds.filter { it > 0 }.distinct() + if (ids.isEmpty()) return emptyList() + val out = ArrayList>() + for (chunk in ids.chunked(800)) { + val chunkArgs = HashMap(baseArgs) + chunkArgs["lotIds"] = chunk + out.addAll(query(chunkArgs)) + } + return out + } + + private fun formatQty(n: Double): String { + val body = String.format(Locale.US, "%,.2f", kotlin.math.abs(n)) + return if (n < 0) "($body)" else body + } + + private fun toDouble(v: Any?): Double { + if (v == null) return 0.0 + if (v is Number) return v.toDouble() + return v.toString().replace(",", "").toDoubleOrNull() ?: 0.0 + } + + private fun toLong(v: Any?): Long { + if (v == null) return 0L + if (v is Number) return v.toLong() + return v.toString().toLongOrNull() ?: 0L + } + + private fun buildMultiValueLikeClause( + paramValue: String?, + columnName: String, + paramPrefix: String, + args: MutableMap, + ): String { + if (paramValue.isNullOrBlank()) return "" + val values = paramValue.split(",").map { it.trim() }.filter { it.isNotBlank() } + if (values.isEmpty()) return "" + val conditions = values.mapIndexed { index, value -> + val paramName = "${paramPrefix}_$index" + args[paramName] = "%$value%" + "$columnName LIKE :$paramName" + } + return "AND (${conditions.joinToString(" OR ")})" + } +} diff --git a/src/main/java/com/ffii/fpsms/modules/report/service/StockLotLedgerReportService.kt b/src/main/java/com/ffii/fpsms/modules/report/service/StockLotLedgerReportService.kt new file mode 100644 index 0000000..16547fc --- /dev/null +++ b/src/main/java/com/ffii/fpsms/modules/report/service/StockLotLedgerReportService.kt @@ -0,0 +1,492 @@ +package com.ffii.fpsms.modules.report.service + +import com.ffii.core.support.JdbcDao +import org.slf4j.LoggerFactory +import org.springframework.stereotype.Service +import java.time.LocalDate +import java.time.format.DateTimeFormatter + +/** + * 庫存流水帳:期初讀 as-of 當天 [stock_lot_day](2.6 carry),期間明細讀 stock_ledger。 + */ +@Service +open class StockLotLedgerReportService( + private val jdbcDao: JdbcDao, +) { + private val log = LoggerFactory.getLogger(StockLotLedgerReportService::class.java) + + data class LedgerPeriod( + val start: LocalDate, + val end: LocalDate, + ) { + val startStr: String get() = start.format(DateTimeFormatter.ofPattern("yyyy-MM-dd")) + val endStr: String get() = end.format(DateTimeFormatter.ofPattern("yyyy-MM-dd")) + val asOfStr: String get() = start.minusDays(1).format(DateTimeFormatter.ofPattern("yyyy-MM-dd")) + val endExclusiveStr: String get() = end.plusDays(1).format(DateTimeFormatter.ofPattern("yyyy-MM-dd")) + val isValid: Boolean get() = !end.isBefore(start) + } + + data class TimedResult( + val rows: List>, + val timings: List, + val period: LedgerPeriod, + ) + + fun resolvePeriod(startRaw: String?, endRaw: String?): LedgerPeriod { + val end = if (!endRaw.isNullOrBlank()) { + LocalDate.parse(endRaw.replace("/", "-")) + } else { + LocalDate.now() + } + val startParsed = if (!startRaw.isNullOrBlank()) { + LocalDate.parse(startRaw.replace("/", "-")) + } else { + end.withDayOfMonth(1) + } + return LedgerPeriod(start = startParsed.withDayOfMonth(1), end = end) + } + + fun searchStockLotLedgerReport( + itemCode: String?, + reportPeriodStart: String?, + reportPeriodEnd: String?, + ): List> = searchStockLotLedgerReportTimed( + itemCode, reportPeriodStart, reportPeriodEnd, + ).rows + + fun searchStockLotLedgerReportTimed( + itemCode: String?, + reportPeriodStart: String?, + reportPeriodEnd: String?, + ): TimedResult { + val period = resolvePeriod(reportPeriodStart, reportPeriodEnd) + if (!period.isValid) { + return TimedResult(emptyList(), listOf("invalid-period 0ms"), period) + } + + val timings = mutableListOf() + val tAll = System.nanoTime() + fun timed(name: String, extra: (T) -> String, block: () -> T): T { + val t0 = System.nanoTime() + return try { + val r = block() + val ms = (System.nanoTime() - t0) / 1_000_000 + val note = extra(r) + val line = "$name $note ${ms}ms" + timings.add(line) + log.info("stock-lot-ledger {}", line) + r + } catch (e: Exception) { + val ms = (System.nanoTime() - t0) / 1_000_000 + log.warn("stock-lot-ledger {} FAILED {}ms: {}", name, ms, e.message) + timings.add("$name FAILED ${ms}ms") + throw e + } + } + + val args = mutableMapOf( + "reportPeriodStart" to period.start.atStartOfDay(), + "reportPeriodEndExclusive" to period.end.plusDays(1).atStartOfDay(), + "asOfDate" to period.asOfStr, + ) + val itemCodeSqlSl = buildMultiValueLikeClause(itemCode, "sl.itemCode", "itemCodeSl", args) + val itemCodeSqlDay = buildMultiValueLikeClause(itemCode, "d.itemCode", "itemCodeDay", args) + + val forceLedgerIndex = if (itemCode.isNullOrBlank()) { + "FORCE INDEX (idx_sl_deleted_date)" + } else { + "" + } + + val dayOpen = timed("1.day_open", { rows: List> -> "rows=${rows.size}" }) { + jdbcDao.queryForList( + """ + SELECT d.inventoryLotLineId, d.closing AS openingQty + FROM stock_lot_day d + WHERE IFNULL(d.deleted, 0) = 0 + AND d.date = :asOfDate + AND d.closing <> 0 + $itemCodeSqlDay + """.trimIndent(), + args, + ) + } + val dayOpenByLot = HashMap(dayOpen.size * 2) + for (r in dayOpen) { + dayOpenByLot[toLong(r["inventoryLotLineId"])] = toDouble(r["openingQty"]) + } + + val ledgerRows = timed("2a.period_ledger", { rows: List> -> "rows=${rows.size}" }) { + jdbcDao.queryForList( + """ + SELECT + sl.inventoryLotLineId, + sl.id AS slId, + DATE_FORMAT(sl.date, '%Y-%m-%d') AS trnDate, + COALESCE(sl.inQty, 0) AS inQty, + COALESCE(sl.outQty, 0) AS outQty, + sl.lotQtyAfter AS lotQtyAfter, + sl.lotQtyBefore AS lotQtyBefore, + sl.stockInLineId, + sl.stockOutLineId + FROM stock_ledger sl $forceLedgerIndex + WHERE sl.deleted = 0 + AND sl.date >= :reportPeriodStart + AND sl.date < :reportPeriodEndExclusive + AND sl.inventoryLotLineId IS NOT NULL + $itemCodeSqlSl + """.trimIndent(), + args, + ) + } + + val refBySlId = timed("2b.ref_docs", { map: Map -> "refs=${map.size}" }) { + loadRefDocs(ledgerRows) + } + + val moves = timed("2c.attach_refs", { rows: List> -> "rows=${rows.size}" }) { + ledgerRows.map { r -> + val out = HashMap(r) + val slId = toLong(r["slId"]) + out["orderRefNo"] = refBySlId[slId] ?: "" + out + } + } + + val periodLotIds = LinkedHashSet() + val firstMoveByLot = HashMap() + val firstMoveKey = HashMap>() + for (r in moves) { + val lotId = toLong(r["inventoryLotLineId"]) + if (lotId <= 0) continue + periodLotIds.add(lotId) + if (lotId in dayOpenByLot) continue + val date = r["trnDate"]?.toString().orEmpty() + val slId = toLong(r["slId"]) + val prev = firstMoveKey[lotId] + if (prev == null || date < prev.first || (date == prev.first && slId < prev.second)) { + firstMoveKey[lotId] = date to slId + firstMoveByLot[lotId] = toDouble(r["lotQtyBefore"]) + } + } + timings.add("3.first_move derived lots=${firstMoveByLot.size} 0ms") + log.info("stock-lot-ledger 3.first_move derived lots={} 0ms", firstMoveByLot.size) + + val eligibleLotIds = LinkedHashSet(dayOpenByLot.size + periodLotIds.size) + eligibleLotIds.addAll(dayOpenByLot.keys) + eligibleLotIds.addAll(periodLotIds) + + val masterByLot = HashMap>(eligibleLotIds.size * 2) + timed("4.master", { rows: List> -> "rows=${rows.size} lots=${eligibleLotIds.size}" }) { + if (eligibleLotIds.isEmpty()) emptyList() + else queryLotIdChunks(eligibleLotIds.toList(), emptyMap()) { chunkArgs -> + jdbcDao.queryForList( + """ + SELECT + ill.id AS inventoryLotLineId, + COALESCE(it.code, '') AS itemNo, + COALESCE(it.name, '') AS itemName, + COALESCE(uc.udfudesc, uc.code, '') AS unitOfMeasure, + COALESCE(il.lotNo, '') AS lotNo, + COALESCE(DATE_FORMAT(il.expiryDate, '%Y-%m-%d'), '') AS expiryDate, + iu.uomId AS lotUomId + FROM inventory_lot_line ill + INNER JOIN inventory_lot il + ON il.id = ill.inventoryLotId AND il.deleted = 0 + INNER JOIN items it + ON it.id = il.itemId AND it.deleted = 0 + LEFT JOIN item_uom iu + ON iu.id = ill.stockItemUomId AND iu.deleted = 0 + LEFT JOIN uom_conversion uc + ON uc.id = iu.uomId + WHERE ill.deleted = 0 + AND ill.id IN (:lotIds) + AND it.code IS NOT NULL AND it.code <> '' + """.trimIndent(), + chunkArgs, + ) + } + }.forEach { r -> + masterByLot[toLong(r["inventoryLotLineId"])] = r + } + + val assembled = timed("5.assemble", { rows: List> -> "rows=${rows.size}" }) { + assembleRows( + asOfStr = period.asOfStr, + dayOpenByLot = dayOpenByLot, + firstMoveByLot = firstMoveByLot, + moves = moves, + masterByLot = masterByLot, + ) + } + + val rows = timed("6.totals", { list: List> -> "rows=${list.size}" }) { + attachItemUomTotals(assembled) + } + + val totalMs = (System.nanoTime() - tAll) / 1_000_000 + timings.add("search.total ${totalMs}ms") + log.info( + "stock-lot-ledger DONE period={}..{} asOf={} itemCode={} {}", + period.startStr, + period.endStr, + period.asOfStr, + itemCode ?: "", + timings.joinToString(" | "), + ) + return TimedResult(rows = rows, timings = timings, period = period) + } + + private fun assembleRows( + asOfStr: String, + dayOpenByLot: Map, + firstMoveByLot: Map, + moves: List>, + masterByLot: Map>, + ): List> { + val movesByLot = LinkedHashMap>>() + for (m in moves) { + val lotId = toLong(m["inventoryLotLineId"]) + if (lotId !in masterByLot) continue + movesByLot.getOrPut(lotId) { mutableListOf() }.add(m) + } + + val openingLots = LinkedHashSet() + openingLots.addAll(dayOpenByLot.keys) + openingLots.addAll(firstMoveByLot.keys) + openingLots.addAll(movesByLot.keys) + + val out = ArrayList>(openingLots.size + moves.size) + for (lotId in openingLots) { + val master = masterByLot[lotId] ?: continue + val opening = dayOpenByLot[lotId] ?: firstMoveByLot[lotId] ?: 0.0 + out.add(openingRow(lotId, asOfStr, opening, master)) + val lotMoves = (movesByLot[lotId] ?: continue).sortedWith( + compareBy( + { it["trnDate"]?.toString().orEmpty() }, + { toLong(it["slId"]) }, + ), + ) + var run = opening + for (m in lotMoves) { + val inQty = toDouble(m["inQty"]) + val outQty = toDouble(m["outQty"]) + val after = m["lotQtyAfter"] + run = if (after != null) toDouble(after) else run + inQty - outQty + out.add(moveRow(lotId, m, inQty, outQty, run, master)) + } + } + + out.sortWith( + compareBy> { it["itemNo"]?.toString().orEmpty() } + .thenBy { it["unitOfMeasure"]?.toString().orEmpty() } + .thenBy { it["lotNo"]?.toString().orEmpty() } + .thenBy { it["inventoryLotLineId"]?.toString().orEmpty() } + .thenByDescending { toInt(it["isOpening"]) } + .thenBy { it["trnDate"]?.toString().orEmpty() } + .thenBy { toLong(it["slId"]) }, + ) + return out + } + + private fun loadRefDocs(ledgerRows: List>): Map { + if (ledgerRows.isEmpty()) return emptyMap() + val slByIn = HashMap>() + val slByOut = HashMap>() + for (r in ledgerRows) { + val slId = toLong(r["slId"]) + if (slId <= 0) continue + val inId = toLong(r["stockInLineId"]) + val outId = toLong(r["stockOutLineId"]) + if (inId > 0) slByIn.getOrPut(inId) { mutableListOf() }.add(slId) + if (outId > 0) slByOut.getOrPut(outId) { mutableListOf() }.add(slId) + } + val refs = HashMap() + if (slByOut.isNotEmpty()) { + for (chunk in slByOut.keys.toList().chunked(800)) { + val rows = jdbcDao.queryForList( + """ + SELECT + sol.id AS lineId, + COALESCE( + NULLIF(TRIM(do.code), ''), + NULLIF(TRIM(po_out.code), ''), + NULLIF(TRIM(jo.code), ''), + '' + ) AS orderRefNo + FROM stock_out_line sol + LEFT JOIN pick_order_line pol + ON pol.id = sol.pickOrderLineId AND pol.deleted = 0 + LEFT JOIN pick_order po_out + ON po_out.id = pol.poId AND po_out.deleted = 0 + LEFT JOIN delivery_order do + ON do.id = po_out.doId AND do.deleted = 0 + LEFT JOIN job_order jo + ON jo.id = po_out.joId AND jo.deleted = 0 + WHERE sol.id IN (:ids) + """.trimIndent(), + mapOf("ids" to chunk), + ) + for (r in rows) { + val ref = r["orderRefNo"]?.toString()?.trim().orEmpty() + if (ref.isEmpty()) continue + slByOut[toLong(r["lineId"])]?.forEach { slId -> refs[slId] = ref } + } + } + } + if (slByIn.isNotEmpty()) { + for (chunk in slByIn.keys.toList().chunked(800)) { + val rows = jdbcDao.queryForList( + """ + SELECT + sil.id AS lineId, + COALESCE( + NULLIF(TRIM(jo.code), ''), + NULLIF(TRIM(po.code), ''), + NULLIF(TRIM(si.code), ''), + '' + ) AS orderRefNo + FROM stock_in_line sil + LEFT JOIN stock_in si + ON si.id = sil.stockInId AND si.deleted = 0 + LEFT JOIN job_order jo + ON jo.id = sil.jobOrderId AND jo.deleted = 0 + LEFT JOIN purchase_order po + ON po.id = sil.purchaseOrderId AND po.deleted = 0 + WHERE sil.id IN (:ids) + """.trimIndent(), + mapOf("ids" to chunk), + ) + for (r in rows) { + val ref = r["orderRefNo"]?.toString()?.trim().orEmpty() + if (ref.isEmpty()) continue + slByIn[toLong(r["lineId"])]?.forEach { slId -> + if (refs[slId].isNullOrEmpty()) refs[slId] = ref + } + } + } + } + return refs + } + + private fun openingRow( + lotId: Long, + asOfStr: String, + opening: Double, + master: Map, + ): Map { + val row = HashMap(16) + row["inventoryLotLineId"] = lotId.toString() + row["slId"] = 0L + row["itemNo"] = master["itemNo"] ?: "" + row["itemName"] = master["itemName"] ?: "" + row["unitOfMeasure"] = master["unitOfMeasure"] ?: "" + row["lotNo"] = master["lotNo"] ?: "" + row["expiryDate"] = master["expiryDate"] ?: "" + row["trnDate"] = asOfStr + row["orderRefNo"] = "As of $asOfStr" + row["isOpening"] = 1 + row["openingQtyRaw"] = opening + row["inQtyRaw"] = 0.0 + row["outQtyRaw"] = 0.0 + row["balQtyRaw"] = opening + return row + } + + private fun moveRow( + lotId: Long, + m: Map, + inQty: Double, + outQty: Double, + bal: Double, + master: Map, + ): Map { + val row = HashMap(16) + row["inventoryLotLineId"] = lotId.toString() + row["slId"] = toLong(m["slId"]) + row["itemNo"] = master["itemNo"] ?: "" + row["itemName"] = master["itemName"] ?: "" + row["unitOfMeasure"] = master["unitOfMeasure"] ?: "" + row["lotNo"] = master["lotNo"] ?: "" + row["expiryDate"] = master["expiryDate"] ?: "" + row["trnDate"] = m["trnDate"] ?: "" + row["orderRefNo"] = m["orderRefNo"] ?: "" + row["isOpening"] = 0 + row["openingQtyRaw"] = 0.0 + row["inQtyRaw"] = inQty + row["outQtyRaw"] = outQty + row["balQtyRaw"] = bal + return row + } + + private fun queryLotIdChunks( + lotIds: Collection, + baseArgs: Map, + query: (Map) -> List>, + ): List> { + val ids = lotIds.filter { it > 0 }.distinct() + if (ids.isEmpty()) return emptyList() + val out = ArrayList>() + for (chunk in ids.chunked(800)) { + val chunkArgs = HashMap(baseArgs) + chunkArgs["lotIds"] = chunk + out.addAll(query(chunkArgs)) + } + return out + } + + private fun attachItemUomTotals(rows: List>): List> { + if (rows.isEmpty()) return rows + val lotEnd = LinkedHashMap>() + for (r in rows) { + val lotId = r["inventoryLotLineId"]?.toString().orEmpty() + val key = "${r["itemNo"]}|${r["unitOfMeasure"]}" + lotEnd[lotId] = key to toDouble(r["balQtyRaw"]) + } + val tot = HashMap() + for ((_, v) in lotEnd) { + tot[v.first] = (tot[v.first] ?: 0.0) + v.second + } + return rows.map { r -> + val key = "${r["itemNo"]}|${r["unitOfMeasure"]}" + val out = HashMap(r) + out["totalCumBalance"] = tot[key] ?: 0.0 + out + } + } + + private fun toDouble(v: Any?): Double { + if (v == null) return 0.0 + if (v is Number) return v.toDouble() + return v.toString().replace(",", "").toDoubleOrNull() ?: 0.0 + } + + private fun toLong(v: Any?): Long { + if (v == null) return 0L + if (v is Number) return v.toLong() + return v.toString().toLongOrNull() ?: 0L + } + + private fun toInt(v: Any?): Int { + if (v == null) return 0 + if (v is Number) return v.toInt() + return v.toString().toIntOrNull() ?: 0 + } + + private fun buildMultiValueLikeClause( + paramValue: String?, + columnName: String, + paramPrefix: String, + args: MutableMap, + ): String { + if (paramValue.isNullOrBlank()) return "" + val values = paramValue.split(",").map { it.trim() }.filter { it.isNotBlank() } + if (values.isEmpty()) return "" + val conditions = values.mapIndexed { index, value -> + val paramName = "${paramPrefix}_$index" + args[paramName] = "%$value%" + "$columnName LIKE :$paramName" + } + return "AND (${conditions.joinToString(" OR ")})" + } +} diff --git a/src/main/java/com/ffii/fpsms/modules/report/web/ReportController.kt b/src/main/java/com/ffii/fpsms/modules/report/web/ReportController.kt index fc24362..b1cf56b 100644 --- a/src/main/java/com/ffii/fpsms/modules/report/web/ReportController.kt +++ b/src/main/java/com/ffii/fpsms/modules/report/web/ReportController.kt @@ -121,7 +121,13 @@ class ReportController( @RequestParam(required = false) stockCategory: String?, @RequestParam(required = false) itemCode: String?, @RequestParam(required = false) lastInDateStart: String?, - @RequestParam(required = false) lastInDateEnd: String? + @RequestParam(required = false) lastInDateEnd: String?, + @RequestParam(required = false) storeId: String?, + @RequestParam(required = false) warehouse: String?, + @RequestParam(required = false) area: String?, + @RequestParam(required = false) slot: String?, + @RequestParam(required = false) lotNo: String?, + @RequestParam(required = false) poPrefix: String?, ): ResponseEntity { val parameters = mutableMapOf() @@ -137,7 +143,13 @@ class ReportController( stockCategory, itemCode, lastInDateStart, - lastInDateEnd + lastInDateEnd, + storeId, + warehouse, + area, + slot, + lotNo, + poPrefix, ) val pdfBytes = reportService.createPdfResponse( @@ -161,18 +173,37 @@ class ReportController( @RequestParam(required = false) itemCode: String?, @RequestParam(required = false) lastInDateStart: String?, @RequestParam(required = false) lastInDateEnd: String?, + @RequestParam(required = false) storeId: String?, + @RequestParam(required = false) warehouse: String?, + @RequestParam(required = false) area: String?, + @RequestParam(required = false) slot: String?, + @RequestParam(required = false) lotNo: String?, + @RequestParam(required = false) poPrefix: String?, ): ResponseEntity { val dbData = reportService.searchStockInTraceabilityReport( stockCategory, itemCode, lastInDateStart, lastInDateEnd, + storeId, + warehouse, + area, + slot, + lotNo, + poPrefix, ) val excelBytes = createStockInTraceabilityExcel( dbData = dbData, lastInDateStart = lastInDateStart ?: "", lastInDateEnd = lastInDateEnd ?: "", + itemCode = itemCode, + storeId = storeId, + warehouse = warehouse, + area = area, + slot = slot, + lotNo = lotNo, + poPrefix = poPrefix, ) val headers = HttpHeaders().apply { @@ -563,6 +594,13 @@ class ReportController( dbData: List>, lastInDateStart: String, lastInDateEnd: String, + itemCode: String? = null, + storeId: String? = null, + warehouse: String? = null, + area: String? = null, + slot: String? = null, + lotNo: String? = null, + poPrefix: String? = null, ): ByteArray { val workbook = XSSFWorkbook() val styles = createCommonWorkbookStyles(workbook) @@ -598,8 +636,18 @@ class ReportController( } sheet.addMergedRegion(CellRangeAddress(1, 1, 0, 5)) subtitleRow.createCell(6).apply { - val cap = (lastInDateStart.trim().ifBlank { "" }) + " 至 " + (lastInDateEnd.trim().ifBlank { "" }) - setCellValue("最後入倉日期:${cap.trim()}") + val dateCap = (lastInDateStart.trim().ifBlank { "" }) + " 至 " + (lastInDateEnd.trim().ifBlank { "" }) + val filterBits = listOfNotNull( + itemCode?.takeIf { it.isNotBlank() }?.let { "貨品:$it" }, + storeId?.takeIf { it.isNotBlank() && it != "All" }?.let { "樓層:$it" }, + warehouse?.takeIf { it.isNotBlank() && it != "All" }?.let { "倉庫:$it" }, + area?.takeIf { it.isNotBlank() && it != "All" }?.let { "區域:$it" }, + slot?.takeIf { it.isNotBlank() && it != "All" }?.let { "儲位:$it" }, + lotNo?.takeIf { it.isNotBlank() }?.let { "批號:$it" }, + poPrefix?.takeIf { it.isNotBlank() && it != "All" }?.let { "PP/PF:$it" }, + ).joinToString(" ") + val extra = if (filterBits.isNotBlank()) " $filterBits" else "" + setCellValue("最後入倉日期:${dateCap.trim()}$extra") cellStyle = styles.subtitle } sheet.addMergedRegion(CellRangeAddress(1, 1, 6, totalColumns - 1)) diff --git a/src/main/java/com/ffii/fpsms/modules/report/web/StockLotBalanceReportController.kt b/src/main/java/com/ffii/fpsms/modules/report/web/StockLotBalanceReportController.kt new file mode 100644 index 0000000..818647d --- /dev/null +++ b/src/main/java/com/ffii/fpsms/modules/report/web/StockLotBalanceReportController.kt @@ -0,0 +1,330 @@ +package com.ffii.fpsms.modules.report.web + +import com.ffii.fpsms.modules.report.service.StockLotBalanceReportService +import org.slf4j.LoggerFactory +import org.apache.poi.ss.usermodel.BorderStyle +import org.apache.poi.ss.usermodel.CellStyle +import org.apache.poi.ss.usermodel.DataFormat +import org.apache.poi.ss.usermodel.FillPatternType +import org.apache.poi.ss.usermodel.HorizontalAlignment +import org.apache.poi.ss.usermodel.IndexedColors +import org.apache.poi.ss.usermodel.Row +import org.apache.poi.ss.usermodel.VerticalAlignment +import org.apache.poi.ss.usermodel.Workbook +import org.apache.poi.ss.util.CellRangeAddress +import org.apache.poi.ss.util.WorkbookUtil +import org.apache.poi.xssf.streaming.SXSSFWorkbook +import org.springframework.http.HttpHeaders +import org.springframework.http.HttpStatus +import org.springframework.http.MediaType +import org.springframework.http.ResponseEntity +import org.springframework.web.bind.annotation.GetMapping +import org.springframework.web.bind.annotation.RequestMapping +import org.springframework.web.bind.annotation.RequestParam +import org.springframework.web.bind.annotation.RestController +import java.io.ByteArrayOutputStream +import java.time.LocalTime +import java.time.format.DateTimeFormatter + +/** + * FP-MTMS | 庫存批次結餘報告 (Excel only, always today) + * Excel: /report/print-stock-lot-balance-excel + */ +@RestController +@RequestMapping("/report") +class StockLotBalanceReportController( + private val stockLotBalanceReportService: StockLotBalanceReportService, +) { + private val log = LoggerFactory.getLogger(StockLotBalanceReportController::class.java) + private data class ExcelStyles( + val title: CellStyle, + val subtitle: CellStyle, + val header: CellStyle, + val text: CellStyle, + val center: CellStyle, + val number: CellStyle, + val dash: CellStyle, + ) + + @GetMapping("/print-stock-lot-balance-excel") + fun exportStockLotBalanceReportExcel( + @RequestParam(required = false) stockDate: String?, + @RequestParam(required = false) itemCode: String?, + @RequestParam(required = false) warehouseCode: String?, + @RequestParam(required = false) storeId: String?, + @RequestParam(required = false) lotNo: String?, + ): ResponseEntity { + val tAll = System.nanoTime() + val result = stockLotBalanceReportService.searchStockLotBalanceReportTimed( + stockDate = stockDate, + itemCode = itemCode, + warehouseCode = warehouseCode, + storeId = storeId, + lotNo = lotNo, + ) + val reportDate = result.stockDate + val tExcel = System.nanoTime() + val excelBytes = createStockLotBalanceExcel( + dbData = result.rows, + reportDate = reportDate, + itemCode = itemCode, + warehouseCode = warehouseCode, + storeId = storeId, + lotNo = lotNo, + timings = result.timings, + ) + val excelMs = (System.nanoTime() - tExcel) / 1_000_000 + val totalMs = (System.nanoTime() - tAll) / 1_000_000 + val excelLine = "6.excel rows=${result.rows.size} ${excelMs}ms" + val requestLine = "request.total ${totalMs}ms" + log.info("stock-lot-balance {} | {}", excelLine, requestLine) + log.info( + "stock-lot-balance REQUEST {} | {} | {}", + requestLine, + result.timings.joinToString(" | "), + excelLine, + ) + + val headers = HttpHeaders().apply { + contentType = MediaType.parseMediaType( + "application/vnd.openxmlformats-officedocument.spreadsheetml.sheet", + ) + setContentDispositionFormData("attachment", "StockLotBalanceReport.xlsx") + set("filename", "StockLotBalanceReport.xlsx") + set("X-Report-Timings", (result.timings + excelLine + requestLine).joinToString(" | ")) + } + return ResponseEntity(excelBytes, headers, HttpStatus.OK) + } + + private fun createStyles(workbook: Workbook): ExcelStyles { + val titleStyle = workbook.createCellStyle().apply { + alignment = HorizontalAlignment.CENTER + verticalAlignment = VerticalAlignment.CENTER + val font = workbook.createFont().apply { + bold = true + fontHeightInPoints = 14 + } + setFont(font) + } + val subtitleStyle = workbook.createCellStyle().apply { + alignment = HorizontalAlignment.LEFT + verticalAlignment = VerticalAlignment.CENTER + val font = workbook.createFont().apply { + bold = true + fontHeightInPoints = 11 + } + setFont(font) + } + val headerStyle = workbook.createCellStyle().apply { + alignment = HorizontalAlignment.CENTER + verticalAlignment = VerticalAlignment.CENTER + fillForegroundColor = IndexedColors.GREY_25_PERCENT.index + fillPattern = FillPatternType.SOLID_FOREGROUND + borderTop = BorderStyle.THIN + borderBottom = BorderStyle.THIN + borderLeft = BorderStyle.THIN + borderRight = BorderStyle.THIN + val font = workbook.createFont().apply { bold = true } + setFont(font) + } + val textStyle = workbook.createCellStyle().apply { + alignment = HorizontalAlignment.LEFT + verticalAlignment = VerticalAlignment.CENTER + borderTop = BorderStyle.THIN + borderBottom = BorderStyle.THIN + borderLeft = BorderStyle.THIN + borderRight = BorderStyle.THIN + } + val centerStyle = workbook.createCellStyle().apply { + alignment = HorizontalAlignment.CENTER + verticalAlignment = VerticalAlignment.CENTER + borderTop = BorderStyle.THIN + borderBottom = BorderStyle.THIN + borderLeft = BorderStyle.THIN + borderRight = BorderStyle.THIN + } + val numberStyle = workbook.createCellStyle().apply { + alignment = HorizontalAlignment.RIGHT + verticalAlignment = VerticalAlignment.CENTER + borderTop = BorderStyle.THIN + borderBottom = BorderStyle.THIN + borderLeft = BorderStyle.THIN + borderRight = BorderStyle.THIN + val df: DataFormat = workbook.createDataFormat() + dataFormat = df.getFormat("#,##0.##") + } + val dashStyle = workbook.createCellStyle().apply { + alignment = HorizontalAlignment.RIGHT + verticalAlignment = VerticalAlignment.CENTER + borderTop = BorderStyle.THIN + borderBottom = BorderStyle.THIN + borderLeft = BorderStyle.THIN + borderRight = BorderStyle.THIN + } + return ExcelStyles( + title = titleStyle, + subtitle = subtitleStyle, + header = headerStyle, + text = textStyle, + center = centerStyle, + number = numberStyle, + dash = dashStyle, + ) + } + + private fun setTextCell(row: Row, col: Int, value: Any?, style: CellStyle) { + row.createCell(col).apply { + setCellValue(value?.toString() ?: "") + cellStyle = style + } + } + + private fun parseSignedNumber(value: Any?): Double? { + val raw = value?.toString()?.trim().orEmpty() + if (raw.isBlank() || raw == "-" || raw.equals("null", ignoreCase = true)) return null + val negative = raw.startsWith("(") && raw.endsWith(")") + val cleaned = raw.removePrefix("(").removeSuffix(")").replace(",", "").trim() + val n = cleaned.toDoubleOrNull() ?: return null + return if (negative) -n else n + } + + private fun setNumberCellFromFormatted( + row: Row, + col: Int, + value: Any?, + numberStyle: CellStyle, + dashStyle: CellStyle, + ) { + val cell = row.createCell(col) + val parsed = parseSignedNumber(value) + if (parsed == null) { + cell.setCellValue("") + cell.cellStyle = dashStyle + return + } + if (parsed < 0) { + cell.setCellValue("(${"%,.2f".format(kotlin.math.abs(parsed))})") + cell.cellStyle = dashStyle + } else { + cell.setCellValue(parsed) + cell.cellStyle = numberStyle + } + } + + private fun createStockLotBalanceExcel( + dbData: List>, + reportDate: String, + itemCode: String?, + warehouseCode: String?, + storeId: String?, + lotNo: String?, + timings: List, + ): ByteArray { + val workbook = SXSSFWorkbook(100) + workbook.setCompressTempFiles(true) + try { + val styles = createStyles(workbook) + val reportTitle = "庫存批次結餘報告" + val sheet = workbook.createSheet(WorkbookUtil.createSafeSheetName(reportTitle)) + + val headers = listOf( + "貨品編號", "貨品名稱", "單位", + "批號", "到期日", "倉位", "樓層", + "期初存量", "纍計存入量", "纍計存出量", + "錯誤輸入或遺失", "盤盈虧", "不良品棄置", "過期棄置", + "現存存貨", "單位均價", "庫存總價值", + ) + val totalColumns = headers.size + var rowIndex = 0 + + val titleRow = sheet.createRow(rowIndex++) + titleRow.createCell(0).apply { + setCellValue(reportTitle) + cellStyle = styles.title + } + sheet.addMergedRegion(CellRangeAddress(0, 0, 0, totalColumns - 1)) + + val reportDateTime = + reportDate + + "(" + + LocalTime.now().format(DateTimeFormatter.ofPattern("HH:mm:ss")) + + ")" + val filterBits = listOfNotNull( + itemCode?.takeIf { it.isNotBlank() }?.let { "貨品:$it" }, + warehouseCode?.takeIf { it.isNotBlank() && it != "All" }?.let { "倉位:$it" }, + storeId?.takeIf { it.isNotBlank() && it != "All" }?.let { "樓層:$it" }, + lotNo?.takeIf { it.isNotBlank() }?.let { "批號:$it" }, + ).joinToString(" ") + val subtitleRow = sheet.createRow(rowIndex++) + subtitleRow.createCell(0).apply { + setCellValue( + "報告日期(現況):$reportDateTime" + + if (filterBits.isNotBlank()) " $filterBits" else "", + ) + cellStyle = styles.subtitle + } + sheet.addMergedRegion(CellRangeAddress(1, 1, 0, totalColumns - 1)) + if (timings.isNotEmpty()) { + val timingRow = sheet.createRow(rowIndex++) + timingRow.createCell(0).apply { + setCellValue("耗時:${timings.joinToString(" | ")}") + cellStyle = styles.subtitle + } + sheet.addMergedRegion(CellRangeAddress(2, 2, 0, totalColumns - 1)) + } else { + sheet.createRow(rowIndex++) + } + + val headerRowIndex = rowIndex + val headerRow = sheet.createRow(rowIndex++) + headers.forEachIndexed { i, h -> + headerRow.createCell(i).apply { + setCellValue(h) + cellStyle = styles.header + } + } + + if (dbData.isEmpty()) { + val r = sheet.createRow(rowIndex++) + for (c in 0 until totalColumns) { + r.createCell(c).apply { setCellValue("-"); cellStyle = styles.text } + } + } else { + dbData.forEach { m -> + val r = sheet.createRow(rowIndex++) + setTextCell(r, 0, m["itemNo"], styles.text) + setTextCell(r, 1, m["itemName"], styles.text) + setTextCell(r, 2, m["unitOfMeasure"], styles.center) + setTextCell(r, 3, m["lotNo"], styles.text) + setTextCell(r, 4, m["expiryDate"], styles.center) + setTextCell(r, 5, m["warehouseCode"], styles.text) + setTextCell(r, 6, m["storeId"], styles.center) + setNumberCellFromFormatted(r, 7, m["totalOpeningBalance"], styles.number, styles.dash) + setNumberCellFromFormatted(r, 8, m["totalCumStockIn"], styles.number, styles.dash) + setNumberCellFromFormatted(r, 9, m["totalCumStockOut"], styles.number, styles.dash) + setNumberCellFromFormatted(r, 10, m["totalMisInputAndLost"], styles.number, styles.dash) + setNumberCellFromFormatted(r, 11, m["totalVariance"], styles.number, styles.dash) + setNumberCellFromFormatted(r, 12, m["totalDefectiveGoods"], styles.number, styles.dash) + setNumberCellFromFormatted(r, 13, m["totalExpiredDisposed"], styles.number, styles.dash) + setNumberCellFromFormatted(r, 14, m["totalCurrentBalance"], styles.number, styles.dash) + setNumberCellFromFormatted(r, 15, m["avgUnitPrice"], styles.number, styles.dash) + setNumberCellFromFormatted(r, 16, m["totalStockBalance"], styles.number, styles.dash) + } + } + + val lastRowIndex = rowIndex - 1 + if (lastRowIndex >= headerRowIndex) { + sheet.setAutoFilter(CellRangeAddress(headerRowIndex, lastRowIndex, 0, 0)) + } + val widths = intArrayOf(14, 22, 12, 18, 12, 18, 8, 12, 12, 12, 14, 12, 12, 12, 12, 12, 14) + widths.forEachIndexed { i, w -> sheet.setColumnWidth(i, w * 256) } + + val out = ByteArrayOutputStream() + workbook.write(out) + return out.toByteArray() + } finally { + workbook.dispose() + workbook.close() + } + } +} diff --git a/src/main/java/com/ffii/fpsms/modules/report/web/StockLotLedgerReportController.kt b/src/main/java/com/ffii/fpsms/modules/report/web/StockLotLedgerReportController.kt new file mode 100644 index 0000000..083d558 --- /dev/null +++ b/src/main/java/com/ffii/fpsms/modules/report/web/StockLotLedgerReportController.kt @@ -0,0 +1,326 @@ +package com.ffii.fpsms.modules.report.web + +import com.ffii.fpsms.modules.report.service.StockLotLedgerReportService +import org.slf4j.LoggerFactory +import org.apache.poi.ss.usermodel.BorderStyle +import org.apache.poi.ss.usermodel.CellStyle +import org.apache.poi.ss.usermodel.DataFormat +import org.apache.poi.ss.usermodel.FillPatternType +import org.apache.poi.ss.usermodel.HorizontalAlignment +import org.apache.poi.ss.usermodel.IndexedColors +import org.apache.poi.ss.usermodel.Row +import org.apache.poi.ss.usermodel.VerticalAlignment +import org.apache.poi.ss.usermodel.Workbook +import org.apache.poi.ss.util.CellRangeAddress +import org.apache.poi.ss.util.WorkbookUtil +import org.apache.poi.xssf.streaming.SXSSFWorkbook +import org.springframework.http.HttpHeaders +import org.springframework.http.HttpStatus +import org.springframework.http.MediaType +import org.springframework.http.ResponseEntity +import org.springframework.web.bind.annotation.GetMapping +import org.springframework.web.bind.annotation.RequestMapping +import org.springframework.web.bind.annotation.RequestParam +import org.springframework.web.bind.annotation.RestController +import java.io.ByteArrayOutputStream +import java.time.LocalDate +import java.time.LocalTime +import java.time.format.DateTimeFormatter + +/** + * 庫存流水帳報告 (Excel) + * Excel: /report/print-stock-lot-ledger-excel + */ +@RestController +@RequestMapping("/report") +class StockLotLedgerReportController( + private val stockLotLedgerReportService: StockLotLedgerReportService, +) { + private val log = LoggerFactory.getLogger(StockLotLedgerReportController::class.java) + private data class ExcelStyles( + val title: CellStyle, + val subtitle: CellStyle, + val header: CellStyle, + val text: CellStyle, + val center: CellStyle, + val number: CellStyle, + val dash: CellStyle, + ) + + @GetMapping("/print-stock-lot-ledger-excel") + fun exportStockLotLedgerReportExcel( + @RequestParam(required = false) itemCode: String?, + @RequestParam(name = "lastInDateStart", required = false) reportPeriodStart: String?, + @RequestParam(name = "lastInDateEnd", required = false) reportPeriodEnd: String?, + ): ResponseEntity { + val tAll = System.nanoTime() + val result = stockLotLedgerReportService.searchStockLotLedgerReportTimed( + itemCode = itemCode, + reportPeriodStart = reportPeriodStart, + reportPeriodEnd = reportPeriodEnd, + ) + val period = result.period + val tExcel = System.nanoTime() + val excelBytes = createExcel( + dbData = result.rows, + reportPeriodStart = period.startStr, + reportPeriodEnd = period.endStr, + timings = result.timings, + ) + val excelMs = (System.nanoTime() - tExcel) / 1_000_000 + val totalMs = (System.nanoTime() - tAll) / 1_000_000 + val excelLine = "7.excel rows=${result.rows.size} ${excelMs}ms" + val requestLine = "request.total ${totalMs}ms" + log.info("stock-lot-ledger {} | {}", excelLine, requestLine) + log.info( + "stock-lot-ledger REQUEST {} | {} | {}", + requestLine, + result.timings.joinToString(" | "), + excelLine, + ) + + val headers = HttpHeaders().apply { + contentType = MediaType.parseMediaType( + "application/vnd.openxmlformats-officedocument.spreadsheetml.sheet", + ) + setContentDispositionFormData("attachment", "StockLotLedgerReport.xlsx") + set("filename", "StockLotLedgerReport.xlsx") + set("X-Report-Timings", (result.timings + excelLine + requestLine).joinToString(" | ")) + } + return ResponseEntity(excelBytes, headers, HttpStatus.OK) + } + + private fun createStyles(workbook: Workbook): ExcelStyles { + val titleStyle = workbook.createCellStyle().apply { + alignment = HorizontalAlignment.CENTER + verticalAlignment = VerticalAlignment.CENTER + val font = workbook.createFont().apply { + bold = true + fontHeightInPoints = 14 + } + setFont(font) + } + val subtitleStyle = workbook.createCellStyle().apply { + alignment = HorizontalAlignment.LEFT + verticalAlignment = VerticalAlignment.CENTER + val font = workbook.createFont().apply { + bold = true + fontHeightInPoints = 11 + } + setFont(font) + } + val headerStyle = workbook.createCellStyle().apply { + alignment = HorizontalAlignment.CENTER + verticalAlignment = VerticalAlignment.CENTER + fillForegroundColor = IndexedColors.GREY_25_PERCENT.index + fillPattern = FillPatternType.SOLID_FOREGROUND + borderTop = BorderStyle.THIN + borderBottom = BorderStyle.THIN + borderLeft = BorderStyle.THIN + borderRight = BorderStyle.THIN + val font = workbook.createFont().apply { bold = true } + setFont(font) + } + val textStyle = workbook.createCellStyle().apply { + alignment = HorizontalAlignment.LEFT + verticalAlignment = VerticalAlignment.CENTER + borderTop = BorderStyle.THIN + borderBottom = BorderStyle.THIN + borderLeft = BorderStyle.THIN + borderRight = BorderStyle.THIN + } + val centerStyle = workbook.createCellStyle().apply { + alignment = HorizontalAlignment.CENTER + verticalAlignment = VerticalAlignment.CENTER + borderTop = BorderStyle.THIN + borderBottom = BorderStyle.THIN + borderLeft = BorderStyle.THIN + borderRight = BorderStyle.THIN + } + val numberStyle = workbook.createCellStyle().apply { + alignment = HorizontalAlignment.RIGHT + verticalAlignment = VerticalAlignment.CENTER + borderTop = BorderStyle.THIN + borderBottom = BorderStyle.THIN + borderLeft = BorderStyle.THIN + borderRight = BorderStyle.THIN + val df: DataFormat = workbook.createDataFormat() + dataFormat = df.getFormat("#,##0.##") + } + val dashStyle = workbook.createCellStyle().apply { + alignment = HorizontalAlignment.RIGHT + verticalAlignment = VerticalAlignment.CENTER + borderTop = BorderStyle.THIN + borderBottom = BorderStyle.THIN + borderLeft = BorderStyle.THIN + borderRight = BorderStyle.THIN + } + return ExcelStyles( + title = titleStyle, + subtitle = subtitleStyle, + header = headerStyle, + text = textStyle, + center = centerStyle, + number = numberStyle, + dash = dashStyle, + ) + } + + private fun setTextCell(row: Row, col: Int, value: Any?, style: CellStyle) { + row.createCell(col).apply { + setCellValue(value?.toString() ?: "") + cellStyle = style + } + } + + private fun setNumberCell(row: Row, col: Int, value: Any?, numberStyle: CellStyle, dashStyle: CellStyle) { + val cell = row.createCell(col) + val n = when (value) { + null -> null + is Number -> value.toDouble() + else -> { + val raw = value.toString().trim() + if (raw.isEmpty() || raw == "-") null else raw.replace(",", "").toDoubleOrNull() + } + } + if (n == null) { + cell.setCellValue("") + cell.cellStyle = dashStyle + return + } + if (n < 0) { + cell.setCellValue("(${"%,.2f".format(kotlin.math.abs(n))})") + cell.cellStyle = dashStyle + } else { + cell.setCellValue(n) + cell.cellStyle = numberStyle + } + } + + private fun isOpeningRow(m: Map): Boolean { + val v = m["isOpening"] + if (v is Number) return v.toInt() != 0 + return v?.toString() == "1" + } + + private fun createExcel( + dbData: List>, + reportPeriodStart: String, + reportPeriodEnd: String, + timings: List, + ): ByteArray { + val workbook = SXSSFWorkbook(100) + workbook.setCompressTempFiles(true) + try { + val styles = createStyles(workbook) + val reportTitle = "庫存流水帳報告" + val sheet = workbook.createSheet(WorkbookUtil.createSafeSheetName(reportTitle)) + val headers = listOf( + "貨品編號", "貨品名稱", "單位", "貨品單位結餘", + "批號", "到期日", "參考日期", "參考單據", + "批號期初", "入庫", "出庫", "結餘", + ) + val totalColumns = headers.size + var rowIndex = 0 + + val titleRow = sheet.createRow(rowIndex++) + titleRow.createCell(0).apply { + setCellValue(reportTitle) + cellStyle = styles.title + } + sheet.addMergedRegion(CellRangeAddress(0, 0, 0, totalColumns - 1)) + + val reportDateTime = + LocalDate.now().format(DateTimeFormatter.ofPattern("yyyy-MM-dd")) + + "(" + + LocalTime.now().format(DateTimeFormatter.ofPattern("HH:mm:ss")) + + ")" + val subtitleRow = sheet.createRow(rowIndex++) + subtitleRow.createCell(0).apply { + setCellValue("報告日期:$reportDateTime") + cellStyle = styles.subtitle + } + sheet.addMergedRegion(CellRangeAddress(1, 1, 0, 4)) + subtitleRow.createCell(5).apply { + setCellValue("報告期間:${reportPeriodStart.trim()} 至 ${reportPeriodEnd.trim()}") + cellStyle = styles.subtitle + } + sheet.addMergedRegion(CellRangeAddress(1, 1, 5, totalColumns - 1)) + if (timings.isNotEmpty()) { + val timingRow = sheet.createRow(rowIndex++) + timingRow.createCell(0).apply { + setCellValue("耗時:${timings.joinToString(" | ")}") + cellStyle = styles.subtitle + } + sheet.addMergedRegion(CellRangeAddress(2, 2, 0, totalColumns - 1)) + } else { + sheet.createRow(rowIndex++) + } + + val headerRowIndex = rowIndex + val headerRow = sheet.createRow(rowIndex++) + headers.forEachIndexed { i, h -> + headerRow.createCell(i).apply { + setCellValue(h) + cellStyle = styles.header + } + } + + if (dbData.isEmpty()) { + val r = sheet.createRow(rowIndex++) + for (c in 0 until totalColumns) { + r.createCell(c).apply { setCellValue("-"); cellStyle = styles.text } + } + } else { + var prevItemUom = "" + var prevLot = "" + dbData.forEach { m -> + val itemNo = m["itemNo"]?.toString().orEmpty() + val uom = m["unitOfMeasure"]?.toString().orEmpty() + val lotId = m["inventoryLotLineId"]?.toString().orEmpty() + val itemUomKey = "$itemNo|$uom" + val lotKey = "$itemUomKey|$lotId" + val showItem = itemUomKey != prevItemUom + val showLot = lotKey != prevLot + val r = sheet.createRow(rowIndex++) + setTextCell(r, 0, if (showItem) itemNo else "", styles.text) + setTextCell(r, 1, if (showItem) m["itemName"] else "", styles.text) + setTextCell(r, 2, if (showItem) uom else "", styles.center) + if (showItem) { + setNumberCell(r, 3, m["totalCumBalance"], styles.number, styles.dash) + } else { + setTextCell(r, 3, "", styles.dash) + } + setTextCell(r, 4, if (showLot) m["lotNo"] else "", styles.text) + setTextCell(r, 5, if (showLot) m["expiryDate"] else "", styles.center) + setTextCell(r, 6, m["trnDate"], styles.center) + setTextCell(r, 7, m["orderRefNo"], styles.text) + if (isOpeningRow(m)) { + setNumberCell(r, 8, m["openingQtyRaw"], styles.number, styles.dash) + } else { + setTextCell(r, 8, "", styles.dash) + } + setNumberCell(r, 9, m["inQtyRaw"], styles.number, styles.dash) + setNumberCell(r, 10, m["outQtyRaw"], styles.number, styles.dash) + setNumberCell(r, 11, m["balQtyRaw"], styles.number, styles.dash) + prevItemUom = itemUomKey + prevLot = lotKey + } + } + + val lastRowIndex = rowIndex - 1 + if (lastRowIndex >= headerRowIndex) { + sheet.setAutoFilter(CellRangeAddress(headerRowIndex, lastRowIndex, 0, 0)) + } + intArrayOf(14, 26, 10, 14, 16, 12, 12, 22, 12, 10, 10, 12) + .forEachIndexed { i, w -> sheet.setColumnWidth(i, w * 256) } + + val out = ByteArrayOutputStream() + workbook.write(out) + return out.toByteArray() + } finally { + workbook.dispose() + workbook.close() + } + } +} diff --git a/src/main/java/com/ffii/fpsms/modules/stock/service/StockLotDayCloseService.kt b/src/main/java/com/ffii/fpsms/modules/stock/service/StockLotDayCloseService.kt new file mode 100644 index 0000000..d2031af --- /dev/null +++ b/src/main/java/com/ffii/fpsms/modules/stock/service/StockLotDayCloseService.kt @@ -0,0 +1,260 @@ +package com.ffii.fpsms.modules.stock.service + +import com.ffii.core.support.JdbcDao +import org.slf4j.LoggerFactory +import org.springframework.stereotype.Service +import java.time.LocalDate +import java.util.concurrent.ConcurrentHashMap + +/** + * Daily close for [stock_lot_day] (fix step 2.6): + * - moves: aggregate day's ledger by lot (opening / in / out / closing) + * - carry: previous closing > 0 with no ledger today → opening = closing = prev.closing + * + * Never closes today or future dates. Idempotent via ON DUPLICATE KEY UPDATE. + */ +@Service +open class StockLotDayCloseService( + private val jdbcDao: JdbcDao, +) { + private val log = LoggerFactory.getLogger(StockLotDayCloseService::class.java) + private val inFlight = ConcurrentHashMap.newKeySet() + + data class CloseDayResult( + val date: LocalDate, + val moveRows: Int, + val carryRows: Int, + ) { + val rowsWritten: Int get() = moveRows + carryRows + } + + data class CloseRunResult( + val days: List, + val skipped: Boolean = false, + val skipReason: String? = null, + ) { + val totalRows: Int get() = days.sumOf { it.rowsWritten } + val dateFrom: LocalDate? get() = days.minOfOrNull { it.date } + val dateTo: LocalDate? get() = days.maxOfOrNull { it.date } + } + + /** + * Close [day] only. [actor] is written to createdBy/modifiedBy. + */ + open fun closeDay(day: LocalDate, actor: String = ACTOR_SCHEDULER): CloseDayResult { + require(day.isBefore(LocalDate.now())) { + "only dates before today can be closed (got $day)" + } + if (!inFlight.add(LOCK)) { + throw IllegalStateException("stock_lot_day close already running") + } + try { + return doCloseDay(day, actor) + } finally { + inFlight.remove(LOCK) + } + } + + /** + * Same as [doCloseDay] without the scheduler lock — for [StockLedgerFixService] which already + * holds its own run lock around day steps 2.1–2.6. + * Fix UI may close **today** on a freeze-night dump (e.g. 31/8 dump → fix 0831 same evening). + * Scheduler [closeDay] / [closeThrough] still reject today. + */ + open fun upsertDayForFix(day: LocalDate): CloseDayResult { + require(!day.isAfter(LocalDate.now())) { + "only dates on or before today can be closed for fix (got $day)" + } + return doCloseDay(day, ACTOR_FIX) + } + + /** + * Close yesterday, optionally catching up from last closed day (capped by [catchUpDays]). + */ + open fun closeYesterday(catchUpDays: Int = 7, actor: String = ACTOR_SCHEDULER): CloseRunResult { + val yesterday = LocalDate.now().minusDays(1) + return closeThrough(yesterday, catchUpDays, actor) + } + + /** + * Close through [target] (inclusive). Catch-up from last closed + 1, capped by [catchUpDays]. + * If [target] is null, uses yesterday. + */ + open fun closeThrough( + target: LocalDate? = null, + catchUpDays: Int = 7, + actor: String = ACTOR_SCHEDULER, + ): CloseRunResult { + val end = target ?: LocalDate.now().minusDays(1) + require(end.isBefore(LocalDate.now())) { + "only dates before today can be closed (got $end)" + } + val cap = catchUpDays.coerceAtLeast(0) + if (!inFlight.add(LOCK)) { + log.warn("stock_lot_day close skipped: already running") + return CloseRunResult(emptyList(), skipped = true, skipReason = "already running") + } + try { + val lastClosed = maxClosedDate() + val from = when { + lastClosed == null -> end + lastClosed.isBefore(end) -> { + val gapStart = lastClosed.plusDays(1) + val earliest = end.minusDays(cap.toLong()) + if (gapStart.isBefore(earliest)) earliest else gapStart + } + else -> end // already closed through end → re-close end (idempotent) + } + val days = mutableListOf() + var d = from + while (!d.isAfter(end)) { + days += doCloseDay(d, actor) + d = d.plusDays(1) + } + log.info( + "stock_lot_day close done from={} to={} days={} rows={} actor={}", + from, end, days.size, days.sumOf { it.rowsWritten }, actor, + ) + return CloseRunResult(days) + } finally { + inFlight.remove(LOCK) + } + } + + private fun doCloseDay(day: LocalDate, actor: String): CloseDayResult { + val args = mapOf( + "day" to day, + "dayNext" to day.plusDays(1), + "prevDay" to day.minusDays(1), + "actor" to actor, + ) + val t0 = System.nanoTime() + val moveRows = jdbcDao.executeUpdate(MOVES_SQL, args) + val carryRows = jdbcDao.executeUpdate(CARRY_SQL, args) + log.info( + "stock_lot_day close {} moves={} carry={} {}ms actor={}", + day, moveRows, carryRows, (System.nanoTime() - t0) / 1_000_000, actor, + ) + return CloseDayResult(date = day, moveRows = moveRows, carryRows = carryRows) + } + + private fun maxClosedDate(): LocalDate? { + val row = jdbcDao.queryForList( + """ + SELECT MAX(date) AS d + FROM stock_lot_day + WHERE IFNULL(deleted, 0) = 0 + """.trimIndent(), + ).firstOrNull() ?: return null + val v = row["d"] ?: return null + return when (v) { + is LocalDate -> v + is java.sql.Date -> v.toLocalDate() + is java.sql.Timestamp -> v.toLocalDateTime().toLocalDate() + else -> LocalDate.parse(v.toString().take(10)) + } + } + + companion object { + const val ACTOR_SCHEDULER = "stock-lot-day-close" + const val ACTOR_FIX = "ledger-fix-api" + private const val LOCK = "stock-lot-day-close" + + private val MOVES_SQL = """ + INSERT INTO stock_lot_day ( + created, createdBy, version, modified, modifiedBy, deleted, + inventoryLotLineId, itemId, itemCode, uomId, lotNo, date, + opening, inQty, outQty, closing + ) + SELECT + NOW(), :actor, 0, NOW(), :actor, 0, + x.inventoryLotLineId, + x.itemId, + x.itemCode, + x.uomId, + il.lotNo, + :day, + firstSl.lotQtyBefore, + x.inQty, + x.outQty, + lastSl.lotQtyAfter + FROM ( + SELECT + sl.inventoryLotLineId, + 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 >= :day AND sl.date < :dayNext + AND sl.inventoryLotLineId IS NOT NULL + AND sl.lotQtyAfter IS NOT NULL + GROUP BY sl.inventoryLotLineId + ) 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 = VALUES(modifiedBy), + version = stock_lot_day.version + 1, + deleted = 0 + """.trimIndent() + + private val CARRY_SQL = """ + INSERT INTO stock_lot_day ( + created, createdBy, version, modified, modifiedBy, deleted, + inventoryLotLineId, itemId, itemCode, uomId, lotNo, date, + opening, inQty, outQty, closing + ) + SELECT + NOW(), :actor, 0, NOW(), :actor, 0, + prev.inventoryLotLineId, + prev.itemId, + prev.itemCode, + prev.uomId, + prev.lotNo, + :day, + prev.closing, + 0, + 0, + prev.closing + 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 + ) + ON DUPLICATE KEY UPDATE + itemId = VALUES(itemId), + itemCode = VALUES(itemCode), + uomId = VALUES(uomId), + lotNo = VALUES(lotNo), + opening = VALUES(opening), + inQty = VALUES(inQty), + outQty = VALUES(outQty), + closing = VALUES(closing), + modified = NOW(), + modifiedBy = VALUES(modifiedBy), + version = stock_lot_day.version + 1, + deleted = 0 + """.trimIndent() + } +} diff --git a/src/main/resources/application-prod.yml b/src/main/resources/application-prod.yml index dcd2d6e..3629408 100644 --- a/src/main/resources/application-prod.yml +++ b/src/main/resources/application-prod.yml @@ -40,6 +40,9 @@ scheduler: syncOffsetDays: 10 # from (today − 10) 00:00 to now, rows missing grn_code inventoryLotExpiry: enabled: true + stockLotDayClose: + enabled: true + catchUpDays: 7 # One-time DO1 catch-ups (only registered when scheduler.m18Sync.enabled=true — production profile). # skipExistingDo defaults true for catch-up: already-synced/picked DOs are not overwritten. do1CatchUp: diff --git a/src/main/resources/application.yml b/src/main/resources/application.yml index 74ce2bc..d85c24a 100644 --- a/src/main/resources/application.yml +++ b/src/main/resources/application.yml @@ -33,6 +33,10 @@ scheduler: syncOffsetDays: 0 inventoryLotExpiry: enabled: true + # Daily stock_lot_day close (yesterday) at 00:15 — moves + carry; catchUpDays caps missed days. + stockLotDayClose: + enabled: true + catchUpDays: 7 # Job order: at 00:00:15 daily, process JOs whose planStart was yesterday (hide or reschedule). jo: planStart: diff --git a/src/main/resources/db/changelog/changes/20260812_stock_lot_balance_perf/01_ledger_lot_date_index.sql b/src/main/resources/db/changelog/changes/20260812_stock_lot_balance_perf/01_ledger_lot_date_index.sql new file mode 100644 index 0000000..dd21548 --- /dev/null +++ b/src/main/resources/db/changelog/changes/20260812_stock_lot_balance_perf/01_ledger_lot_date_index.sql @@ -0,0 +1,7 @@ +--liquibase formatted sql + +--changeset fpsms:stock_lot_balance_ledger_lot_date_index +--comment: Speed stock lot balance report — as-of last row by inventoryLotLineId + date + id + +CREATE INDEX `idx_ledger_lot_date_id` + ON `stock_ledger` (`inventoryLotLineId`, `date`, `id`); diff --git a/src/main/resources/db/changelog/changes/20260814_stock_lot_day/01_create_stock_lot_day.sql b/src/main/resources/db/changelog/changes/20260814_stock_lot_day/01_create_stock_lot_day.sql new file mode 100644 index 0000000..414eea0 --- /dev/null +++ b/src/main/resources/db/changelog/changes/20260814_stock_lot_day/01_create_stock_lot_day.sql @@ -0,0 +1,30 @@ +--liquibase formatted sql + +--changeset fpsms:create_stock_lot_day +--comment: Daily lot stock card (主錨). Month-end report aggregates this table. +--precondition onFail:MARK_RAN +--precondition-sql-check expectedResult:0 SELECT COUNT(*) FROM information_schema.TABLES WHERE TABLE_SCHEMA = DATABASE() AND TABLE_NAME = 'stock_lot_day' + +CREATE TABLE `stock_lot_day` ( + `id` BIGINT NOT NULL AUTO_INCREMENT, + `created` DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP, + `createdBy` VARCHAR(30) NULL DEFAULT NULL, + `version` INT NOT NULL DEFAULT '0', + `modified` DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP, + `modifiedBy` VARCHAR(30) NULL DEFAULT NULL, + `deleted` TINYINT(1) NOT NULL DEFAULT '0', + `inventoryLotLineId` BIGINT NOT NULL, + `itemId` INT NULL, + `itemCode` VARCHAR(255) NULL, + `uomId` INT NULL, + `lotNo` VARCHAR(512) NULL, + `date` DATE NOT NULL, + `opening` DECIMAL(14,2) NOT NULL DEFAULT 0.00, + `inQty` DECIMAL(14,2) NOT NULL DEFAULT 0.00, + `outQty` DECIMAL(14,2) NOT NULL DEFAULT 0.00, + `closing` DECIMAL(14,2) NOT NULL DEFAULT 0.00, + PRIMARY KEY (`id`), + UNIQUE KEY `uk_stock_lot_day_lot_date` (`inventoryLotLineId`, `date`), + KEY `idx_stock_lot_day_date` (`date`), + KEY `idx_stock_lot_day_item_date` (`itemId`, `date`) +); diff --git a/src/main/resources/db/changelog/changes/20260821_stock_lot_day_close/01_insert_schedule_setting.sql b/src/main/resources/db/changelog/changes/20260821_stock_lot_day_close/01_insert_schedule_setting.sql new file mode 100644 index 0000000..1ea65b0 --- /dev/null +++ b/src/main/resources/db/changelog/changes/20260821_stock_lot_day_close/01_insert_schedule_setting.sql @@ -0,0 +1,10 @@ +--liquibase formatted sql +--changeset fpsms:20260821_stock_lot_day_close_schedule +--comment: Daily stock_lot_day close cron (default 00:15) + +INSERT INTO `settings` (`name`, `value`, `category`, `type`) +SELECT 'SCHEDULE.stockLotDay.close', '0 15 0 * * *', 'SCHEDULE', 'string' +FROM DUAL +WHERE NOT EXISTS ( + SELECT 1 FROM `settings` WHERE `name` = 'SCHEDULE.stockLotDay.close' +); diff --git a/src/main/resources/db/changelog/changes/20260824_stock_ledger_fix_perf/01_add_sl_date_deleted_item_uom_index.sql b/src/main/resources/db/changelog/changes/20260824_stock_ledger_fix_perf/01_add_sl_date_deleted_item_uom_index.sql new file mode 100644 index 0000000..bffada7 --- /dev/null +++ b/src/main/resources/db/changelog/changes/20260824_stock_ledger_fix_perf/01_add_sl_date_deleted_item_uom_index.sql @@ -0,0 +1,8 @@ +-- liquibase formatted sql + +--changeset fpsms:stock_ledger_fix_add_sl_date_deleted_item_uom_index +--comment: Speed stock-ledger-fix step 2.3 fillInventoryId (DISTINCT itemId,uomId over date range) + +CREATE INDEX idx_sl_date_deleted_item_uom + ON stock_ledger (`date`, `deleted`, `itemId`, `uomId`); + diff --git a/src/main/resources/db/scripts/20260814_inventory_1_0.sql b/src/main/resources/db/scripts/20260814_inventory_1_0.sql new file mode 100644 index 0000000..355f899 --- /dev/null +++ b/src/main/resources/db/scripts/20260814_inventory_1_0.sql @@ -0,0 +1,87 @@ +-- Manual (not Liquibase). Inventory 1.0 option A. +-- Keep existing inventory.id. INSERT missing (itemId, uomId) from lot lines +-- (including item_uom.deleted=1 — historical lots still point there). +-- Recalc onHand / onHold / unavailable from Σ lot (in-out / hold / unavailable). +-- UOM with no lots → qty 0. Do NOT UPDATE inventory_lot_line (old trigger would fire). +-- Run once on the dump before walking ledger days. Idempotent if lots unchanged. + +-- 1) INSERT missing (itemId, uomId); copy price/currency from any existing row of that item. +INSERT INTO inventory ( + itemId, uomId, onHandQty, onHoldQty, unavailableQty, + price, currencyId, cpu, cpuUnit, cpm, cpmUnit, status, + created, createdBy, modified, modifiedBy, version, deleted +) +SELECT + src.itemId, + 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 inventory existing + ON existing.itemId = src.itemId + AND existing.uomId = src.uomId + AND IFNULL(existing.deleted, 0) = 0 +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; + +-- 2) Recalc every inventory row from lot lines grouped by item+UOM. No lots → 0. +UPDATE inventory i +LEFT JOIN ( + SELECT + il.itemId, + iu.uomId, + SUM(COALESCE(ill.inQty, 0) - COALESCE(ill.outQty, 0)) AS onHand, + SUM(COALESCE(ill.holdQty, 0)) AS onHold, + SUM( + CASE + WHEN LOWER(ill.status) = 'unavailable' + 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 = i.uomId +SET + i.onHandQty = COALESCE(agg.onHand, 0), + i.onHoldQty = COALESCE(agg.onHold, 0), + i.unavailableQty = COALESCE(agg.unavailable, 0), + i.status = IF( + COALESCE(agg.onHand, 0) - COALESCE(agg.onHold, 0) - COALESCE(agg.unavailable, 0) > 0, + 'available', + 'unavailable' + ), + i.modified = NOW(), + i.modifiedBy = 'stock-ledger-fix' +WHERE IFNULL(i.deleted, 0) = 0;