| @@ -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) */ | /** Mark expired inventory lot lines as unavailable (default 00:05 daily) */ | ||||
| public static final String SCHEDULE_INVENTORY_LOT_EXPIRY = "SCHEDULE.inventoryLot.expiry"; | 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_ROUGH = "SCHEDULE.prod.rough"; | ||||
| public static final String SCHEDULE_PROD_DETAILED = "SCHEDULE.prod.detailed"; | 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.jobOrder.service.JobOrderPlanStartAutoService | ||||
| import com.ffii.fpsms.modules.master.service.BomM18ShopBulkPushService | import com.ffii.fpsms.modules.master.service.BomM18ShopBulkPushService | ||||
| import com.ffii.fpsms.modules.master.service.ProductionScheduleService | 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.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.entity.Settings | ||||
| import com.ffii.fpsms.modules.settings.service.SettingsService | import com.ffii.fpsms.modules.settings.service.SettingsService | ||||
| import jakarta.annotation.PostConstruct | import jakarta.annotation.PostConstruct | ||||
| @@ -43,6 +44,8 @@ open class SchedulerService( | |||||
| @Value("\${scheduler.m18Units.enabled:true}") val m18UnitsSchedulerEnabled: Boolean, | @Value("\${scheduler.m18Units.enabled:true}") val m18UnitsSchedulerEnabled: Boolean, | ||||
| @Value("\${scheduler.m18Units.incrementalLookbackDays:7}") val m18UnitsIncrementalLookbackDays: Int, | @Value("\${scheduler.m18Units.incrementalLookbackDays:7}") val m18UnitsIncrementalLookbackDays: Int, | ||||
| @Value("\${scheduler.inventoryLotExpiry.enabled:true}") val inventoryLotExpiryEnabled: Boolean, | @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. */ | /** 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.m18Sync.enabled:false}") val m18SyncEnabled: Boolean, | ||||
| @Value("\${scheduler.jo.planStart.enabled:true}") val jobOrderPlanStartAutoEnabled: Boolean, | @Value("\${scheduler.jo.planStart.enabled:true}") val jobOrderPlanStartAutoEnabled: Boolean, | ||||
| @@ -71,6 +74,7 @@ open class SchedulerService( | |||||
| val jobOrderPlanStartAutoService: JobOrderPlanStartAutoService, | val jobOrderPlanStartAutoService: JobOrderPlanStartAutoService, | ||||
| private val bomM18ShopBulkPushService: BomM18ShopBulkPushService, | private val bomM18ShopBulkPushService: BomM18ShopBulkPushService, | ||||
| private val schedulerSyncAlertService: SchedulerSyncAlertService, | private val schedulerSyncAlertService: SchedulerSyncAlertService, | ||||
| private val stockLotDayCloseService: StockLotDayCloseService, | |||||
| @Value("\${scheduler.sync-alert.check-cron:0 */15 * * * *}") private val syncAlertCheckCron: String, | @Value("\${scheduler.sync-alert.check-cron:0 */15 * * * *}") private val syncAlertCheckCron: String, | ||||
| @Value("\${scheduler.sync-alert.enabled:false}") private val syncAlertEnabled: Boolean, | @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 * * *" | const val M18_BOM_SHOP_DEFAULT_CRON: String = "0 0 23 * * *" | ||||
| /** Daily 00:00:15 — process job orders whose planStart was yesterday. */ | /** Daily 00:00:15 — process job orders whose planStart was yesterday. */ | ||||
| const val JO_PLAN_START_DEFAULT_CRON: String = "15 0 0 * * *" | 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). */ | /** 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 scheduledGrnCodeSync: ScheduledFuture<*>? = null | ||||
| var scheduledInventoryLotExpiry: ScheduledFuture<*>? = null | var scheduledInventoryLotExpiry: ScheduledFuture<*>? = null | ||||
| var scheduledStockLotDayClose: ScheduledFuture<*>? = null | |||||
| var scheduledJobOrderPlanStart: ScheduledFuture<*>? = null | var scheduledJobOrderPlanStart: ScheduledFuture<*>? = null | ||||
| var scheduledDo1CatchUp: ScheduledFuture<*>? = null | var scheduledDo1CatchUp: ScheduledFuture<*>? = null | ||||
| @@ -208,6 +216,7 @@ open class SchedulerService( | |||||
| schedulePostCompletedDnGrn(); | schedulePostCompletedDnGrn(); | ||||
| scheduleGrnCodeSync(); | scheduleGrnCodeSync(); | ||||
| scheduleInventoryLotExpiry(); | scheduleInventoryLotExpiry(); | ||||
| scheduleStockLotDayClose(); | |||||
| scheduleJobOrderPlanStartAuto(); | scheduleJobOrderPlanStartAuto(); | ||||
| scheduleDo1CatchUpOnce(); | scheduleDo1CatchUpOnce(); | ||||
| scheduleSyncAlertWatchdog(); | 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 | // Function for schedule | ||||
| // --------------------------- FP-MTMS --------------------------- // | // --------------------------- FP-MTMS --------------------------- // | ||||
| @@ -96,6 +96,17 @@ class SchedulerController( | |||||
| return "Inventory lot expiry status update triggered" | 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") | @GetMapping("/trigger/post-completed-dn-grn") | ||||
| fun triggerPostCompletedDnGrn( | fun triggerPostCompletedDnGrn( | ||||
| @RequestParam(required = false) @DateTimeFormat(iso = DateTimeFormat.ISO.DATE) receiptDate: LocalDate? = null, | @RequestParam(required = false) @DateTimeFormat(iso = DateTimeFormat.ISO.DATE) receiptDate: LocalDate? = null, | ||||
| @@ -89,8 +89,8 @@ class ItemsController( | |||||
| @GetMapping("/pickOrderItems") | @GetMapping("/pickOrderItems") | ||||
| fun getPickOrderItemsByPage(request: HttpServletRequest): RecordsRes<Map<String, Any>> { | fun getPickOrderItemsByPage(request: HttpServletRequest): RecordsRes<Map<String, Any>> { | ||||
| try { | try { | ||||
| println("=== Debug: getPickOrderItemsByPage ===") | |||||
| println("Request parameters: ${request.parameterMap}") | |||||
| //println("=== Debug: getPickOrderItemsByPage ===") | |||||
| //println("Request parameters: ${request.parameterMap}") | |||||
| val criteriaArgs = CriteriaArgsBuilder.withRequest(request) | val criteriaArgs = CriteriaArgsBuilder.withRequest(request) | ||||
| .addStringLike("itemCode") | .addStringLike("itemCode") | ||||
| @@ -100,18 +100,18 @@ class ItemsController( | |||||
| .addString("targetDateTo") | .addString("targetDateTo") | ||||
| .build() | .build() | ||||
| println("Criteria args: $criteriaArgs") | |||||
| //println("Criteria args: $criteriaArgs") | |||||
| val pageSize = request.getParameter("pageSize")?.toIntOrNull() ?: 10 | val pageSize = request.getParameter("pageSize")?.toIntOrNull() ?: 10 | ||||
| val pageNum = request.getParameter("pageNum")?.toIntOrNull() ?: 1 | 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) | val fullList = itemsService.getPickOrderItemsByPage(criteriaArgs) | ||||
| println("Full list size: ${fullList.size}") | |||||
| //println("Full list size: ${fullList.size}") | |||||
| val paginatedList = PagingUtils.getPaginatedList(fullList, pageSize, pageNum) | val paginatedList = PagingUtils.getPaginatedList(fullList, pageSize, pageNum) | ||||
| println("Paginated list size: ${paginatedList.size}") | |||||
| //println("Paginated list size: ${paginatedList.size}") | |||||
| return RecordsRes(paginatedList, fullList.size) | return RecordsRes(paginatedList, fullList.size) | ||||
| } catch (e: Exception) { | } 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. | * 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. | * Supports comma-separated values for stockCategory (items.type) and itemCode. | ||||
| * Date range [lastInDateStart, lastInDateEnd] filters on stock_in_line.productionDate (完成生產日期), same basis as 成品/半成品生產分析報告. | * 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( | fun searchStockInTraceabilityReport( | ||||
| stockCategory: String?, | stockCategory: String?, | ||||
| itemCode: String?, | itemCode: String?, | ||||
| lastInDateStart: 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>> { | ): List<Map<String, Any>> { | ||||
| val args = mutableMapOf<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)" | "AND sil.productionDate IS NOT NULL AND DATE(sil.productionDate) <= DATE(:lastInDateEnd)" | ||||
| } else "" | } 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 = """ | val sql = """ | ||||
| SELECT | SELECT | ||||
| COALESCE(it.code, '') as itemNo, | COALESCE(it.code, '') as itemNo, | ||||
| @@ -728,10 +763,17 @@ return result | |||||
| GROUP BY qr.stockInLineId | GROUP BY qr.stockInLineId | ||||
| ) qr_agg ON qr_agg.stockInLineId = sil.id | ) qr_agg ON qr_agg.stockInLineId = sil.id | ||||
| WHERE sil.deleted = false | WHERE sil.deleted = false | ||||
| AND UPPER(TRIM(COALESCE(sil.type, ''))) = 'NOR' | |||||
| $stockCategorySql | $stockCategorySql | ||||
| $itemCodeSql | $itemCodeSql | ||||
| $lastInDateStartSql | $lastInDateStartSql | ||||
| $lastInDateEndSql | $lastInDateEndSql | ||||
| $storeIdSql | |||||
| $warehouseSql | |||||
| $areaSql | |||||
| $slotSql | |||||
| $lotNoSql | |||||
| $poPrefixSql | |||||
| ORDER BY it.code, sil.lotNo | ORDER BY it.code, sil.lotNo | ||||
| """.trimIndent() | """.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) stockCategory: String?, | ||||
| @RequestParam(required = false) itemCode: String?, | @RequestParam(required = false) itemCode: String?, | ||||
| @RequestParam(required = false) lastInDateStart: 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> { | ): ResponseEntity<ByteArray> { | ||||
| val parameters = mutableMapOf<String, Any>() | val parameters = mutableMapOf<String, Any>() | ||||
| @@ -137,7 +143,13 @@ class ReportController( | |||||
| stockCategory, | stockCategory, | ||||
| itemCode, | itemCode, | ||||
| lastInDateStart, | lastInDateStart, | ||||
| lastInDateEnd | |||||
| lastInDateEnd, | |||||
| storeId, | |||||
| warehouse, | |||||
| area, | |||||
| slot, | |||||
| lotNo, | |||||
| poPrefix, | |||||
| ) | ) | ||||
| val pdfBytes = reportService.createPdfResponse( | val pdfBytes = reportService.createPdfResponse( | ||||
| @@ -161,18 +173,37 @@ class ReportController( | |||||
| @RequestParam(required = false) itemCode: String?, | @RequestParam(required = false) itemCode: String?, | ||||
| @RequestParam(required = false) lastInDateStart: 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> { | ): ResponseEntity<ByteArray> { | ||||
| val dbData = reportService.searchStockInTraceabilityReport( | val dbData = reportService.searchStockInTraceabilityReport( | ||||
| stockCategory, | stockCategory, | ||||
| itemCode, | itemCode, | ||||
| lastInDateStart, | lastInDateStart, | ||||
| lastInDateEnd, | lastInDateEnd, | ||||
| storeId, | |||||
| warehouse, | |||||
| area, | |||||
| slot, | |||||
| lotNo, | |||||
| poPrefix, | |||||
| ) | ) | ||||
| val excelBytes = createStockInTraceabilityExcel( | val excelBytes = createStockInTraceabilityExcel( | ||||
| dbData = dbData, | dbData = dbData, | ||||
| lastInDateStart = lastInDateStart ?: "", | lastInDateStart = lastInDateStart ?: "", | ||||
| lastInDateEnd = lastInDateEnd ?: "", | lastInDateEnd = lastInDateEnd ?: "", | ||||
| itemCode = itemCode, | |||||
| storeId = storeId, | |||||
| warehouse = warehouse, | |||||
| area = area, | |||||
| slot = slot, | |||||
| lotNo = lotNo, | |||||
| poPrefix = poPrefix, | |||||
| ) | ) | ||||
| val headers = HttpHeaders().apply { | val headers = HttpHeaders().apply { | ||||
| @@ -563,6 +594,13 @@ class ReportController( | |||||
| dbData: List<Map<String, Any>>, | dbData: List<Map<String, Any>>, | ||||
| lastInDateStart: String, | lastInDateStart: String, | ||||
| lastInDateEnd: 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 { | ): ByteArray { | ||||
| val workbook = XSSFWorkbook() | val workbook = XSSFWorkbook() | ||||
| val styles = createCommonWorkbookStyles(workbook) | val styles = createCommonWorkbookStyles(workbook) | ||||
| @@ -598,8 +636,18 @@ class ReportController( | |||||
| } | } | ||||
| sheet.addMergedRegion(CellRangeAddress(1, 1, 0, 5)) | sheet.addMergedRegion(CellRangeAddress(1, 1, 0, 5)) | ||||
| subtitleRow.createCell(6).apply { | 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 | cellStyle = styles.subtitle | ||||
| } | } | ||||
| sheet.addMergedRegion(CellRangeAddress(1, 1, 6, totalColumns - 1)) | 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 | syncOffsetDays: 10 # from (today − 10) 00:00 to now, rows missing grn_code | ||||
| inventoryLotExpiry: | inventoryLotExpiry: | ||||
| enabled: true | enabled: true | ||||
| stockLotDayClose: | |||||
| enabled: true | |||||
| catchUpDays: 7 | |||||
| # One-time DO1 catch-ups (only registered when scheduler.m18Sync.enabled=true — production profile). | # 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. | # skipExistingDo defaults true for catch-up: already-synced/picked DOs are not overwritten. | ||||
| do1CatchUp: | do1CatchUp: | ||||
| @@ -33,6 +33,10 @@ scheduler: | |||||
| syncOffsetDays: 0 | syncOffsetDays: 0 | ||||
| inventoryLotExpiry: | inventoryLotExpiry: | ||||
| enabled: true | 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). | # Job order: at 00:00:15 daily, process JOs whose planStart was yesterday (hide or reschedule). | ||||
| jo: | jo: | ||||
| planStart: | 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; | |||||