| @@ -0,0 +1,418 @@ | |||
| #!/usr/bin/env python3 | |||
| # -*- coding: utf-8 -*- | |||
| """ | |||
| 31/8 night stock-ledger-fix timeline (InventoryId match vs not match). | |||
| Usage (from repo root): | |||
| python scripts/export_stock_ledger_fix_timeline.py | |||
| Output: | |||
| docs/deploy/20260831_stock_ledger_fix_timeline.xlsx | |||
| """ | |||
| from __future__ import annotations | |||
| from pathlib import Path | |||
| from openpyxl import Workbook | |||
| from openpyxl.styles import Alignment, Border, Font, PatternFill, Side | |||
| from openpyxl.utils import get_column_letter | |||
| from openpyxl.worksheet.worksheet import Worksheet | |||
| ROOT = Path(__file__).resolve().parents[1] | |||
| OUT = ROOT / "docs" / "deploy" / "20260831_stock_ledger_fix_timeline.xlsx" | |||
| THIN = Border( | |||
| left=Side(style="thin", color="B0B0B0"), | |||
| right=Side(style="thin", color="B0B0B0"), | |||
| top=Side(style="thin", color="B0B0B0"), | |||
| bottom=Side(style="thin", color="B0B0B0"), | |||
| ) | |||
| WRAP = Alignment(wrap_text=True, vertical="center") | |||
| WRAP_CENTER = Alignment(wrap_text=True, vertical="center", horizontal="center") | |||
| FILL_TITLE = PatternFill("solid", fgColor="1F4E79") | |||
| FILL_MATCH = PatternFill("solid", fgColor="548235") | |||
| FILL_NOMATCH = PatternFill("solid", fgColor="C45911") | |||
| FILL_HEAD = PatternFill("solid", fgColor="D9E2F3") | |||
| FILL_HEAD_M = PatternFill("solid", fgColor="C6E0B4") | |||
| FILL_HEAD_N = PatternFill("solid", fgColor="F8CBAD") | |||
| FILL_GATE = PatternFill("solid", fgColor="FFF2CC") | |||
| FILL_WAIT = PatternFill("solid", fgColor="DDEBF7") | |||
| FILL_STOP = PatternFill("solid", fgColor="F4B183") | |||
| FILL_NOTE = PatternFill("solid", fgColor="F2F2F2") | |||
| WHITE = Font(bold=True, color="FFFFFF", name="Calibri", size=14) | |||
| WHITE11 = Font(bold=True, color="FFFFFF", name="Calibri", size=11) | |||
| BOLD = Font(bold=True, name="Calibri", size=11) | |||
| NORMAL = Font(name="Calibri", size=10) | |||
| ITALIC = Font(italic=True, color="666666", name="Calibri", size=9) | |||
| def set_col_widths(ws: Worksheet, widths: dict[str, float]) -> 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() | |||
| @@ -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"; | |||
| @@ -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 --------------------------- // | |||
| @@ -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, | |||
| @@ -89,8 +89,8 @@ class ItemsController( | |||
| @GetMapping("/pickOrderItems") | |||
| fun getPickOrderItemsByPage(request: HttpServletRequest): RecordsRes<Map<String, Any>> { | |||
| 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) { | |||
| @@ -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<StockLedgerFixSearchInventoryHit> = | |||
| stockLedgerFixService.searchInventory(q) | |||
| @PreAuthorize("hasAnyAuthority('ADMIN','TESTING')") | |||
| @GetMapping("/lookup/lot") | |||
| open fun lookupLot(@RequestParam q: String): List<StockLedgerFixSearchLotHit> = | |||
| 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<String>?, | |||
| 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") | |||
| } | |||
| } | |||
| } | |||
| @@ -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<String>? = 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<StockLedgerFixDayStatus>, | |||
| ) | |||
| 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<StockLedgerFixCheckPart>, | |||
| ) | |||
| 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<StockLedgerFixAdjRow>, | |||
| ) | |||
| 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<StockLedgerFixCheckPart>, | |||
| ) | |||
| @@ -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<Map<String, Any>> { | |||
| val args = mutableMapOf<String, Any>() | |||
| @@ -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() | |||
| @@ -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<Map<String, Any>>, | |||
| val timings: List<String>, | |||
| val stockDate: String, | |||
| ) | |||
| fun searchStockLotBalanceReport( | |||
| stockDate: String?, | |||
| itemCode: String?, | |||
| warehouseCode: String?, | |||
| storeId: String?, | |||
| lotNo: String?, | |||
| ): List<Map<String, Any>> = 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<String>() | |||
| val tAll = System.nanoTime() | |||
| fun <T> 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<String, Any>( | |||
| "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<String, Any>() | |||
| 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<Map<String, Any>> -> "rows=${rows.size}" }) { | |||
| jdbcDao.queryForList( | |||
| """ | |||
| $masterSelectSql | |||
| AND (COALESCE(ill.inQty, 0) - COALESCE(ill.outQty, 0)) <> 0 | |||
| """.trimIndent(), | |||
| masterArgs, | |||
| ) | |||
| } | |||
| val masterByLot = HashMap<Long, Map<String, Any>>(liveLots.size * 2) | |||
| for (r in liveLots) { | |||
| masterByLot[toLong(r["inventoryLotLineId"])] = r | |||
| } | |||
| val ledgerRows = timed("2.day_ledger", { rows: List<Map<String, Any>> -> "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<Long, LotDayAgg> -> "lots=${map.size}" }) { | |||
| aggregateDayMoves(ledgerRows) | |||
| } | |||
| val missingMoveIds = movesByLot.keys.filter { it !in masterByLot } | |||
| timed("4.zero_lots", { rows: List<Map<String, Any>> -> "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<Map<String, Any>> -> "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<String, Any>>): Map<Long, LotDayAgg> { | |||
| val byLot = HashMap<Long, LotDayAgg>() | |||
| 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<Long, LotDayAgg>, | |||
| masterByLot: Map<Long, Map<String, Any>>, | |||
| ): List<Map<String, Any>> { | |||
| val lotIds = LinkedHashSet<Long>(masterByLot.size) | |||
| lotIds.addAll(masterByLot.keys) | |||
| val out = ArrayList<Map<String, Any>>(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<Map<String, Any>> { 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<String, Any>, | |||
| opening: Double, | |||
| current: Double, | |||
| move: LotDayAgg?, | |||
| ): Map<String, Any> { | |||
| 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<String, Any>(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<Long>, | |||
| baseArgs: Map<String, Any>, | |||
| query: (Map<String, Any>) -> List<Map<String, Any>>, | |||
| ): List<Map<String, Any>> { | |||
| val ids = lotIds.filter { it > 0 }.distinct() | |||
| if (ids.isEmpty()) return emptyList() | |||
| val out = ArrayList<Map<String, Any>>() | |||
| for (chunk in ids.chunked(800)) { | |||
| val chunkArgs = HashMap<String, Any>(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, Any>, | |||
| ): 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 ")})" | |||
| } | |||
| } | |||
| @@ -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<Map<String, Any>>, | |||
| val timings: List<String>, | |||
| 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<Map<String, Any>> = 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<String>() | |||
| val tAll = System.nanoTime() | |||
| fun <T> 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<String, Any>( | |||
| "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<Map<String, Any>> -> "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<Long, Double>(dayOpen.size * 2) | |||
| for (r in dayOpen) { | |||
| dayOpenByLot[toLong(r["inventoryLotLineId"])] = toDouble(r["openingQty"]) | |||
| } | |||
| val ledgerRows = timed("2a.period_ledger", { rows: List<Map<String, Any>> -> "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<Long, String> -> "refs=${map.size}" }) { | |||
| loadRefDocs(ledgerRows) | |||
| } | |||
| val moves = timed("2c.attach_refs", { rows: List<Map<String, Any>> -> "rows=${rows.size}" }) { | |||
| ledgerRows.map { r -> | |||
| val out = HashMap(r) | |||
| val slId = toLong(r["slId"]) | |||
| out["orderRefNo"] = refBySlId[slId] ?: "" | |||
| out | |||
| } | |||
| } | |||
| val periodLotIds = LinkedHashSet<Long>() | |||
| val firstMoveByLot = HashMap<Long, Double>() | |||
| val firstMoveKey = HashMap<Long, Pair<String, Long>>() | |||
| 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<Long>(dayOpenByLot.size + periodLotIds.size) | |||
| eligibleLotIds.addAll(dayOpenByLot.keys) | |||
| eligibleLotIds.addAll(periodLotIds) | |||
| val masterByLot = HashMap<Long, Map<String, Any>>(eligibleLotIds.size * 2) | |||
| timed("4.master", { rows: List<Map<String, Any>> -> "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<Map<String, Any>> -> "rows=${rows.size}" }) { | |||
| assembleRows( | |||
| asOfStr = period.asOfStr, | |||
| dayOpenByLot = dayOpenByLot, | |||
| firstMoveByLot = firstMoveByLot, | |||
| moves = moves, | |||
| masterByLot = masterByLot, | |||
| ) | |||
| } | |||
| val rows = timed("6.totals", { list: List<Map<String, Any>> -> "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<Long, Double>, | |||
| firstMoveByLot: Map<Long, Double>, | |||
| moves: List<Map<String, Any>>, | |||
| masterByLot: Map<Long, Map<String, Any>>, | |||
| ): List<Map<String, Any>> { | |||
| val movesByLot = LinkedHashMap<Long, MutableList<Map<String, Any>>>() | |||
| for (m in moves) { | |||
| val lotId = toLong(m["inventoryLotLineId"]) | |||
| if (lotId !in masterByLot) continue | |||
| movesByLot.getOrPut(lotId) { mutableListOf() }.add(m) | |||
| } | |||
| val openingLots = LinkedHashSet<Long>() | |||
| openingLots.addAll(dayOpenByLot.keys) | |||
| openingLots.addAll(firstMoveByLot.keys) | |||
| openingLots.addAll(movesByLot.keys) | |||
| val out = ArrayList<Map<String, Any>>(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<Map<String, Any>> { 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<String, Any>>): Map<Long, String> { | |||
| if (ledgerRows.isEmpty()) return emptyMap() | |||
| val slByIn = HashMap<Long, MutableList<Long>>() | |||
| val slByOut = HashMap<Long, MutableList<Long>>() | |||
| 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<Long, String>() | |||
| 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<String, Any>, | |||
| ): Map<String, Any> { | |||
| val row = HashMap<String, Any>(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<String, Any>, | |||
| inQty: Double, | |||
| outQty: Double, | |||
| bal: Double, | |||
| master: Map<String, Any>, | |||
| ): Map<String, Any> { | |||
| val row = HashMap<String, Any>(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<Long>, | |||
| baseArgs: Map<String, Any>, | |||
| query: (Map<String, Any>) -> List<Map<String, Any>>, | |||
| ): List<Map<String, Any>> { | |||
| val ids = lotIds.filter { it > 0 }.distinct() | |||
| if (ids.isEmpty()) return emptyList() | |||
| val out = ArrayList<Map<String, Any>>() | |||
| for (chunk in ids.chunked(800)) { | |||
| val chunkArgs = HashMap<String, Any>(baseArgs) | |||
| chunkArgs["lotIds"] = chunk | |||
| out.addAll(query(chunkArgs)) | |||
| } | |||
| return out | |||
| } | |||
| private fun attachItemUomTotals(rows: List<Map<String, Any>>): List<Map<String, Any>> { | |||
| if (rows.isEmpty()) return rows | |||
| val lotEnd = LinkedHashMap<String, Pair<String, Double>>() | |||
| 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<String, Double>() | |||
| 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, Any>, | |||
| ): 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 ")})" | |||
| } | |||
| } | |||
| @@ -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<ByteArray> { | |||
| val parameters = mutableMapOf<String, Any>() | |||
| @@ -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<ByteArray> { | |||
| 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<Map<String, Any>>, | |||
| 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)) | |||
| @@ -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<ByteArray> { | |||
| 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<Map<String, Any>>, | |||
| reportDate: String, | |||
| itemCode: String?, | |||
| warehouseCode: String?, | |||
| storeId: String?, | |||
| lotNo: String?, | |||
| timings: List<String>, | |||
| ): 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() | |||
| } | |||
| } | |||
| } | |||
| @@ -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<ByteArray> { | |||
| 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<String, Any>): Boolean { | |||
| val v = m["isOpening"] | |||
| if (v is Number) return v.toInt() != 0 | |||
| return v?.toString() == "1" | |||
| } | |||
| private fun createExcel( | |||
| dbData: List<Map<String, Any>>, | |||
| reportPeriodStart: String, | |||
| reportPeriodEnd: String, | |||
| timings: List<String>, | |||
| ): 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() | |||
| } | |||
| } | |||
| } | |||
| @@ -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<String>() | |||
| data class CloseDayResult( | |||
| val date: LocalDate, | |||
| val moveRows: Int, | |||
| val carryRows: Int, | |||
| ) { | |||
| val rowsWritten: Int get() = moveRows + carryRows | |||
| } | |||
| data class CloseRunResult( | |||
| val days: List<CloseDayResult>, | |||
| 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<CloseDayResult>() | |||
| 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() | |||
| } | |||
| } | |||
| @@ -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: | |||
| @@ -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: | |||
| @@ -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`); | |||
| @@ -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`) | |||
| ); | |||
| @@ -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' | |||
| ); | |||
| @@ -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`); | |||
| @@ -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; | |||