Sfoglia il codice sorgente

stock ledger fix

fix負數倉
CANCERYS\kw093 2 settimane fa
parent
commit
8e30fd7e0d
23 ha cambiato i file con 5887 aggiunte e 13 eliminazioni
  1. BIN
      docs/deploy/20260831_stock_ledger_fix_timeline.xlsx
  2. +418
    -0
      scripts/export_stock_ledger_fix_timeline.py
  3. +3
    -0
      src/main/java/com/ffii/fpsms/modules/common/SettingNames.java
  4. +82
    -1
      src/main/java/com/ffii/fpsms/modules/common/scheduler/service/SchedulerService.kt
  5. +11
    -0
      src/main/java/com/ffii/fpsms/modules/common/scheduler/web/SchedulerController.kt
  6. +3078
    -0
      src/main/java/com/ffii/fpsms/modules/master/service/StockLedgerFixService.kt
  7. +7
    -7
      src/main/java/com/ffii/fpsms/modules/master/web/ItemsController.kt
  8. +126
    -0
      src/main/java/com/ffii/fpsms/modules/master/web/StockLedgerFixController.kt
  9. +138
    -0
      src/main/java/com/ffii/fpsms/modules/master/web/models/StockLedgerFixModels.kt
  10. +43
    -1
      src/main/java/com/ffii/fpsms/modules/report/service/ReportService.kt
  11. +372
    -0
      src/main/java/com/ffii/fpsms/modules/report/service/StockLotBalanceReportService.kt
  12. +492
    -0
      src/main/java/com/ffii/fpsms/modules/report/service/StockLotLedgerReportService.kt
  13. +52
    -4
      src/main/java/com/ffii/fpsms/modules/report/web/ReportController.kt
  14. +330
    -0
      src/main/java/com/ffii/fpsms/modules/report/web/StockLotBalanceReportController.kt
  15. +326
    -0
      src/main/java/com/ffii/fpsms/modules/report/web/StockLotLedgerReportController.kt
  16. +260
    -0
      src/main/java/com/ffii/fpsms/modules/stock/service/StockLotDayCloseService.kt
  17. +3
    -0
      src/main/resources/application-prod.yml
  18. +4
    -0
      src/main/resources/application.yml
  19. +7
    -0
      src/main/resources/db/changelog/changes/20260812_stock_lot_balance_perf/01_ledger_lot_date_index.sql
  20. +30
    -0
      src/main/resources/db/changelog/changes/20260814_stock_lot_day/01_create_stock_lot_day.sql
  21. +10
    -0
      src/main/resources/db/changelog/changes/20260821_stock_lot_day_close/01_insert_schedule_setting.sql
  22. +8
    -0
      src/main/resources/db/changelog/changes/20260824_stock_ledger_fix_perf/01_add_sl_date_deleted_item_uom_index.sql
  23. +87
    -0
      src/main/resources/db/scripts/20260814_inventory_1_0.sql

BIN
docs/deploy/20260831_stock_ledger_fix_timeline.xlsx Vedi File


+ 418
- 0
scripts/export_stock_ledger_fix_timeline.py Vedi File

@@ -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()

+ 3
- 0
src/main/java/com/ffii/fpsms/modules/common/SettingNames.java Vedi File

@@ -59,6 +59,9 @@ public abstract class SettingNames {
/** Mark expired inventory lot lines as unavailable (default 00:05 daily) */
public static final String SCHEDULE_INVENTORY_LOT_EXPIRY = "SCHEDULE.inventoryLot.expiry";

/** Daily stock_lot_day close for yesterday (default 00:15; moves + carry) */
public static final String SCHEDULE_STOCK_LOT_DAY_CLOSE = "SCHEDULE.stockLotDay.close";

public static final String SCHEDULE_PROD_ROUGH = "SCHEDULE.prod.rough";

public static final String SCHEDULE_PROD_DETAILED = "SCHEDULE.prod.detailed";


+ 82
- 1
src/main/java/com/ffii/fpsms/modules/common/scheduler/service/SchedulerService.kt Vedi File

@@ -13,8 +13,9 @@ import com.ffii.fpsms.modules.common.alert.SchedulerSyncAlertService
import com.ffii.fpsms.modules.jobOrder.service.JobOrderPlanStartAutoService
import com.ffii.fpsms.modules.master.service.BomM18ShopBulkPushService
import com.ffii.fpsms.modules.master.service.ProductionScheduleService
import com.ffii.fpsms.modules.stock.service.SearchCompletedDnService
import com.ffii.fpsms.modules.stock.service.InventoryLotLineService
import com.ffii.fpsms.modules.stock.service.SearchCompletedDnService
import com.ffii.fpsms.modules.stock.service.StockLotDayCloseService
import com.ffii.fpsms.modules.settings.entity.Settings
import com.ffii.fpsms.modules.settings.service.SettingsService
import jakarta.annotation.PostConstruct
@@ -43,6 +44,8 @@ open class SchedulerService(
@Value("\${scheduler.m18Units.enabled:true}") val m18UnitsSchedulerEnabled: Boolean,
@Value("\${scheduler.m18Units.incrementalLookbackDays:7}") val m18UnitsIncrementalLookbackDays: Int,
@Value("\${scheduler.inventoryLotExpiry.enabled:true}") val inventoryLotExpiryEnabled: Boolean,
@Value("\${scheduler.stockLotDayClose.enabled:true}") val stockLotDayCloseEnabled: Boolean,
@Value("\${scheduler.stockLotDayClose.catchUpDays:7}") val stockLotDayCloseCatchUpDays: Int,
/** When false (default), M18 PO / DO1 / DO2 / master-data cron jobs are not registered — use true in production only. */
@Value("\${scheduler.m18Sync.enabled:false}") val m18SyncEnabled: Boolean,
@Value("\${scheduler.jo.planStart.enabled:true}") val jobOrderPlanStartAutoEnabled: Boolean,
@@ -71,6 +74,7 @@ open class SchedulerService(
val jobOrderPlanStartAutoService: JobOrderPlanStartAutoService,
private val bomM18ShopBulkPushService: BomM18ShopBulkPushService,
private val schedulerSyncAlertService: SchedulerSyncAlertService,
private val stockLotDayCloseService: StockLotDayCloseService,
@Value("\${scheduler.sync-alert.check-cron:0 */15 * * * *}") private val syncAlertCheckCron: String,
@Value("\${scheduler.sync-alert.enabled:false}") private val syncAlertEnabled: Boolean,
) {
@@ -82,6 +86,8 @@ open class SchedulerService(
const val M18_BOM_SHOP_DEFAULT_CRON: String = "0 0 23 * * *"
/** Daily 00:00:15 — process job orders whose planStart was yesterday. */
const val JO_PLAN_START_DEFAULT_CRON: String = "15 0 0 * * *"
/** Daily 00:15 — stock_lot_day close for yesterday (moves + carry). */
const val STOCK_LOT_DAY_CLOSE_DEFAULT_CRON: String = "0 15 0 * * *"
}

/** Class logger (was incorrectly wired to JwtTokenUtil, so all scheduler lines showed under that category). */
@@ -111,6 +117,8 @@ open class SchedulerService(
var scheduledGrnCodeSync: ScheduledFuture<*>? = null
var scheduledInventoryLotExpiry: ScheduledFuture<*>? = null

var scheduledStockLotDayClose: ScheduledFuture<*>? = null

var scheduledJobOrderPlanStart: ScheduledFuture<*>? = null

var scheduledDo1CatchUp: ScheduledFuture<*>? = null
@@ -208,6 +216,7 @@ open class SchedulerService(
schedulePostCompletedDnGrn();
scheduleGrnCodeSync();
scheduleInventoryLotExpiry();
scheduleStockLotDayClose();
scheduleJobOrderPlanStartAuto();
scheduleDo1CatchUpOnce();
scheduleSyncAlertWatchdog();
@@ -581,6 +590,78 @@ open class SchedulerService(
}
}

/**
* Daily stock_lot_day close at 00:15 (yesterday + catch-up).
* Set scheduler.stockLotDayClose.enabled=false to disable.
*/
fun scheduleStockLotDayClose() {
if (!stockLotDayCloseEnabled) {
scheduledStockLotDayClose?.cancel(false)
scheduledStockLotDayClose = null
logger.info("stock_lot_day close scheduler disabled (scheduler.stockLotDayClose.enabled=false)")
return
}
scheduledStockLotDayClose = commonSchedule(
scheduledStockLotDayClose,
SettingNames.SCHEDULE_STOCK_LOT_DAY_CLOSE,
STOCK_LOT_DAY_CLOSE_DEFAULT_CRON,
{ runStockLotDayClose() },
)
logger.info(
"Scheduled stock_lot_day close (default cron={}, catchUpDays={})",
STOCK_LOT_DAY_CLOSE_DEFAULT_CRON,
stockLotDayCloseCatchUpDays,
)
}

/**
* Close through [date] (default yesterday). Catch-up from last closed day, capped by catchUpDays.
* Rejects date >= today.
*/
open fun runStockLotDayClose(date: LocalDate? = null): String {
val currentTime = LocalDateTime.now()
return try {
val result = stockLotDayCloseService.closeThrough(
target = date,
catchUpDays = stockLotDayCloseCatchUpDays,
)
if (result.skipped) {
val msg = "stock_lot_day close skipped: ${result.skipReason}"
logger.warn("Scheduler - {}", msg)
saveSyncLog(
type = "STOCK_LOT_DAY",
status = "SKIPPED",
result = SyncResult(0, 0, 0, msg),
start = currentTime,
)
return msg
}
val query = "from=${result.dateFrom} to=${result.dateTo} days=${result.days.size} catchUpDays=$stockLotDayCloseCatchUpDays"
saveSyncLog(
type = "STOCK_LOT_DAY",
status = "SUCCESS",
result = SyncResult(
totalProcessed = result.days.size,
totalSuccess = result.totalRows,
totalFail = 0,
query = query,
),
start = currentTime,
)
logger.info("Scheduler - stock_lot_day close done {}", query)
"stock_lot_day close ok $query rows=${result.totalRows}"
} catch (e: Exception) {
logger.error("Scheduler - stock_lot_day close failed: ${e.message}", e)
saveSyncLog(
type = "STOCK_LOT_DAY",
status = "FAILED",
error = e.message,
start = currentTime,
)
throw e
}
}

// Function for schedule
// --------------------------- FP-MTMS --------------------------- //


+ 11
- 0
src/main/java/com/ffii/fpsms/modules/common/scheduler/web/SchedulerController.kt Vedi File

@@ -96,6 +96,17 @@ class SchedulerController(
return "Inventory lot expiry status update triggered"
}

/**
* Manual stock_lot_day close (moves + carry). Omit [date] → through yesterday (with catch-up).
* [date] must be before today. Example: GET /scheduler/trigger/stock-lot-day-close?date=2026-08-20
*/
@GetMapping("/trigger/stock-lot-day-close")
fun triggerStockLotDayClose(
@RequestParam(required = false) @DateTimeFormat(iso = DateTimeFormat.ISO.DATE) date: LocalDate? = null,
): String {
return schedulerService.runStockLotDayClose(date)
}

@GetMapping("/trigger/post-completed-dn-grn")
fun triggerPostCompletedDnGrn(
@RequestParam(required = false) @DateTimeFormat(iso = DateTimeFormat.ISO.DATE) receiptDate: LocalDate? = null,


+ 3078
- 0
src/main/java/com/ffii/fpsms/modules/master/service/StockLedgerFixService.kt
File diff soppresso perché troppo grande
Vedi File


+ 7
- 7
src/main/java/com/ffii/fpsms/modules/master/web/ItemsController.kt Vedi File

@@ -89,8 +89,8 @@ class ItemsController(
@GetMapping("/pickOrderItems")
fun getPickOrderItemsByPage(request: HttpServletRequest): RecordsRes<Map<String, Any>> {
try {
println("=== Debug: getPickOrderItemsByPage ===")
println("Request parameters: ${request.parameterMap}")
//println("=== Debug: getPickOrderItemsByPage ===")
//println("Request parameters: ${request.parameterMap}")
val criteriaArgs = CriteriaArgsBuilder.withRequest(request)
.addStringLike("itemCode")
@@ -100,18 +100,18 @@ class ItemsController(
.addString("targetDateTo")
.build()
println("Criteria args: $criteriaArgs")
//println("Criteria args: $criteriaArgs")
val pageSize = request.getParameter("pageSize")?.toIntOrNull() ?: 10
val pageNum = request.getParameter("pageNum")?.toIntOrNull() ?: 1
println("Page size: $pageSize, Page num: $pageNum")
//println("Page size: $pageSize, Page num: $pageNum")
val fullList = itemsService.getPickOrderItemsByPage(criteriaArgs)
println("Full list size: ${fullList.size}")
//println("Full list size: ${fullList.size}")
val paginatedList = PagingUtils.getPaginatedList(fullList, pageSize, pageNum)
println("Paginated list size: ${paginatedList.size}")
//println("Paginated list size: ${paginatedList.size}")
return RecordsRes(paginatedList, fullList.size)
} catch (e: Exception) {


+ 126
- 0
src/main/java/com/ffii/fpsms/modules/master/web/StockLedgerFixController.kt Vedi File

@@ -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")
}
}
}

+ 138
- 0
src/main/java/com/ffii/fpsms/modules/master/web/models/StockLedgerFixModels.kt Vedi File

@@ -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>,
)

+ 43
- 1
src/main/java/com/ffii/fpsms/modules/report/service/ReportService.kt Vedi File

@@ -641,12 +641,19 @@ return result
* Joins stock_in_line, stock_in, items, qc_result, inventory_lot, inventory_lot_line, warehouse, and shop tables.
* Supports comma-separated values for stockCategory (items.type) and itemCode.
* Date range [lastInDateStart, lastInDateEnd] filters on stock_in_line.productionDate (完成生產日期), same basis as 成品/半成品生產分析報告.
* Always limited to NOR stock-in (excludes TRF). PP/PF uses this line's PO code prefix, not lot-origin TRF walk.
*/
fun searchStockInTraceabilityReport(
stockCategory: String?,
itemCode: String?,
lastInDateStart: String?,
lastInDateEnd: String?
lastInDateEnd: String?,
storeId: String? = null,
warehouse: String? = null,
area: String? = null,
slot: String? = null,
lotNo: String? = null,
poPrefix: String? = null,
): List<Map<String, Any>> {
val args = mutableMapOf<String, Any>()
@@ -671,6 +678,34 @@ return result
"AND sil.productionDate IS NOT NULL AND DATE(sil.productionDate) <= DATE(:lastInDateEnd)"
} else ""

val storeIdSql = if (!storeId.isNullOrBlank() && storeId.trim() != "All") {
args["storeId"] = storeId.trim()
"AND REPLACE(COALESCE(wh.store_id, ''), '/', '') = REPLACE(:storeId, '/', '')"
} else {
""
}
val warehouseSql = if (!warehouse.isNullOrBlank() && warehouse.trim() != "All") {
buildMultiValueLikeClause(warehouse, "wh.warehouse", "warehousePart", args)
} else {
""
}
val areaSql = if (!area.isNullOrBlank() && area.trim() != "All") {
buildMultiValueLikeClause(area, "wh.area", "areaPart", args)
} else {
""
}
val slotSql = if (!slot.isNullOrBlank() && slot.trim() != "All") {
buildMultiValueLikeClause(slot, "wh.slot", "slotPart", args)
} else {
""
}
val lotNoSql = buildMultiValueLikeClause(lotNo, "COALESCE(sil.lotNo, il.lotNo)", "lotNo", args)
val poPrefixSql = when (poPrefix?.trim()?.uppercase()) {
"PP" -> "AND UPPER(LEFT(TRIM(COALESCE(po.code, '')), 2)) = 'PP'"
"PF" -> "AND UPPER(LEFT(TRIM(COALESCE(po.code, '')), 2)) = 'PF'"
else -> ""
}

val sql = """
SELECT
COALESCE(it.code, '') as itemNo,
@@ -728,10 +763,17 @@ return result
GROUP BY qr.stockInLineId
) qr_agg ON qr_agg.stockInLineId = sil.id
WHERE sil.deleted = false
AND UPPER(TRIM(COALESCE(sil.type, ''))) = 'NOR'
$stockCategorySql
$itemCodeSql
$lastInDateStartSql
$lastInDateEndSql
$storeIdSql
$warehouseSql
$areaSql
$slotSql
$lotNoSql
$poPrefixSql
ORDER BY it.code, sil.lotNo
""".trimIndent()



+ 372
- 0
src/main/java/com/ffii/fpsms/modules/report/service/StockLotBalanceReportService.kt Vedi File

@@ -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 ")})"
}
}

+ 492
- 0
src/main/java/com/ffii/fpsms/modules/report/service/StockLotLedgerReportService.kt Vedi File

@@ -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 ")})"
}
}

+ 52
- 4
src/main/java/com/ffii/fpsms/modules/report/web/ReportController.kt Vedi File

@@ -121,7 +121,13 @@ class ReportController(
@RequestParam(required = false) stockCategory: String?,
@RequestParam(required = false) itemCode: String?,
@RequestParam(required = false) lastInDateStart: String?,
@RequestParam(required = false) lastInDateEnd: String?
@RequestParam(required = false) lastInDateEnd: String?,
@RequestParam(required = false) storeId: String?,
@RequestParam(required = false) warehouse: String?,
@RequestParam(required = false) area: String?,
@RequestParam(required = false) slot: String?,
@RequestParam(required = false) lotNo: String?,
@RequestParam(required = false) poPrefix: String?,
): ResponseEntity<ByteArray> {
val parameters = mutableMapOf<String, Any>()
@@ -137,7 +143,13 @@ class ReportController(
stockCategory,
itemCode,
lastInDateStart,
lastInDateEnd
lastInDateEnd,
storeId,
warehouse,
area,
slot,
lotNo,
poPrefix,
)

val pdfBytes = reportService.createPdfResponse(
@@ -161,18 +173,37 @@ class ReportController(
@RequestParam(required = false) itemCode: String?,
@RequestParam(required = false) lastInDateStart: String?,
@RequestParam(required = false) lastInDateEnd: String?,
@RequestParam(required = false) storeId: String?,
@RequestParam(required = false) warehouse: String?,
@RequestParam(required = false) area: String?,
@RequestParam(required = false) slot: String?,
@RequestParam(required = false) lotNo: String?,
@RequestParam(required = false) poPrefix: String?,
): ResponseEntity<ByteArray> {
val dbData = reportService.searchStockInTraceabilityReport(
stockCategory,
itemCode,
lastInDateStart,
lastInDateEnd,
storeId,
warehouse,
area,
slot,
lotNo,
poPrefix,
)

val excelBytes = createStockInTraceabilityExcel(
dbData = dbData,
lastInDateStart = lastInDateStart ?: "",
lastInDateEnd = lastInDateEnd ?: "",
itemCode = itemCode,
storeId = storeId,
warehouse = warehouse,
area = area,
slot = slot,
lotNo = lotNo,
poPrefix = poPrefix,
)

val headers = HttpHeaders().apply {
@@ -563,6 +594,13 @@ class ReportController(
dbData: List<Map<String, Any>>,
lastInDateStart: String,
lastInDateEnd: String,
itemCode: String? = null,
storeId: String? = null,
warehouse: String? = null,
area: String? = null,
slot: String? = null,
lotNo: String? = null,
poPrefix: String? = null,
): ByteArray {
val workbook = XSSFWorkbook()
val styles = createCommonWorkbookStyles(workbook)
@@ -598,8 +636,18 @@ class ReportController(
}
sheet.addMergedRegion(CellRangeAddress(1, 1, 0, 5))
subtitleRow.createCell(6).apply {
val cap = (lastInDateStart.trim().ifBlank { "" }) + " 至 " + (lastInDateEnd.trim().ifBlank { "" })
setCellValue("最後入倉日期:${cap.trim()}")
val dateCap = (lastInDateStart.trim().ifBlank { "" }) + " 至 " + (lastInDateEnd.trim().ifBlank { "" })
val filterBits = listOfNotNull(
itemCode?.takeIf { it.isNotBlank() }?.let { "貨品:$it" },
storeId?.takeIf { it.isNotBlank() && it != "All" }?.let { "樓層:$it" },
warehouse?.takeIf { it.isNotBlank() && it != "All" }?.let { "倉庫:$it" },
area?.takeIf { it.isNotBlank() && it != "All" }?.let { "區域:$it" },
slot?.takeIf { it.isNotBlank() && it != "All" }?.let { "儲位:$it" },
lotNo?.takeIf { it.isNotBlank() }?.let { "批號:$it" },
poPrefix?.takeIf { it.isNotBlank() && it != "All" }?.let { "PP/PF:$it" },
).joinToString(" ")
val extra = if (filterBits.isNotBlank()) " $filterBits" else ""
setCellValue("最後入倉日期:${dateCap.trim()}$extra")
cellStyle = styles.subtitle
}
sheet.addMergedRegion(CellRangeAddress(1, 1, 6, totalColumns - 1))


+ 330
- 0
src/main/java/com/ffii/fpsms/modules/report/web/StockLotBalanceReportController.kt Vedi File

@@ -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()
}
}
}

+ 326
- 0
src/main/java/com/ffii/fpsms/modules/report/web/StockLotLedgerReportController.kt Vedi File

@@ -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()
}
}
}

+ 260
- 0
src/main/java/com/ffii/fpsms/modules/stock/service/StockLotDayCloseService.kt Vedi File

@@ -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()
}
}

+ 3
- 0
src/main/resources/application-prod.yml Vedi File

@@ -40,6 +40,9 @@ scheduler:
syncOffsetDays: 10 # from (today − 10) 00:00 to now, rows missing grn_code
inventoryLotExpiry:
enabled: true
stockLotDayClose:
enabled: true
catchUpDays: 7
# One-time DO1 catch-ups (only registered when scheduler.m18Sync.enabled=true — production profile).
# skipExistingDo defaults true for catch-up: already-synced/picked DOs are not overwritten.
do1CatchUp:


+ 4
- 0
src/main/resources/application.yml Vedi File

@@ -33,6 +33,10 @@ scheduler:
syncOffsetDays: 0
inventoryLotExpiry:
enabled: true
# Daily stock_lot_day close (yesterday) at 00:15 — moves + carry; catchUpDays caps missed days.
stockLotDayClose:
enabled: true
catchUpDays: 7
# Job order: at 00:00:15 daily, process JOs whose planStart was yesterday (hide or reschedule).
jo:
planStart:


+ 7
- 0
src/main/resources/db/changelog/changes/20260812_stock_lot_balance_perf/01_ledger_lot_date_index.sql Vedi File

@@ -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`);

+ 30
- 0
src/main/resources/db/changelog/changes/20260814_stock_lot_day/01_create_stock_lot_day.sql Vedi File

@@ -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`)
);

+ 10
- 0
src/main/resources/db/changelog/changes/20260821_stock_lot_day_close/01_insert_schedule_setting.sql Vedi File

@@ -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'
);

+ 8
- 0
src/main/resources/db/changelog/changes/20260824_stock_ledger_fix_perf/01_add_sl_date_deleted_item_uom_index.sql Vedi File

@@ -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`);


+ 87
- 0
src/main/resources/db/scripts/20260814_inventory_1_0.sql Vedi File

@@ -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;

Caricamento…
Annulla
Salva