diff --git a/.cursor/commands/gen-commit-test-plans.md b/.cursor/commands/gen-commit-test-plans.md new file mode 100644 index 0000000..2481fc6 --- /dev/null +++ b/.cursor/commands/gen-commit-test-plans.md @@ -0,0 +1,11 @@ +# Gen commit test plans + +Generate Word test plans for recent commits (default last 10). + +1. Run from FPSMS-backend repo root: + `python scripts/generate_commit_test_plans_docx.py HEAD~10..HEAD` +2. If the user named a range or SHA, use that instead (`HEAD~1..HEAD` for latest only). +3. Reply with the path `docs/deploy/commit-plans/` and summarize `_index.md`. +4. For the newest commit, briefly confirm areas covered; offer to refine from diff if needed. + +See `docs/deploy/HOW_TO_GENERATE_COMMIT_TEST_PLANS.md`. diff --git a/.cursor/rules/deploy-test-plan.mdc b/.cursor/rules/deploy-test-plan.mdc new file mode 100644 index 0000000..b21b478 --- /dev/null +++ b/.cursor/rules/deploy-test-plan.mdc @@ -0,0 +1,48 @@ +--- +description: Require deploy/release notes with test plan and expected results for commits and PRs +alwaysApply: true +--- + +# Deploy notes: what changed + how to test + +When the user asks to **commit**, **open a PR**, **prepare a deploy**, or **summarize changes for the team**, also produce a short **Deploy / QA note** (Markdown) with test steps and expected results. + +## Required sections + +```markdown +## Summary +- (1–3 bullets: what changed and why; user-facing impact) + +## Scope +- Backend: … +- Frontend: … (if any) +- DB / Liquibase: … (none if N/A) +- Config / ops: … (none if N/A) + +## Commits +- `abc1234` — short title + +## Test plan +| # | Steps (who / where / data) | Expected result | +|---|----------------------------|-----------------| +| 1 | … | … | +| 2 | … | … | + +## Out of scope / not tested +- … + +## Rollback +- How to revert or disable if broken (branch, flag, or previous build) +``` + +## Rules + +- Prefer **concrete UI labels** in 「」 and real routes (e.g. `/doworkbench`) when UI is involved. +- Prefer **concrete sample data** when known (item codes, dates, shop codes) from local DB or the change itself. +- Cover **happy path + one failure / edge** when the change adds validation or a fix. +- If only backend API changed, still give **how to verify** (API call, UI screen that uses it, or SQL check). +- Do **not** invent test steps for files you did not inspect; if unclear, say what to confirm with the author. +- Save durable notes under `docs/deploy/` when the user asks to keep them (filename: `YYYYMMDD_short-topic.md`). +- To auto-generate **one Word file per commit** into a folder, run: + `python scripts/generate_commit_test_plans_docx.py ` → `docs/deploy/commit-plans/`. +- Commit messages should still be clear; “no message” is not acceptable when we author the commit. diff --git a/.cursor/rules/gen-commit-test-plan.mdc b/.cursor/rules/gen-commit-test-plan.mdc new file mode 100644 index 0000000..c680e06 --- /dev/null +++ b/.cursor/rules/gen-commit-test-plan.mdc @@ -0,0 +1,28 @@ +--- +description: When user asks to gen test plan(s) for new/recent commit(s), auto-run the Word generator +alwaysApply: true +--- + +# Gen commit test plans (simple ask) + +If the user says anything like: + +- 「gen test plan」 / 「generate test plan」 +- 「new commit test plan」 / 「test plan for commit」 +- 「產測試計畫」 / 「產生 commit 測試計畫」 +- 「有新 commit,幫我出 test plan」 + +Then **do this immediately** (do not only explain): + +1. Default range: **new / recent commits** = `HEAD~10..HEAD` + - If user gives a SHA or `A..B`, use that. + - If they say “latest / this commit”, use `HEAD~1..HEAD`. + - If they say “since last deploy” and give a SHA, use `..HEAD`. +2. Run from repo root: + ```bash + python scripts/generate_commit_test_plans_docx.py + ``` +3. Tell them the output folder: `docs/deploy/commit-plans/` and point to `_index.md`. +4. Optionally refine the **latest** commit’s plan from the real diff if steps look too generic. + +Team how-to: `docs/deploy/HOW_TO_GENERATE_COMMIT_TEST_PLANS.md` diff --git a/.cursor/skills/fp-mtms-version-checklist/SKILL.md b/.cursor/skills/fp-mtms-version-checklist/SKILL.md index 44f1d72..d93901c 100644 --- a/.cursor/skills/fp-mtms-version-checklist/SKILL.md +++ b/.cursor/skills/fp-mtms-version-checklist/SKILL.md @@ -2,9 +2,10 @@ name: fp-mtms-version-checklist description: >- Updates the FP-MTMS Version Programs and Functions Checklist Excel on each - developer's synced SharePoint folder. Use only when the user explicitly - invokes this skill or asks to update the FP-MTMS version control checklist, - page/program version sheet, or function version sheet. + developer's synced SharePoint folder. Overflow beyond Latest+3 previous + versions is appended to the Archive long-table Excel. Use only when the user + explicitly invokes this skill or asks to update the FP-MTMS version control + checklist, page/program version sheet, function version sheet, or archive. disable-model-invocation: true --- @@ -26,8 +27,8 @@ All written cell values must be **Traditional Chinese + English** **Exception — do NOT bilingualize:** -- `Functions in Page&Program` column **F — Name of Function within - Program** (English symbol + source path only; see Column F rule) +- `Functions in Page&Program` column **G — Name of Function within + Program** (English symbol + source path only; see Column G rule) - Version & Date fields (e.g. `v1.0.0 2026-07-14`) - Developed By (person name as-is) - Ref. No. (number) @@ -52,12 +53,31 @@ Job Order Pick Execution page (/jodetail). Used / called from: Item Tracing (doc deep-link to open completed pick record). ``` -**Major Changes** (Page col F / history I,L,O and Functions col I / history -L,O,R) must also list **file + line range + short per-line explanation** — +**Major Changes** (Page col F / history I,L,O and Functions col J / history +M,P,S) must also list **file + line range + short per-line explanation** — see **Major Changes: file, lines, explanation** below. Prefer EN/ZH pairs from `src/i18n/en/*` and `src/i18n/zh/*` for menu and -feature terms. Enable Excel wrap text when a cell has multiple lines. +feature terms. + +## Excel formatting (required on every write) + +When writing with `openpyxl`, apply formatting so cells stay readable: + +1. **Wrap text** — enable wrap on every multi-line cell you write (Purpose, + Major Changes, Function name with multiple paths, Highlights). Prefer + `Alignment(wrap_text=True, vertical="top")`. +2. **Line breaks** — use real Excel newlines (`\n`) between ZH/EN pairs and + between file / line bullets; do not stuff everything onto one long line. +3. **Row height** — after writing multi-line cells, set a reasonable row + height (or leave Excel auto-fit) so wrapped text is visible; do not leave + multi-line content in a single-line-tall row if the sheet already uses + taller history rows. +4. **Do not break headers** — never rename, reorder, or delete existing header + labels; only fill data cells (and Archive append columns as defined). +5. **Match existing style** — when updating a row, keep the same wrap/alignment + pattern as neighbouring filled cells on that sheet. +6. **Archive** — same wrap rules as main; no extra “Source” or helper columns. ## Excel path (per developer) @@ -72,10 +92,18 @@ Path varies by Windows user / OneDrive sync root. Resolve in this order: `FP-MTMS Version Programs and Functions Checklist v0.1.xlsx` (real `.xlsx` only). 4. **Always confirm the resolved path with the user before writing.** -Do not edit `.url` shortcuts or Downloads copies unless the user explicitly asks. +### Archive Excel path -Use `openpyxl` to read/write. If the file is locked (PermissionError), ask the -user to close Excel / Excel Online sync lock, then retry. +Same folder as the main checklist. Default: + +`{USERPROFILE}/2Fi Business Solutions Limited/2Fi Business Solutions Limited - FP-MTMS VERSION CONTROL/FP-MTMS Version Programs and Functions Checklist v0.1 - Archive.xlsx` + +If the user provides an archive path, use it. Confirm both main and archive +paths before writing. Do not edit `.url` shortcuts or Downloads copies unless +the user explicitly asks. + +Use `openpyxl` to read/write. If either file is locked (PermissionError), ask +the user to close Excel / Excel Online sync lock, then retry. ## Hard rule: preview first — do not interview column by column @@ -86,7 +114,7 @@ Instead: 1. Infer all planned adds/updates from conversation context, git diff, PR, feature description, and existing Excel rows. 2. Before any write, show a **full preview** of every row that will be added or - changed (including history shifts). + changed (including history shifts and any Archive long-table appends). 3. Ask **one** confirmation: whether this preview is correct (yes / no / what to fix). Prefer the Ask Questions tool when available; otherwise ask once in chat. @@ -109,6 +137,7 @@ Before drafting or writing any row: 2. Match on existing rows when the program/page already exists. 3. If updating a function: the parent page/program should already exist on `Page&Program Name` (or be included in the same preview as a new page row). + Set **Page Ref. No. (B)** to that page row’s Ref. No. 4. If nothing matches and the user intends a **new** program or function, say so clearly in the preview as `NEW ROW`. 5. Do **not** invent duplicate rows for the same page + same function. @@ -126,10 +155,12 @@ rightward** before writing the new Latest: | Latest | → 1st previous | | 1st previous | → 2nd previous | | 2nd previous | → 3rd previous | -| 3rd previous | → **deleted** (dropped) | +| 3rd previous | → **Archive Excel** (append long-table row; then clear 3rd on main) | Then write the new change into **Latest** (version/date, highlights, Developed By). +If the old 3rd previous slot is **empty**, skip archive append for that shift. + ### Page&Program Name — fields that shift together Treat each “slot” as a triple: @@ -139,35 +170,113 @@ Treat each “slot” as a triple: - **2nd**: K, L, M - **3rd**: N, O, P -Shift: `(D,F,G) → (H,I,J) → (K,L,M) → (N,O,P)` then drop old 3rd; write new into D/F/G. +Shift: `(D,F,G) → (H,I,J) → (K,L,M) → (N,O,P)` then **archive** old 3rd +(N/O/P) to the Archive Excel long table if filled; clear N/O/P on main; write +new into D/F/G. Leave identity columns A–C and purpose E unchanged unless the user asked to change them (purpose may be refreshed if the preview says so). ### Functions in Page&Program — fields that shift together -- **Latest**: H (Function Version & Date), I (Major Changes), J (Developed By) -- **1st**: K, L, M -- **2nd**: N, O, P -- **3rd**: Q, R, S +- **Latest**: I (Function Version & Date), J (Major Changes), K (Developed By) +- **1st**: L, M, N +- **2nd**: O, P, Q +- **3rd**: R, S, T + +Shift: `(I,J,K) → (L,M,N) → (O,P,Q) → (R,S,T)` then **archive** old 3rd +(R/S/T) to the Archive Excel long table if filled; clear R/S/T on main; write +new into I/J/K. +Leave A–H (Ref, **Page Ref. No.**, subsystem, page, page version/purpose, +function name/highlight) unchanged unless the preview explicitly updates them. + +Always show the shift **and any archive appends** in the preview (old Latest → +new 1st, old 3rd → Archive, etc.). + +#### Column B — Page Ref. No. (required) + +**Page Ref. No. (B)** on every Functions row must equal the parent row’s +**Ref. No. (A)** on sheet `Page&Program Name`. + +Rules: + +1. Resolve the parent by matching **System Page / Program Name** (and + subsystem when needed) on the Page sheet; use that page row’s A. +2. Never leave B blank on a Functions data row. +3. Do **not** invent a Page Ref that has no matching Page sheet row (unless + that page row is included in the same approved preview as NEW). +4. Multiple function rows under the same page share the **same** Page Ref. No. +5. Function **Ref. No. (A)** stays independent (one per 功能 row). +6. Preview must show `Page Ref. No. = → Page sheet Ref ()`. + +Page sheet columns A–P are unchanged by this field (Page sheet has no +Page Ref column). + +## Archive Excel — long table (required on 3rd overflow) + +When a filled **3rd previous** is pushed off the main checklist, **append one +row** to the Archive workbook (never overwrite existing archive rows). + +Data starts at **row 5** (rows 1–3 title/notes, row 4 headers). + +### Sheet: `Page&Program Name` (archive) + +| Col | Header | +|-----|--------| +| A | Ref. No. | +| B | Name of Subsystem / Module / Menu Selection | +| C | System Page / Program Name | +| D | Version & Date | +| E | Major Changes Highlights | +| F | Developed By | +| G | Archived At | + +Map from main page row 3rd slot: A←A, B←B, C←C, D←N, E←O, F←P. +`Archived At` = today's date (`YYYY-MM-DD`). +Do **not** add a Source column. -Shift: `(H,I,J) → (K,L,M) → (N,O,P) → (Q,R,S)` then drop old 3rd; write new into H/I/J. -Leave A–G (Ref, subsystem, page, page version/purpose, function name/highlight) -unchanged unless the preview explicitly updates them. +### Sheet: `Functions in Page&Program` (archive) -Always show the shift in the preview (old Latest → new 1st, etc.). +| Col | Header | +|-----|--------| +| A | Ref. No. | +| B | Page Ref. No. | +| C | Name of Subsystem / Module / Menu Selection | +| D | System Page / Program Name | +| E | Name of Function within Program | +| F | Version & Date | +| G | Major Changes Highlights | +| H | Developed By | +| I | Archived At | + +Map from main functions row 3rd slot: +A←A, B←B (Page Ref. No.), C←C, D←D, E←G, F←R, G←S, H←T; +`Archived At` (I) = today. +Always copy **Page Ref. No.** so archive rows stay linked to the parent page. +Do **not** add a Source column. + +### Archive write rules + +1. **Append only** — find the next empty data row (after last non-empty row ≥ 5). +2. Keep **中英對照** and Major Changes content exactly as they were on main 3rd. +3. Do **not** invent archive rows for empty 3rd slots. +4. Do **not** edit older archive rows. +5. Write archive **before or in the same approved batch as** the main shift; if + archive save fails (lock), do not leave main half-shifted — abort and ask the + user to close the file, then retry the whole approved batch. +6. Never commit either Excel file to git. ### Function history only when that function changed (required) **Do not** push or invent a Functions-sheet history slot for a commit / release -that did **not** change the symbols listed in column F for that row. +that did **not** change the symbols listed in column G for that row. - **Page&Program Name** may still advance Latest / history for a page-level release (e.g. backend-only fix under the same page). -- **Functions in Page&Program**: shift H→K→N→Q **only** when this row’s - function(s) actually changed (diff touches the verified F symbol(s) / paths). +- **Functions in Page&Program**: shift I→L→O→R **only** when this row’s + function(s) actually changed (diff touches the verified G symbol(s) / paths). - If a commit updates the **page** but not this function: - - You may refresh column **D** (Latest Page Version) to match the page row. - - Leave **H/I/J** and previous function history **unchanged**. + - You may refresh column **E** (Latest Page Version) to match the page row. + - Leave **I/J/K** and previous function history **unchanged**. - Do **not** write filler Latest/history text such as「頁面版本對齊;本功能無程式變更」 / “Page version align; no UI change this release”. - When mapping several commits into history for a **new** function row: include @@ -176,13 +285,13 @@ that did **not** change the symbols listed in column F for that row. versions and history depths. Example: page Latest = v1.0.3 (backend TRF fix). Frontend transfer UI row last -changed at v1.0.2 → keep function H = v1.0.2; optional D = v1.0.3; no fake +changed at v1.0.2 → keep function I = v1.0.2; optional E = v1.0.3; no fake v1.0.3 function history slot. ### Major Changes: file, lines, explanation (required) For every **Major Changes Highlights** cell written into Excel (Page sheet -Latest F and history I/L/O; Functions sheet Latest I and history L/O/R), +Latest F and history I/L/O; Functions sheet Latest J and history M/P/S), include concrete code locations from the commit / diff — not only a summary sentence. @@ -219,10 +328,10 @@ StockInLineService.kt - Prefer the member / symbol name in the explanation when helpful (e.g. `createStockOutBatch`, `assignLotNo`). - If many files: keep bullets short; still list each touched path that belongs - to this row (Functions row → only files/symbols in column F, plus closely + to this row (Functions row → only files/symbols in column G, plus closely related hunks in those files for this change). - Page-sheet Major Changes may summarize all files for that page release; - Functions-sheet Major Changes stay scoped to that function row’s F symbols + Functions-sheet Major Changes stay scoped to that function row’s G symbols and their files. - Show the same file/line bullets in the **preview** before write. - Pure i18n-only or one-liner UI copy changes: still cite file + line(s) @@ -266,31 +375,41 @@ Data starts at **row 5** (rows 1–4 are title/headers). | Col | Header | |-----|--------| | A | Ref. No. | -| B | Name of Subsystem / Module / Menu Selection | -| C | System Page / Program Name | -| D | Latest Page Version & Date | -| E | Page Purpose Highlights | -| F | Name of Function within Program | -| G | Functions Highlight | -| H | Latest Function Version & Date | -| I | Major Changes Highlights | -| J | Developed By | -| K | 1st Previous Page Version & Date | -| L | 1st Major Changes Highlights | -| M | Developed By | -| N | 2nd Previous Page Version & Date | -| O | 2nd Major Changes Highlights | -| P | Developed By | -| Q | 3rd Previous Page Version & Date | -| R | 3rd Major Changes Highlights | -| S | Developed By | - -#### Column F — English symbol + source file (required) - -**Name of Function within Program (F)** must use the real English +| B | Page Ref. No. | +| C | Name of Subsystem / Module / Menu Selection | +| D | System Page / Program Name | +| E | Latest Page Version & Date | +| F | Page Purpose Highlights | +| G | Name of Function within Program | +| H | Functions Highlight | +| I | Latest Function Version & Date | +| J | Major Changes Highlights | +| K | Developed By | +| L | 1st Previous Page Version & Date | +| M | 1st Major Changes Highlights | +| N | Developed By | +| O | 2nd Previous Page Version & Date | +| P | 2nd Major Changes Highlights | +| Q | Developed By | +| R | 3rd Previous Page Version & Date | +| S | 3rd Major Changes Highlights | +| T | Developed By | + +(Main workbook may also show helper note columns such as U `Archive`; do +**not** require filling them unless the user asks. Page Ref. No. is the +required parent link.) + +#### Column B — Page Ref. No. (see also history section) + +Must point at the parent `Page&Program Name` row’s **Ref. No. (A)**. Required +on every Functions data row (main and archive). + +#### Column G — English symbol + source file (required) + +**Name of Function within Program (G)** must use the real English identifier from code (component / class / function / endpoint name), **and** state which file(s) it comes from. Do **not** put only a -localized UI label in F. +localized UI label in G. Format: @@ -313,7 +432,7 @@ ItemTracingLocations — src/components/ItemTracing/ItemTracingLocations.tsx ##### Service / controller / class files — also list member functions When the primary symbol is a **service, controller, or other class** -(not a React page component), F must also name the **member function(s)** +(not a React page component), G must also name the **member function(s)** that were added or changed — not only the class name. Put **each class + its methods + its path on its own line** (Excel `\n`, @@ -348,13 +467,13 @@ Fact-check each listed member function exists in that file (same casing). If only one method changed, list that one method. Do not cite a class without its relevant `fun` / method names when the change is in a `*Service` / `*Controller` (or similar) file. -**Functions Highlight (G)** and **Major Changes (I)** must be 中英對照 +**Functions Highlight (H)** and **Major Changes (J)** must be 中英對照 (Chinese then English on the next line). Prefer i18n terms. Page / -subsystem names (B/C) use `中文 / English`. +subsystem names (C/D) use `中文 / English`. -#### Column E — Page Purpose + where the function is used (required) +#### Column F — Page Purpose + where the function is used (required) -On **Functions in Page&Program**, **Page Purpose Highlights (E)** must +On **Functions in Page&Program**, **Page Purpose Highlights (F)** must include: 1. The owning page purpose (what page C is for), **and** @@ -409,11 +528,11 @@ When inferring usage, fact-check callers (imports, links, API consumers) from the change/diff. Do not invent a caller. If usage is only the owning page, say `本頁()`. If multiple callers, list them (i18n names). -Do **not** leave E as only the generic page blurb when the function was +Do **not** leave F as only the generic page blurb when the function was added/changed for another page’s flow (e.g. refs that support 批號追溯 but sit under 工單提料/提料單/成品出倉). -#### Fact-check column F before preview (required) +#### Fact-check column G before preview (required) Never invent a symbol or path. For **every** Functions-sheet row in the preview (new or updated), verify against the real repos: @@ -441,10 +560,10 @@ preview (new or updated), verify against the real repos: 4. **Wrong location** — if the symbol lives elsewhere, correct the path to the real file; do not keep a convenient-but-false path. 5. **Not found** — if the file or symbol cannot be verified, do **not** put - it in F. Omit the row or ask the user; never write an unverified F. + it in G. Omit the row or ask the user; never write an unverified G. In the preview, mark verified rows (optional short note), e.g. -`F verified: symbol+path OK`. If any F failed fact-check, fix before asking +`G verified: symbol+path OK`. If any G failed fact-check, fix before asking for confirmation. ## Preview format (required before save) @@ -452,46 +571,59 @@ for confirmation. Show a clear preview, for example: ```text -Excel: +Excel: +Archive: Sheet: Functions in Page&Program -Row 12 (UPDATE existing | Ref 7 | Page: 批號追溯 | Function: ItemTracingFlowGraphSearch — src/components/ItemTracing/ItemTracingFlowGraphSearch.tsx) +Row 12 (UPDATE existing | Ref 7 | Page Ref 3 → 批號追溯 / Item Tracing | Function: ItemTracingFlowGraphSearch — src/components/ItemTracing/ItemTracingFlowGraphSearch.tsx) History shift: - old Latest (H/I/J) → 1st + old Latest (I/J/K) → 1st old 1st → 2nd old 2nd → 3rd - old 3rd → DROP + old 3rd → ARCHIVE (append) + Archive append (Functions in Page&Program): + A: 7 + B: 3 + C: 倉庫管理 / Store Management + D: 批號追溯 / Item Tracing + E: ItemTracingFlowGraphSearch — src/components/ItemTracing/ItemTracingFlowGraphSearch.tsx + F: + G: + H: + I: 2026-07-16 New Latest: - H: v1.2.0 2026-07-14 - I: 新增流程圖節點搜尋。 + I: v1.2.0 2026-07-14 + J: 新增流程圖節點搜尋。 Add flow-graph node search. ItemTracingFlowGraphSearch.tsx L42–88: 搜尋框與節點高亮。 L42–88: search box and node highlight. - J: + K: -Row NEW (append | next Ref 15 | Page: 批號追溯 / Item Tracing | Function: ItemTracingScanBar — src/components/ItemTracing/ItemTracingScanBar.tsx) +Row NEW (append | next Ref 15 | Page Ref 3 → 批號追溯 / Item Tracing | Function: ItemTracingScanBar — src/components/ItemTracing/ItemTracingScanBar.tsx) A: 15 - B: 倉庫管理 / Store Management - C: 批號追溯 / Item Tracing - E: …頁面目的(中文)… + B: 3 + C: 倉庫管理 / Store Management + D: 批號追溯 / Item Tracing + F: …頁面目的(中文)… …page purpose (English)… 使用/呼叫來源:本頁(批號追溯 / Item Tracing)。 Used / called from: this page (批號追溯 / Item Tracing). - F: ItemTracingScanBar — src/components/ItemTracing/ItemTracingScanBar.tsx - G: 相機掃碼/手動查詢 + G: ItemTracingScanBar — src/components/ItemTracing/ItemTracingScanBar.tsx + H: 相機掃碼/手動查詢 Camera scan / manual search ... - H/I/J: + I/J/K: history slots: empty -Row NEW (… | Page: 工單提料 / Job Order Pick Execution | Function: CompleteJobOrderRecord — …) - C: 工單提料 / Job Order Pick Execution - E: 工單提料執行頁面(/jodetail)。 +Row NEW (… | Page Ref → 工單提料 / Job Order Pick Execution | Function: CompleteJobOrderRecord — …) + B: + D: 工單提料 / Job Order Pick Execution + F: 工單提料執行頁面(/jodetail)。 Job Order Pick Execution page (/jodetail). 使用/呼叫來源:批號追溯(單據深連結)。 Used / called from: Item Tracing (doc deep-link). - F: CompleteJobOrderRecord — src/components/Jodetail/completeJobOrderRecord.tsx + G: CompleteJobOrderRecord — src/components/Jodetail/completeJobOrderRecord.tsx JodetailSearch — src/components/Jodetail/JodetailSearch.tsx … @@ -502,23 +634,27 @@ For page-sheet rows, use the same style with D/F/G and H–P history. ## Workflow -1. Confirm Excel path exists (and is writable when saving). +1. Confirm **main and archive** Excel paths exist (and are writable when saving). 2. Infer target sheets and rows from the user’s change description / diff. 3. **Pre-check** existing program/page/function rows in Excel. 4. For each Functions row: **fact-check** English symbol + every source path - against the codebase (see Fact-check column F). Correct or drop failures. -5. For each Functions row: set **E** with page purpose **and** usage/呼叫來源 - (owning page vs caller page; see Column E rule). + against the codebase (see Fact-check column G). Correct or drop failures. + Also set / verify **Page Ref. No. (B)** against the parent Page sheet Ref. +5. For each Functions row: set **F** with page purpose **and** usage/呼叫來源 + (owning page vs caller page; see Column F rule). 6. For each existing match: plan history shift + new Latest values - (**only** shift function history when that function’s F symbols changed). + (**only** shift function history when that function’s G symbols changed). + If old 3rd is filled, plan an **Archive long-table append**. 7. For each Major Changes cell (page + functions, Latest and any filled history slots): resolve **file path + line range(s) + short per-hunk explanation** from git diff / current sources (see Major Changes: file, lines, explanation). 8. For each new function/page: plan a new Ref. No. row (next integer after max used). -9. Present the **full preview** (verified F + file/line Major Changes) → ask once - if correct. -10. On approval: apply shifts + writes with `openpyxl`, save, report sheet names, - row numbers, Ref. Nos., and key Latest fields. +9. Present the **full preview** (verified G + Page Ref + file/line Major Changes + + archive appends) → ask once if correct. +10. On approval: with `openpyxl`, **append archive rows first** (if any), then + apply main shifts + writes; apply **Excel formatting** (wrap text / + newlines); save both workbooks; report sheet names, row numbers, + Ref. Nos., Page Ref. Nos., archive rows appended, and key Latest fields. 11. **Code comments** — for every Functions-sheet symbol that was added or updated, add or refresh the FP-MTMS checklist comment on that component / class member (see Code comments rule). Include this in the @@ -529,19 +665,19 @@ For page-sheet rows, use the same style with D/F/G and H–P history. After the user approves the checklist preview (or as part of the same approved batch), **add or update a source comment** on every function / -component listed in column F for each affected Functions-sheet row. +component listed in column G for each affected Functions-sheet row. ### Comment contents (required fields) Must include: 1. **Ref. No.** — Functions sheet Ref. No. for that row -2. **Version** — Latest Function Version (column H), e.g. `v1.0.0` -3. **Update date** — the date from column H, e.g. `2026-07-14` +2. **Version** — Latest Function Version (column I), e.g. `v1.0.0` +3. **Update date** — the date from column I, e.g. `2026-07-14` ### Canonical format -Parse column H `vX.Y.Z YYYY-MM-DD` into version + date. +Parse column I `vX.Y.Z YYYY-MM-DD` into version + date. **TypeScript / TSX** (JSDoc immediately above the component / export / function): @@ -572,10 +708,10 @@ checklist line as the first line of the block: ### Placement rules - **React component**: above the primary `const ComponentName` / `export - default` that matches column F. + default` that matches column G. - **Service / controller**: above **each** listed member function (e.g. both `trace` and `traceLocation`). -- Multi-line F (one symbol per line): comment **each** symbol in its file. +- Multi-line G (one symbol per line): comment **each** symbol in its file. - On later checklist updates to the same row: **replace** the old Ref/version/date in the comment with the new Latest values (do not stack duplicate checklist lines). @@ -599,14 +735,15 @@ the user explicitly asks to sync comments only. - **Developed By**: `git config user.name` or chat context. - **Purpose / Changes / Function highlight**: 中英對照; short bullets from recent PR/diff; prefer i18n terms. -- **Major Changes (Page F / Functions I and history slots)**: summary ZH/EN +- **Major Changes (Page F / Functions J and history slots)**: summary ZH/EN **plus** each changed file, line range(s), and short per-hunk explanation (see Major Changes: file, lines, explanation). Resolve lines from git diff + current file; do not omit locations. - **Subsystem / Page names**: `中文 / English` from i18n EN+ZH. -- **Function name (col F)**: English code symbol + source file path only +- **Function name (col G)**: English code symbol + source file path only (no 中英對照). **Fact-check** symbol+path before preview. -- **Page Purpose (E)**: 中英對照 + usage/呼叫來源 lines (ZH then EN). +- **Page Purpose (F)**: 中英對照 + usage/呼叫來源 lines (ZH then EN). +- **Page Ref. No. (B)**: parent Page sheet Ref. No. for column D’s page. - **Ref. No.**: for new rows, next integer after the last used Ref. No. on that sheet. - Prefer updating an existing row over creating a duplicate when page + English function symbol (and file) match. @@ -616,27 +753,32 @@ the user explicitly asks to sync comments only. - Ask the user to fill cells one column at a time - Save without an approved preview - Skip history shift when updating an existing Latest -- Push or invent **function** history (H–S) for a commit that did not change - that row’s column F symbols — no “page align / no code change” filler slots +- Drop a filled **3rd previous** without appending it to the Archive long table +- Add or fill an Archive **Source** column (removed; do not reintroduce) +- Overwrite or edit existing Archive rows (append only) +- Skip Excel formatting (wrap text / newlines) on multi-line cells you write +- Push or invent **function** history (I–T) for a commit that did not change + that row’s column G symbols — no “page align / no code change” filler slots - Write Major Changes as summary-only without **file path + line range(s) + - short per-hunk explanation** (Page F / Functions I and matching history cols) + short per-hunk explanation** (Page F / Functions J and matching history cols) - Write Chinese-only or English-only prose in bilingual-required columns - (everything except Function name F, versions, Developed By, Ref. No., and + (everything except Function name G, versions, Developed By, Ref. No., Page Ref. No., and the path/line tokens inside Major Changes) - Put only a Chinese UI label in **Name of Function within Program** without the English symbol and source file -- Bilingualize column F (keep symbol + path English-only) -- Omit usage/呼叫來源 from **Page Purpose Highlights (E)** when the function +- Bilingualize column G (keep symbol + path English-only) +- Omit usage/呼叫來源 from **Page Purpose Highlights (F)** when the function is invoked from another page (e.g. 批號追溯 deep-link into 工單提料) -- Cite a `*Service` / `*Controller` class in F without its relevant member +- Cite a `*Service` / `*Controller` class in G without its relevant member function names (e.g. write `ItemLotTraceService.trace / .traceLocation`, not only `ItemLotTraceService`) -- Write an F value whose symbol or path was not verified in the codebase +- Write a G value whose symbol or path was not verified in the codebase +- Leave **Page Ref. No. (B)** blank or pointing at a non-existent Page row - Guess file paths or rename symbols to “look right” without opening the file - Put two different 功能 on the same Ref. No. row - Create a function row when the parent program/page was neither found nor included in the same approved preview as a new page row -- Clear history columns without showing that drop in the preview +- Clear history columns without showing that shift/archive in the preview - Skip adding/updating FP-MTMS checklist comments on code for Functions rows that were just written (must include Ref. No., version, and update date) - Commit the Excel file to git diff --git a/AGENTS.md b/AGENTS.md index e675c03..6d4e6b3 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -10,3 +10,35 @@ - If a task is about UI behavior (charts, clicks, page rendering, dialogs), check `../FPSMS-frontend`. - If a task is about API/business logic/data query, check this backend repo. - For end-to-end changes, update both repos and keep API request/response fields aligned. + +## M18 ↔ MTMS data mapping + +- Handbook: [`docs/MTMS_M18_DATA_MAPPING.md`](docs/MTMS_M18_DATA_MAPPING.md) +- Auto tables: [`docs/generated/`](docs/generated/) — regenerate with `python scripts/generate_m18_mapping_docs.py` after changing `ItemType` / `M18ItemType` or product-type `when` in `M18MasterDataService`. +- Word / Excel (readable exports): `python scripts/export_m18_mapping_office.py` → [`docs/exports/`](docs/exports/) + +## User guides (UI-oriented) + +- Source Markdown: [`docs/user-guides/`](docs/user-guides/) +- Export Word: `python scripts/export_user_guide_office.py` → `docs/exports/*.docx` +- Guides (UI labels in 「」): + - 排程 → 工單 → `MTMS_Schedule_JobOrder_UserGuide.docx` + - BOM 匯入/啟停/應用 → `MTMS_BOM_UserGuide.docx` + - 工單提料/報工/上架 → `MTMS_JO_Pick_Production_PutAway_UserGuide.docx` + - 送貨訂單/放單/成品出倉 → `MTMS_DO_Shipping_UserGuide.docx` + +## Deploy / QA notes (what changed + how to test) + +- **How-to for the team:** [`docs/deploy/HOW_TO_GENERATE_COMMIT_TEST_PLANS.md`](docs/deploy/HOW_TO_GENERATE_COMMIT_TEST_PLANS.md) +- **In Cursor:** say `gen test plan` / `產測試計畫`, or slash **`/gen-commit-test-plans`** +- Template: [`docs/deploy/TEMPLATE.md`](docs/deploy/TEMPLATE.md) +- Filled notes: [`docs/deploy/`](docs/deploy/) (e.g. `YYYYMMDD_short-topic.md`) +- **Auto Word test plan per commit** (folder): + ```bash + pip install python-docx + python scripts/generate_commit_test_plans_docx.py HEAD~10..HEAD + ``` + → [`docs/deploy/commit-plans/`](docs/deploy/commit-plans/) (`YYYY-MM-DD__.docx` + `_index.md`) + Optional: `--also-md` / `--out-dir path` / `--limit N` +- Scaffold one range note: `python scripts/generate_deploy_test_plan.py HEAD~5..HEAD --out docs/deploy/draft.md` +- For critical deploys, ask the agent to refine steps against the real diff (Cursor rule: deploy-test-plan) diff --git a/docs/MTMS_M18_DATA_MAPPING.md b/docs/MTMS_M18_DATA_MAPPING.md new file mode 100644 index 0000000..6674bad --- /dev/null +++ b/docs/MTMS_M18_DATA_MAPPING.md @@ -0,0 +1,236 @@ +# MTMS (FPSMS) ↔ M18 資料對照手冊 + +本文件說明 **MTMS / FPSMS** 與 **M18** 之間的主檔與交易對應、同步方向,以及已知陷阱。 + +| 區塊 | 維護方式 | +|------|----------| +| 本手冊(說明、流程、陷阱) | **人手**維護 | +| [`docs/generated/`](./generated/) 對照表 | **腳本產生**(見下方) | + +重新產生自動表(改完 enum / mapping 後請跑): + +```bash +python scripts/generate_m18_mapping_docs.py +``` + +匯出 **Word / Excel**(給非技術閱讀;需已安裝 `python-docx`、`openpyxl`): + +```bash +pip install python-docx openpyxl +python scripts/export_m18_mapping_office.py +``` + +產出: + +- `docs/exports/MTMS_M18_DATA_MAPPING.docx` +- `docs/exports/MTMS_M18_DATA_MAPPING.xlsx` + +--- + +## 1. 系統與名詞 + +| 名稱 | 說明 | +|------|------| +| **MTMS / FPSMS** | 本後端 `FPSMS-backend` + 前端 `FPSMS-frontend` | +| **M18** | 外部 ERP/主檔與採購/送貨來源系統 | +| **Pull** | M18 → MTMS(product / vendor / unit / currency / BOM / business unit / PO / DO) | +| **Push** | MTMS → M18(例如 GRN、BOM for shop) | + +設定入口:`m18/M18Config.kt`(`m18.config.*`)、scheduler 見 `application.yml` / `application-prod.yml`(`scheduler.m18Sync`、`scheduler.m18Grn`)。 + +主程式目錄:`src/main/java/com/ffii/fpsms/m18/`。 + +--- + +## 2. Master API 類型(`StSearchType`) + +完整表見自動產生檔: + +→ **[generated/m18-stsearch-types.md](./generated/m18-stsearch-types.md)** + +摘要: + +| M18 `stSearch` | MTMS 落點 | +|----------------|-----------| +| `pro` | `items` | +| `ven` | `shop`(`type=supplier`) | +| `virDept` | `shop`(`type=shop`) | +| `unit` | `uom_conversion`(+ cunit) | +| `cur` | `currency` | +| `udfbomforshop` | `bom` / materials | + +實作:`M18MasterDataService`。 + +--- + +## 3. 貨品類型(最常查) + +### 3.1 同步規則(自動表) + +→ **[generated/m18-item-type-mapping.md](./generated/m18-item-type-mapping.md)** + +程式:`M18MasterDataService.saveProduct` / `saveProducts` 依 `pro.udfProducttype`: + +```text +Consumable Material → consumables +Non-consumable Material → non-consumables +Product → fg +WIP → sfg +Item → item +(其他,含 CMB) → mat ← default +``` + +Enum 定義:`modules/master/web/models/NewItemRequest.kt`(`ItemType`、`M18ItemType`)。 + +### 3.2 UI 顯示(存貨) + +存貨 Type 欄:`t(itemType)`,翻譯在 `FPSMS-frontend/src/i18n/zh/inventory.json`。 + +| `items.type` | 存貨頁(zh) | +|--------------|--------------| +| `mat` | 原料 | +| `fg` | 成品 | +| `sfg` / `wip` | 半成品 | +| `consumables` / `cmb` | 消耗品 | +| `non-consumables` / `nm` | 非消耗品/雜項 | + +> 系統 **沒有**「產品」這個 `items.type`。M18 的 **Product** 對應 MTMS **`fg`(成品)**。 + +### 3.3 可手動改嗎? + +可以:Settings → Items → Edit → Type(`ProductDetails.tsx`:`fg` / `wip` / `mat` / `cmb` / `nm`)。 + +注意:之後若再跑 **product sync**,type 會依 M18 `udfProducttype` **覆寫**(含再次落到 `mat`)。 + +### 3.4 已知陷阱:`CMB` + +M18 實務上可出現 `"udfProducttype": "CMB"`(例如蔗糖水 `MG1852`)。 + +- `"CMB"` ≠ `"Consumable Material"` +- 也不等於前端的 `cmb` +- → sync 走 **else → `mat`** → 存貨顯示 **原料** + +若要顯示消耗品:需改 mapping(例如把 `CMB` 對到 `consumables`),或在 M18 改成已支援的字串;僅手動改 MTMS 可能被下次 sync 蓋掉。 + +--- + +## 4. 供應商與店鋪 + +| 方向 | M18 | MTMS | +|------|-----|------| +| Pull vendors | `ven` | `shop`,`ShopType.SUPPLIER`(`supplier`) | +| Pull business units | `virDept` | `shop`,`ShopType.SHOP`(`shop`) | + +鍵:`shop.m18Id`、`shop.code`。名稱優先 `descZhTW` → `descZhCN` → `desc`。 + +`ShopType`:`modules/master/enums/ShopType.kt`。 + +--- + +## 5. 單位(UoM) + +| M18 | MTMS | +|-----|------| +| Unit master (`unit`) | `uom_conversion`(`code`、`udfudesc`、`udfShortDesc`、`m18Id`…) | +| Cunit 明細 | `M18CunitService.replaceForUnit` | + +Item 級採購/庫存/銷售單位在 sync product price 時寫入 `item_uom`(見 `M18MasterDataService` product 區塊)。 + +PO/DO 行常同時保留: + +| 欄位 | 意義 | +|------|------| +| `qty` / `uomId` | MTMS 業務單位(例如採購單位換算後) | +| `qtyM18` / `uomIdM18` | M18 原始單位數量 | + +PO 換算邏輯見 `M18PurchaseOrderService`(`convertQtyToPurchaseQty`)。 + +--- + +## 6. 貨幣、BOM + +| M18 | MTMS | Service | +|-----|------|---------| +| Currency | `currency` | `saveCurrencies` | +| BOM (`udfbomforshop`) | `bom` / `bom_material` | `saveBoms` | + +Shop BOM **回寫** M18:`M18BomForShopService`(push)。 + +--- + +## 7. 交易文件(摘要) + +| 文件 | 方向 | MTMS 主表 | 筆記 | +|------|------|-----------|------| +| PO | M18 → MTMS | `purchase_order` / `purchase_order_line` | `m18Id` / data log;qty 可能換算 | +| DO | M18 → MTMS | `delivery_order` / `delivery_order_line` | 含 `qtyM18`、`uomIdM18` | +| GRN | MTMS → M18 | stock-in → M18 GRN API | 部分 `m18CreatedUId` **不送** GRN(見下) | + +### GRN 略過規則 + +`m18/M18GrnRules.kt`: + +| M18 PO `createUid` | 備註 | 行為 | +|--------------------|------|------| +| `2569` | legato | 不 post GRN | +| `2676` | xtech | 不 post GRN | + +--- + +## 8. Config 鍵(對照時常用) + +見 `M18Config` / `application-*.yml`: + +- `m18.config.seriesId.pp|pf|sc|se|sf|sr` +- `m18.config.beId.pp|pf|toa` +- `m18.config.supplier-not.material-po` +- `m18.config.supplier.shop-po` / `oem-po` +- `scheduler.m18Sync.enabled` +- `scheduler.m18Grn.createEnabled` + +--- + +## 9. 驗證用 SQL 範例 + +```sql +-- 某貨品目前 type(決定存貨顯示) +SELECT code, name, type, m18Id, m18LastModifyDate +FROM items +WHERE deleted = 0 AND code = 'MG1852'; + +-- 統計 type 分佈 +SELECT type, COUNT(*) AS cnt +FROM items +WHERE deleted = 0 +GROUP BY type +ORDER BY cnt DESC; +``` + +若 M18 回傳 `udfProducttype` 可與上表比對;對不上表中「exact string」者皆會變 `mat`。 + +--- + +## 10. 維護約定 + +1. **改 mapping**:先改 Kotlin enum / `when`,再跑 `python scripts/generate_m18_mapping_docs.py`,把 `docs/generated/*` 一併 commit。 +2. **改說明/陷阱**:只改本檔,勿手改 `docs/generated/`。 +3. **給營運/開會用**:跑 `python scripts/export_m18_mapping_office.py`,打開 `docs/exports/*.docx` / `*.xlsx`。 +4. **新發現的 M18 值**(如新的 `udfProducttype`):記入 generated 腳本的 `KNOWN_UNMAPPED_M18_VALUES`,或補正式 mapping 後重生。 +5. PR 若動到 `NewItemRequest.kt` / `M18MasterDataService` product type 分支,review 應檢查 generated docs 是否已更新。 + +--- + +## 11. 相關程式索引 + +| 主題 | 路徑 | +|------|------| +| Item / M18 type enums | `modules/master/web/models/NewItemRequest.kt` | +| Product sync | `m18/service/M18MasterDataService.kt` | +| StSearch | `m18/model/M18MasterDataRequest.kt` | +| Shop type | `modules/master/enums/ShopType.kt` | +| GRN skip | `m18/M18GrnRules.kt` | +| PO sync | `m18/service/M18PurchaseOrderService.kt` | +| DO sync | `m18/service/M18DeliveryOrderService.kt` | +| BOM→M18 | `m18/service/M18BomForShopService.kt` | +| 存貨 Type 顯示 | `InventoryTable.tsx` + `i18n/zh/inventory.json` | +| 物品 Type 下拉 | `CreateItem/ProductDetails.tsx` | diff --git a/docs/deploy/20260727_isextra_truck_x_ticket_fix.md b/docs/deploy/20260727_isextra_truck_x_ticket_fix.md new file mode 100644 index 0000000..6d5969e --- /dev/null +++ b/docs/deploy/20260727_isextra_truck_x_ticket_fix.md @@ -0,0 +1,33 @@ +# Deploy note — isExtra / 車線-X workbench ticket fix +Date: 2026-07-27 +Branch / build: production (`b12b9a49`) +Author: (fill) + +## Summary +- Workbench「加單」檢視納入整組 Etra 放單類型(`isExtra` / `isExtrabatch` / `isExtrasingle`),不再只認單一 `isExtra`。 +- 「車線-X」加單票依供應商偏好樓層在畫面上拆成「2/F」/「4/F」顯示(不改 DB `storeId`),撳單指派篩選與之一致。 + +## Scope +- Backend: `DoWorkbenchMainService`, `DoWorkbenchDopoAssignmentService`, `WorkbenchReleaseTypeSupport`, `DoDetailResponse` +- Frontend: 無(沿用現有 `/doworkbench` 加單 UI) +- DB / Liquibase: none +- Config / ops: none + +## Commits +- `b12b9a49` — isextra truck X ticket fix + +## Test plan +| # | Steps | Expected result | +|---|--------|-----------------| +| 1 | 「倉庫管理」→「成品出倉」→ Tab「加單」。選有 **isExtrabatch/isExtrasingle** 票的「是日/翌日」日期。 | 加單車線面板看得到這些票(不只舊的 `isExtra`);未撳數/總單數合理。 | +| 2 | 同一日找 **車線-X** 且 `storeId` 空的加單票;對照供應商屬 2F/4F 設定。 | 票出現在對應「2/F 票」或「4/F 票」檢視;不應因 storeId 空而整組消失。 | +| 3 | 在加單模式對該車線按撳單/「確認分配」。 | 指派成功;票進入提料明細,`ticketStatus` 變提貨中;無「此樓層沒有可用的提料單」誤報(若確實有未分配票)。 | +| 4 | 對照:一般「批量/單量」非加單票、非車線-X。 | 行為與修前相同(回歸)。 | +| 5 | (負向)加單日完全無 Etra 票。 | 「該樓層未有需處理訂單」或空面板,無 500。 | + +## Out of scope / not tested +- 送貨訂單「批量放單」建立票邏輯本身(僅測 Workbench 加單顯示/指派) +- 前端 UI 大改(其他 commit) + +## Rollback +- 還原 `b12b9a49` 或 redeploy 前一版 backend build diff --git a/docs/deploy/HOW_TO_GENERATE_COMMIT_TEST_PLANS.md b/docs/deploy/HOW_TO_GENERATE_COMMIT_TEST_PLANS.md new file mode 100644 index 0000000..5a943d6 --- /dev/null +++ b/docs/deploy/HOW_TO_GENERATE_COMMIT_TEST_PLANS.md @@ -0,0 +1,175 @@ +# How to generate commit test plans (Word) + +This guide is for QA / deploy teammates. Use it when a build is ready and you need **what changed** and **how to test** for each commit. + +## Easiest: just tell Cursor + +In Cursor chat (Agent), say any of: + +- `gen test plan` +- `generate test plan for new commits` +- `產測試計畫` / `有新 commit,幫我出 test plan` +- Or use the slash command: **`/gen-commit-test-plans`** + +Cursor will run the generator and put Word files in `docs/deploy/commit-plans/`. + +You can be more specific: + +- `gen test plan for latest commit` +- `gen test plan for HEAD~5..HEAD` +- `gen test plan since abc1234` + +--- + +## What you get + +Running the generator creates **one Word file per commit** under: + +``` +docs/deploy/commit-plans/ + YYYY-MM-DD__.docx + _index.md ← list of all generated plans +``` + +Each `.docx` includes: + +- Commit id, date, author, message +- Auto-detected areas (e.g. 送貨出倉, 工單, BOM) +- Files touched +- **Test plan table**: Steps + Expected result + +> These plans are a **starting point** (from commit message + file paths). For critical releases, refine steps with the developer or ask the Cursor agent to review the real diff. + +--- + +## Prerequisites + +1. Clone / pull latest `FPSMS-backend`. +2. Install Python 3, then: + +```bash +pip install python-docx +``` + +3. Open a terminal at the **repo root** (`FPSMS-backend`). + +--- + +## Basic usage + +Generate plans for the last 10 commits: + +```bash +python scripts/generate_commit_test_plans_docx.py HEAD~10..HEAD +``` + +Then open the folder: + +``` +docs/deploy/commit-plans/ +``` + +Start from `_index.md` to see the list, then open each `.docx`. + +--- + +## Common examples + +### Last N commits on current branch + +```bash +python scripts/generate_commit_test_plans_docx.py HEAD~5..HEAD +``` + +### Only the latest commit + +```bash +python scripts/generate_commit_test_plans_docx.py HEAD~1..HEAD +``` + +### Commits since a known SHA (e.g. last production deploy) + +```bash +python scripts/generate_commit_test_plans_docx.py ..HEAD +``` + +Example: + +```bash +python scripts/generate_commit_test_plans_docx.py b12b9a49..HEAD +``` + +### Commits on `production` that are not yet on another branch + +```bash +python scripts/generate_commit_test_plans_docx.py origin/staging..origin/production +``` + +(Adjust branch names to match your remote.) + +### Custom output folder + +```bash +python scripts/generate_commit_test_plans_docx.py HEAD~10..HEAD --out-dir docs/deploy/commit-plans/release-2026-08-01 +``` + +### Also write Markdown next to Word + +```bash +python scripts/generate_commit_test_plans_docx.py HEAD~10..HEAD --also-md +``` + +### Cap how many commits are processed + +```bash +python scripts/generate_commit_test_plans_docx.py HEAD~50..HEAD --limit 15 +``` + +--- + +## Suggested team workflow + +1. Developer merges / tags the build to deploy. +2. QA or release owner runs the generator for **only the commits in this deploy** (use `last_sha..new_sha`). +3. Share the `docs/deploy/commit-plans/` folder (or zip it) with testers. +4. Testers follow Steps / Expected result in each Word file; mark pass/fail. +5. For vague commits (`no message`), ask the author to clarify before sign-off. +6. Optional: keep a hand-written summary in `docs/deploy/YYYYMMDD_topic.md` (see `TEMPLATE.md`). + +--- + +## Related files + +| File | Purpose | +|------|---------| +| `scripts/generate_commit_test_plans_docx.py` | Auto Word plans **per commit** | +| `scripts/generate_deploy_test_plan.py` | One Markdown scaffold for a **whole range** | +| `docs/deploy/TEMPLATE.md` | Manual deploy / QA note template | +| `docs/deploy/20260727_isextra_truck_x_ticket_fix.md` | Example of a filled manual note | + +--- + +## Troubleshooting + +| Problem | What to try | +|---------|-------------| +| `No module named 'docx'` | `pip install python-docx` | +| `No commits in range` | Check the range syntax: `A..B` means commits reachable from B but not from A | +| Word filename looks odd | Filenames are ASCII-only on purpose (Windows-safe). Chinese text is still inside the document | +| Steps look too generic | Commit had `no message` or unusual paths — ask author / refine with agent | +| Need frontend checks | This repo is backend; also check `FPSMS-frontend` if the change is UI | + +--- + +## Quick copy-paste (release day) + +```bash +cd /path/to/FPSMS-backend +git fetch +git checkout +pip install python-docx +python scripts/generate_commit_test_plans_docx.py .. +explorer docs\deploy\commit-plans +``` + +(On Mac/Linux, open `docs/deploy/commit-plans` in Finder/files instead of `explorer`.) diff --git a/docs/deploy/TEMPLATE.md b/docs/deploy/TEMPLATE.md new file mode 100644 index 0000000..42519fe --- /dev/null +++ b/docs/deploy/TEMPLATE.md @@ -0,0 +1,46 @@ +# Deploy / QA note template + +Copy this for each deploy (or ask the agent: “寫 deploy test plan for commits X..Y”). + +```markdown +# Deploy note — +Date: YYYY-MM-DD +Branch / build: +Author: + +## Summary +- + +## Scope +- Backend: +- Frontend: +- DB / Liquibase: none +- Config / ops: none + +## Commits +- `________` — + +## Test plan +| # | Steps | Expected result | +|---|--------|-----------------| +| 1 | | | +| 2 | | | + +## Out of scope / not tested +- + +## Rollback +- +``` + +Filled examples live in `docs/deploy/` after each release prep. + +### Auto-generate Word plans (one file per commit) + +Full team instructions: [`HOW_TO_GENERATE_COMMIT_TEST_PLANS.md`](HOW_TO_GENERATE_COMMIT_TEST_PLANS.md) + +```bash +pip install python-docx +python scripts/generate_commit_test_plans_docx.py HEAD~10..HEAD +# → docs/deploy/commit-plans/__.docx +``` diff --git a/docs/deploy/commit-plans/2026-07-20_94dbc8db_no_message.docx b/docs/deploy/commit-plans/2026-07-20_94dbc8db_no_message.docx new file mode 100644 index 0000000..f7c09ac --- /dev/null +++ b/docs/deploy/commit-plans/2026-07-20_94dbc8db_no_message.docx @@ -0,0 +1,15 @@ +Test plan -- 94dbc8db +no messageCommit: 94dbc8db9a0daa677613bfd16bbc039fcd092f02 +Date: 2026-07-20 | Author: tommy +Generated: 2026-08-01 15:55 UTCSummary +no messageAreas (auto-detected) +庫存 / 上架 / 出入倉 +Files touched +src/main/java/com/ffii/fpsms/modules/productProcess/service/ProductProcessService.kt +src/main/java/com/ffii/fpsms/modules/productProcess/web/ProductProcessController.kt +src/main/java/com/ffii/fpsms/modules/stock/trace/ItemLotTraceOrchestrator.kt +src/main/java/com/ffii/fpsms/modules/stock/trace/TraceMovementLoader.kt +src/main/java/com/ffii/fpsms/modules/stock/trace/TraceOutboundLoader.kt +Test plan +Auto-generated from paths + commit message. Refine before sign-off on critical deploys.#StepsExpected result1依改動點進「上架掃碼」或相關庫存查詢頁。掃碼/查詢結果與庫存數量合理。2做一筆小量入/出/調撥(測試庫)。成功;庫存異動可查。3部署後煙霧:登入系統,開側欄主要入口一次(排程/工單/送貨訂單/成品出倉)。頁面可開、無全域錯誤橫幅。4(回歸)與本改動相鄰但未改的主流程走一輪 Happy path。無明顯回退。5向作者確認此 commit 的實際意圖(訊息為 empty/no message)。補上說明後再簽核上線。Out of scope / notes +Frontend-only changes may live in FPSMS-frontend -- verify paired repo if UI behavior is expected.Rollback: revert this commit or redeploy previous backend build. \ No newline at end of file diff --git a/docs/deploy/commit-plans/2026-07-22_173e6ce5_workbenchgoodpickexecutiondetail_ui.docx b/docs/deploy/commit-plans/2026-07-22_173e6ce5_workbenchgoodpickexecutiondetail_ui.docx new file mode 100644 index 0000000..193890a --- /dev/null +++ b/docs/deploy/commit-plans/2026-07-22_173e6ce5_workbenchgoodpickexecutiondetail_ui.docx @@ -0,0 +1,16 @@ +Test plan -- 173e6ce5 +WorkbenchGoodPickExecutionDetail UI 大改Commit: 173e6ce580b779c4577c3c89449b7bfbb18fd43f +Date: 2026-07-22 | Author: CANCERYS\kw093 +Generated: 2026-08-01 15:55 UTCSummary +WorkbenchGoodPickExecutionDetail UI 大改WorkbenchTicketReleaseTable 加 user filter + +DO release / FloorLanePanel:Truck X 票 + 搜尋依樓層Areas (auto-detected) +送貨訂單 / 成品出倉 +Files touched +src/main/java/com/ffii/fpsms/modules/deliveryOrder/service/DoReleaseCoordinatorService.kt +src/main/java/com/ffii/fpsms/modules/deliveryOrder/service/DoWorkbenchMainService.kt +src/main/java/com/ffii/fpsms/modules/deliveryOrder/service/DoWorkbenchReleaseService.kt +src/main/java/com/ffii/fpsms/modules/deliveryOrder/web/DoWorkbenchController.kt +Test plan +Auto-generated from paths + commit message. Refine before sign-off on critical deploys.#StepsExpected result1「送貨訂單」依預計送貨日搜索相關單,必要時「批量放單」或詳情「放單」。放單成功;產生提料票,無未預期 500。2「成品出倉」`/doworkbench`:撳單 --> 掃碼提料 --> 填箱數列印。票可撳、可提、可完成;狀態「待撳單」-->「提貨中」-->「已完成」。3若涉及加單/車線-X:開「加單」分頁與「車線-X」對照。票出現在正確樓層/車線;指派篩選與顯示一致。4核對「車線-X」票在 2/F/4/F 顯示。出現在正確樓層區塊,可指派。5部署後煙霧:登入系統,開側欄主要入口一次(排程/工單/送貨訂單/成品出倉)。頁面可開、無全域錯誤橫幅。6(回歸)與本改動相鄰但未改的主流程走一輪 Happy path。無明顯回退。Out of scope / notes +Frontend-only changes may live in FPSMS-frontend -- verify paired repo if UI behavior is expected.Rollback: revert this commit or redeploy previous backend build. \ No newline at end of file diff --git a/docs/deploy/commit-plans/2026-07-22_3ebf46e3_uom.docx b/docs/deploy/commit-plans/2026-07-22_3ebf46e3_uom.docx new file mode 100644 index 0000000..555f603 --- /dev/null +++ b/docs/deploy/commit-plans/2026-07-22_3ebf46e3_uom.docx @@ -0,0 +1,30 @@ +Test plan -- 3ebf46e3 +建議批號 UOM 檢核/不符則擋、標籤列印只顯示同 UOMCommit: 3ebf46e304ccc847f178cb57dc5ed106f140e0ec +Date: 2026-07-22 | Author: CANCERYS\kw093 +Generated: 2026-08-01 15:55 UTCSummary +建議批號 UOM 檢核/不符則擋、標籤列印只顯示同 UOMAreas (auto-detected) +提料單 / Workbench +建議批號 / UOM +庫存 / 上架 / 出入倉 +來貨 / 品檢 +主檔 (Item/Shop 等) +庫存查詢 +Files touched +src/main/java/com/ffii/fpsms/modules/master/service/ItemsService.kt +src/main/java/com/ffii/fpsms/modules/master/web/ItemsController.kt +src/main/java/com/ffii/fpsms/modules/pickOrder/service/HierarchicalFgPayloadAssembler.kt +src/main/java/com/ffii/fpsms/modules/pickOrder/service/PickOrderWorkbenchService.kt +src/main/java/com/ffii/fpsms/modules/stock/entity/InventoryRepository.kt +src/main/java/com/ffii/fpsms/modules/stock/entity/projection/InventoryLotLineInfo.kt +src/main/java/com/ffii/fpsms/modules/stock/service/InventoryLotLineService.kt +src/main/java/com/ffii/fpsms/modules/stock/service/InventoryService.kt +src/main/java/com/ffii/fpsms/modules/stock/service/StockInLineService.kt +src/main/java/com/ffii/fpsms/modules/stock/service/StockOutLineWorkbenchService.kt +src/main/java/com/ffii/fpsms/modules/stock/service/SuggestedPickLotService.kt +src/main/java/com/ffii/fpsms/modules/stock/service/SuggestedPickLotWorkbenchService.kt +src/main/java/com/ffii/fpsms/modules/stock/web/InventoryController.kt +src/main/java/com/ffii/fpsms/modules/stock/web/InventoryLotLineController.kt +src/main/java/com/ffii/fpsms/modules/stock/web/model/LotLineInfo.kt +Test plan +Auto-generated from paths + commit message. Refine before sign-off on critical deploys.#StepsExpected result1「成品出倉」撳單並完成一張提料票(測試庫)。掃碼/提交正常;完成後記錄頁可見。2「查看提貨情況」核對該票狀態。狀態與負責人符合操作。3出倉或提料時掃建議批號;刻意掃 UOM 不符批號。不符 UOM 被擋並有明確提示;相符批號可提交。4標籤列印/批號列表只應出現同 UOM 選項(若本次改動涵蓋)。列表無錯誤 UOM 批號。5依改動點進「上架掃碼」或相關庫存查詢頁。掃碼/查詢結果與庫存數量合理。6做一筆小量入/出/調撥(測試庫)。成功;庫存異動可查。7「工單生產流程」-->「品檢」或待 QC 列表。可開品檢;確定後狀態更新。8主檔搜索受影響編號,核對顯示欄位。名稱/單位/類型等與預期一致。9庫存搜索頁用受影響貨品/倉位查詢。批號、數量、單位正確。10掃一筆 UOM 不符的批號/物料。系統拒絕或明確提示;相符者可過。11選打印機後列印標籤/送貨單標籤。成功列印;內容正確。12部署後煙霧:登入系統,開側欄主要入口一次(排程/工單/送貨訂單/成品出倉)。頁面可開、無全域錯誤橫幅。13(回歸)與本改動相鄰但未改的主流程走一輪 Happy path。無明顯回退。Out of scope / notes +Frontend-only changes may live in FPSMS-frontend -- verify paired repo if UI behavior is expected.Rollback: revert this commit or redeploy previous backend build. \ No newline at end of file diff --git a/docs/deploy/commit-plans/2026-07-23_18a4d2db_no_message.docx b/docs/deploy/commit-plans/2026-07-23_18a4d2db_no_message.docx new file mode 100644 index 0000000..a0449c5 --- /dev/null +++ b/docs/deploy/commit-plans/2026-07-23_18a4d2db_no_message.docx @@ -0,0 +1,11 @@ +Test plan -- 18a4d2db +no messageCommit: 18a4d2dbffd46cba5582aae035bf75bc0af505ba +Date: 2026-07-23 | Author: Fai Luk +Generated: 2026-08-01 15:55 UTCSummary +no messageAreas (auto-detected) +資料庫變更 +Files touched +src/main/resources/db/changelog/changes/20260723_onpack_qr_pp2404.sql +Test plan +Auto-generated from paths + commit message. Refine before sign-off on critical deploys.#StepsExpected result1部署後確認 Liquibase/changelog 已套用(或啟動 log 無 changeset 失敗)。DB schema/資料符合 changeset。2用相關畫面或 SQL 抽樣驗證新欄位/約束。讀寫正常,無缺欄錯誤。3選打印機後列印標籤/送貨單標籤。成功列印;內容正確。4部署後煙霧:登入系統,開側欄主要入口一次(排程/工單/送貨訂單/成品出倉)。頁面可開、無全域錯誤橫幅。5(回歸)與本改動相鄰但未改的主流程走一輪 Happy path。無明顯回退。6向作者確認此 commit 的實際意圖(訊息為 empty/no message)。補上說明後再簽核上線。Out of scope / notes +Frontend-only changes may live in FPSMS-frontend -- verify paired repo if UI behavior is expected.Rollback: revert this commit or redeploy previous backend build. \ No newline at end of file diff --git a/docs/deploy/commit-plans/2026-07-23_a81c6f1c_added_onpack2030_for_pp2404.docx b/docs/deploy/commit-plans/2026-07-23_a81c6f1c_added_onpack2030_for_pp2404.docx new file mode 100644 index 0000000..39210a2 --- /dev/null +++ b/docs/deploy/commit-plans/2026-07-23_a81c6f1c_added_onpack2030_for_pp2404.docx @@ -0,0 +1,13 @@ +Test plan -- a81c6f1c +added onpack2030 for pp2404Commit: a81c6f1cf66632c0c715b0f23ed9148fa019b79e +Date: 2026-07-23 | Author: Fai Luk +Generated: 2026-08-01 15:55 UTCSummary +added onpack2030 for pp2404Areas (auto-detected) +標籤 / OnPack +Files touched +src/main/resources/onpack2030/3960CFF6FC46C5A174CA3C78D690CA15.bmp +src/main/resources/onpack2030/PP2404.image +src/main/resources/onpack2030/PP2404.job +Test plan +Auto-generated from paths + commit message. Refine before sign-off on critical deploys.#StepsExpected result1對新增/修改的貨品編號列印標籤(測試機)。圖檔/job 正確;可印出。2選打印機後列印標籤/送貨單標籤。成功列印;內容正確。3部署後煙霧:登入系統,開側欄主要入口一次(排程/工單/送貨訂單/成品出倉)。頁面可開、無全域錯誤橫幅。4(回歸)與本改動相鄰但未改的主流程走一輪 Happy path。無明顯回退。Out of scope / notes +Frontend-only changes may live in FPSMS-frontend -- verify paired repo if UI behavior is expected.Rollback: revert this commit or redeploy previous backend build. \ No newline at end of file diff --git a/docs/deploy/commit-plans/2026-07-24_5f628db3_no_message.docx b/docs/deploy/commit-plans/2026-07-24_5f628db3_no_message.docx new file mode 100644 index 0000000..cdb6089 --- /dev/null +++ b/docs/deploy/commit-plans/2026-07-24_5f628db3_no_message.docx @@ -0,0 +1,14 @@ +Test plan -- 5f628db3 +no messageCommit: 5f628db30cb513b88f2aaa164700db0240700ea7 +Date: 2026-07-24 | Author: Fai Luk +Generated: 2026-08-01 15:55 UTCSummary +no messageAreas (auto-detected) +標籤 / OnPack +Files touched +src/main/resources/onpack2030/PP2404.image +src/main/resources/onpack2030/PP2404.job +src/main/resources/onpack2030/pp2404.image +src/main/resources/onpack2030/pp2404.job +Test plan +Auto-generated from paths + commit message. Refine before sign-off on critical deploys.#StepsExpected result1對新增/修改的貨品編號列印標籤(測試機)。圖檔/job 正確;可印出。2選打印機後列印標籤/送貨單標籤。成功列印;內容正確。3部署後煙霧:登入系統,開側欄主要入口一次(排程/工單/送貨訂單/成品出倉)。頁面可開、無全域錯誤橫幅。4(回歸)與本改動相鄰但未改的主流程走一輪 Happy path。無明顯回退。5向作者確認此 commit 的實際意圖(訊息為 empty/no message)。補上說明後再簽核上線。Out of scope / notes +Frontend-only changes may live in FPSMS-frontend -- verify paired repo if UI behavior is expected.Rollback: revert this commit or redeploy previous backend build. \ No newline at end of file diff --git a/docs/deploy/commit-plans/2026-07-24_93c3e931_no_message.docx b/docs/deploy/commit-plans/2026-07-24_93c3e931_no_message.docx new file mode 100644 index 0000000..4f72ce1 --- /dev/null +++ b/docs/deploy/commit-plans/2026-07-24_93c3e931_no_message.docx @@ -0,0 +1,11 @@ +Test plan -- 93c3e931 +no messageCommit: 93c3e9318fa3f5fc65c3ab1ca06b3e73d1caf0e6 +Date: 2026-07-24 | Author: Fai Luk +Generated: 2026-08-01 15:55 UTCSummary +no messageAreas (auto-detected) +標籤 / OnPack +Files touched +src/main/resources/onpack2030/PP2404.image +Test plan +Auto-generated from paths + commit message. Refine before sign-off on critical deploys.#StepsExpected result1對新增/修改的貨品編號列印標籤(測試機)。圖檔/job 正確;可印出。2選打印機後列印標籤/送貨單標籤。成功列印;內容正確。3部署後煙霧:登入系統,開側欄主要入口一次(排程/工單/送貨訂單/成品出倉)。頁面可開、無全域錯誤橫幅。4(回歸)與本改動相鄰但未改的主流程走一輪 Happy path。無明顯回退。5向作者確認此 commit 的實際意圖(訊息為 empty/no message)。補上說明後再簽核上線。Out of scope / notes +Frontend-only changes may live in FPSMS-frontend -- verify paired repo if UI behavior is expected.Rollback: revert this commit or redeploy previous backend build. \ No newline at end of file diff --git a/docs/deploy/commit-plans/2026-07-27_b12b9a49_isextra_truck_ticket_fix.docx b/docs/deploy/commit-plans/2026-07-27_b12b9a49_isextra_truck_ticket_fix.docx new file mode 100644 index 0000000..035ee80 --- /dev/null +++ b/docs/deploy/commit-plans/2026-07-27_b12b9a49_isextra_truck_ticket_fix.docx @@ -0,0 +1,14 @@ +Test plan -- b12b9a49 +isextra truck X ticket fixCommit: b12b9a499f63730c0a61e40075e6eda7785f6919 +Date: 2026-07-27 | Author: CANCERYS\kw093 +Generated: 2026-08-01 15:55 UTCSummary +isextra truck X ticket fixAreas (auto-detected) +送貨訂單 / 成品出倉 +Files touched +src/main/java/com/ffii/fpsms/modules/deliveryOrder/service/DoWorkbenchDopoAssignmentService.kt +src/main/java/com/ffii/fpsms/modules/deliveryOrder/service/DoWorkbenchMainService.kt +src/main/java/com/ffii/fpsms/modules/deliveryOrder/service/WorkbenchReleaseTypeSupport.kt +src/main/java/com/ffii/fpsms/modules/deliveryOrder/web/models/DoDetailResponse.kt +Test plan +Auto-generated from paths + commit message. Refine before sign-off on critical deploys.#StepsExpected result1「送貨訂單」依預計送貨日搜索相關單,必要時「批量放單」或詳情「放單」。放單成功;產生提料票,無未預期 500。2「成品出倉」`/doworkbench`:撳單 --> 掃碼提料 --> 填箱數列印。票可撳、可提、可完成;狀態「待撳單」-->「提貨中」-->「已完成」。3若涉及加單/車線-X:開「加單」分頁與「車線-X」對照。票出現在正確樓層/車線;指派篩選與顯示一致。4「成品出倉」-->「加單」檢視該日票。加單票可見且可撳單。5核對「車線-X」票在 2/F/4/F 顯示。出現在正確樓層區塊,可指派。6重現原問題步驟一次。問題不再出現;無新副作用。7部署後煙霧:登入系統,開側欄主要入口一次(排程/工單/送貨訂單/成品出倉)。頁面可開、無全域錯誤橫幅。8(回歸)與本改動相鄰但未改的主流程走一輪 Happy path。無明顯回退。Out of scope / notes +Frontend-only changes may live in FPSMS-frontend -- verify paired repo if UI behavior is expected.Rollback: revert this commit or redeploy previous backend build. \ No newline at end of file diff --git a/docs/deploy/commit-plans/_index.md b/docs/deploy/commit-plans/_index.md new file mode 100644 index 0000000..41d6278 --- /dev/null +++ b/docs/deploy/commit-plans/_index.md @@ -0,0 +1,14 @@ +# Commit test plans (auto-generated) + +Generated: 2026-08-01 15:55 UTC + +| Date | SHA | Subject | Word | +|------|-----|---------|------| +| 2026-07-20 | `94dbc8db` | no message | [2026-07-20_94dbc8db_no_message.docx](2026-07-20_94dbc8db_no_message.docx) | +| 2026-07-22 | `3ebf46e3` | 建議批號 UOM 檢核/不符則擋、標籤列印只顯示同 UOM | [2026-07-22_3ebf46e3_uom.docx](2026-07-22_3ebf46e3_uom.docx) | +| 2026-07-22 | `173e6ce5` | WorkbenchGoodPickExecutionDetail UI 大改 | [2026-07-22_173e6ce5_workbenchgoodpickexecutiondetail_ui.docx](2026-07-22_173e6ce5_workbenchgoodpickexecutiondetail_ui.docx) | +| 2026-07-23 | `a81c6f1c` | added onpack2030 for pp2404 | [2026-07-23_a81c6f1c_added_onpack2030_for_pp2404.docx](2026-07-23_a81c6f1c_added_onpack2030_for_pp2404.docx) | +| 2026-07-23 | `18a4d2db` | no message | [2026-07-23_18a4d2db_no_message.docx](2026-07-23_18a4d2db_no_message.docx) | +| 2026-07-24 | `93c3e931` | no message | [2026-07-24_93c3e931_no_message.docx](2026-07-24_93c3e931_no_message.docx) | +| 2026-07-24 | `5f628db3` | no message | [2026-07-24_5f628db3_no_message.docx](2026-07-24_5f628db3_no_message.docx) | +| 2026-07-27 | `b12b9a49` | isextra truck X ticket fix | [2026-07-27_b12b9a49_isextra_truck_ticket_fix.docx](2026-07-27_b12b9a49_isextra_truck_ticket_fix.docx) | diff --git a/docs/exports/MTMS_BOM_UserGuide.docx b/docs/exports/MTMS_BOM_UserGuide.docx new file mode 100644 index 0000000..cad431b --- /dev/null +++ b/docs/exports/MTMS_BOM_UserGuide.docx @@ -0,0 +1,106 @@ +MTMS 使用說明:BOM(匯入/啟停/應用) + 本手冊依目前前端畫面與繁中文案整理,按鈕/選單名稱以畫面上「」內文字為準。 + 適用範圍:側欄「設定」下的 BOM 相關頁、建立工單時選 BOM、排程「自動生成工單」。 + 「一附」章節使用本機資料庫 fpsmsdb 的真實例子(查詢當下快照);其他環境請改條件再對。 + 產生日期:依原始碼現況(若畫面改版,請以實際 UI 為準)。 +這份手冊怎麼用 +符號意思「......」畫面上看得到的按鈕、選單、標題-->下一步操作建議閱讀順序: +先匯入/啟用 BOM(「設定」-->「匯入 BOM」) +再檢查單位問題(ADMIN:「BOM / 物料單位問題」) +再建工單/排程產單(選 active BOM) +一、整體流程(BOM 從主檔到工單) +【匯入 BOM】Excel 上傳並檢查 -->「確認匯入」 + ↓ +【BOM 明細】確認「啟用」/必要時「停用」 + ↓(選做)「BOM 權重得分」調整加權 + ↓(ADMIN)「BOM / 物料單位問題」巡檢 + ↓ +【建工單】「建立工單」選「BOM」 + 或【排程】「自動生成工單」(僅含具 BOM 的物料)一附、本地資料庫實例(方便對照畫面) + 以下數字來自本機 fpsmsdb 查詢結果。不同環境/日期資料會不同。 + BOM 狀態:active-->「啟用」、inactive-->「停用」。本機約有 214 筆啟用、3 筆停用。 +例 A:啟用中的成品 BOM(適合在「BOM 明細」搜索) +「物品編號」「物品名稱」「BOM 狀態」約材料筆數PP2167牛丼啟用10PP2277烚意粉啟用3PP2390熱情香果醬啟用4PP1224柚子蒜蓉汁啟用5PP1136白粥啟用9PP0259牛肉水(2KG/包)啟用0(無材料列,排程/提料前宜先確認)怎麼練: +「設定」-->「匯入 BOM」--> Tab「BOM 明細」。 +「物品編號」填 PP2167 -->「搜索」。 +載入後看「基本資訊」「材料清單」「製程與設備」;「BOM 狀態」應為「啟用」。 +例 B:材料清單長什麼樣(PP2167 牛丼) +「物品編號」「物品名稱」約「基本數量」MA0471日式超薄肥牛272.16FA0164洋蔥絲145.15GI3236清水60.00PP2214丼飯汁(2磅/包)41.73MG1507江泉牌燒肉汁(1.8L/支)9.00MG1299李錦記特選老抽(8L/桶)8.00MG0302佛手牌味粉(10lb/罐)4.54MG0239韓國雪白幼砂糖(30kg/包)4.54例 C:用 BOM 建工單(接《排程與工單》手冊) +本機 2026-07-29 手動工單多掛在 active BOM 上,例如:「工單編號」「狀態」成品 BOM「需求數量」JO-260729-038待處理PP2277 烚意粉51JO-260729-044提料中PP2383 辣椒菜脯265怎麼練:「搜索工單/ 建立工單」-->「建立工單」-->「BOM」下拉選 PP2277(僅顯示啟用中的 BOM)。 +自行查核用 SQL(選用) +SELECT b.code, i.name, b.status, + (SELECT COUNT(*) FROM bom_material bm + WHERE bm.bomId = b.id AND IFNULL(bm.deleted,0)=0) AS mats +FROM bom b +JOIN items i ON i.id = b.itemId +WHERE b.deleted = 0 AND b.code = 'PP2167'; + +SELECT i.code, i.name, bm.qty +FROM bom_material bm +JOIN items i ON i.id = bm.itemId +JOIN bom b ON b.id = bm.bomId +WHERE b.code = 'PP2167' AND IFNULL(bm.deleted,0)=0;二、匯入 BOM(「設定」-->「匯入 BOM」) +2.1 入口 +側欄 「設定」 --> 「匯入 BOM」(路徑通常為 /settings/importBom) +頁內標題可能顯示英文 Import BOM;側欄與 Tab 以繁中「匯入 BOM」為準。 +2.2 兩個分頁 +Tab用途「匯入 BOM」上傳 Excel、檢查、確認寫入「BOM 明細」查已有 BOM、啟用/停用2.3 操作步驟:上傳並匯入 +開 Tab 「匯入 BOM」。 +「選擇 BOM Excel 檔案」 -->「選擇檔案」或「選擇資料夾」(可多選 .xlsx)。 +確認「已選 N 個檔案」後,點 「上傳並檢查」。 +進行中:「上傳與檢查中...」、進度「已檢查 x / y 個檔案...」。 +檢視結果: +「正確 BOM 列表(可匯入)」 +「問題 BOM 列表」 +必要時勾選類型相關選項(如「飲料」「箱料粉」等)或用「搜索檔名」篩選。 +確認無誤後點 「確認匯入」。 +成功提示類似 「匯入完成」;亦可先「下載檢查結果 Excel」留底。 +若要重來:點「返回重選檔案」。 +2.4 常見錯誤(匯入) +畫面提示建議處理「請至少選擇一個 .xlsx 檔案」先選檔再上傳「上傳或檢查失敗,請稍後再試。」/伺服器 500檢查網路/檔案格式後重試檔名重複相關提示(_2、_3...)依提示整理檔名後重傳「匯入失敗,請查看主控台。」記下失敗檔,修正 Excel 後只重傳問題檔三、BOM 明細:查詢與啟停 +3.1 搜索 +Tab 「BOM 明細」。 +「物品編號」/「物品名稱」-->「搜索」/「重置」。 +多筆時:「找到多筆 BOM,請選擇一筆載入明細」(按鈕上會標「成品|半成品」「啟用|停用」)。 +3.2 可改什麼 +區塊說明「BOM 狀態」「啟用」/「停用」-->「儲存狀態」「基本資訊」產出數量、類型、過敏原、色深/浮沉/濃淡、時段、複雜度、基礎得分等(多為檢視)「材料清單」物品編號/名稱、基本/庫存/銷售數量與單位「製程與設備」製程檢視 注意: 完整欄位線上「編輯/儲存」目前前端關閉(SHOW_BOM_FULL_EDIT = false)。日常維護以 Excel 匯入 與 啟用/停用 為主。 +3.3 何時「停用」 +配方作廢、暫不允許再建工單時,將「BOM 狀態」改「停用」並「儲存狀態」。 +「建立工單」的 BOM 下拉只列出啟用項目;停用後新單選不到該 BOM。 +四、BOM 權重得分 +4.1 入口 +「設定」--> 「BOM 權重得分」(/settings/bomWeighting) +4.2 分頁 +Tab用途「BOM 加權」調整各評分項目的「權重」(總和須=1)「BOM得分」查看各貨品「基礎得分」4.3 操作 +「BOM 加權」-->「編輯」-->改「權重」-->「儲存」。 +校驗失敗時:「權重必須為數字」或「權重總和必須等於 1(目前總和: x)」。 +成功:「更新成功(已重新計算 N 筆 BOM 基礎分)」。 +到「BOM得分」核對「貨品編號」「物品名稱」「基礎得分」。 +五、BOM/物料單位問題(ADMIN) +5.1 入口 +「設定」--> 「BOM / 物料單位問題」(僅 ADMIN;/settings/masterDataIssues) +頁標題常顯示:「BOM/貨品單位問題」 +5.2 操作 +Tab「BOM」或「貨品」。 +「搜索」「類型」(「全部」「BOM 總表」「BOM 原材料」)。 +「重新檢查」更新清單;「複製清單」方便貼到表單/郵件。 +點列開詳情:「問題」「應為」「實際」-->「關閉」。 +5.3 常見問題文案(節錄) +「BOM 編號為空」「BOM 名稱為空」 +「BOM 產出單位與成品銷售單位不一致」 +「BOM 原料銷售/基本/庫存單位與貨品主檔不一致」 +「BOM 編號與關聯貨品不一致」 +側欄紅點例:BOM N 筆 · 貨品 M 筆。空狀態:「目前沒有問題。」六、應用:建立工單時選 BOM +6.1 手動建立 +「管理工單」-->「搜索工單/ 建立工單」-->「建立工單」。 +「BOM」(必填)選成品/半成品;同名時可能標「(成品)」「(半成品)」。 +「標準生產數」x「批數」=「需求數量」(單位來自 BOM 產出 UOM)。 +「預計生產日期」等填妥 -->「建立」。 +成功常提示「成功更新資料」。未選 BOM 時可能出現「請選擇 BOM」/Bom required!。 +6.2 排程自動生成 +「排程」-->「生產排程」-->「詳細」。 +「自動生成工單」(僅處理具 BOM 的物料;說明文案類似「選擇日期範圍(僅含具 BOM 的物料...)」)。 +舊細排頁另有「生成工單」「查看 BOM」(材料表含「編號」「名稱」「可用數量」「需求數量」)。 +詳見《MTMS 排程與工單 使用說明》。七、權限與路徑速查 +畫面路徑備註匯入 BOM/settings/importBom「設定」下BOM 權重得分/settings/bomWeightingBOM/物料單位問題/settings/masterDataIssuesADMIN建立工單/jo選 active BOM排程產單/ps需 BOM八、常見問題速查 +情況建議建工單下拉找不到某成品到「BOM 明細」確認是否「啟用」;或尚未匯入排程產單跳過某物料該物料可能無 BOM/不在排期 BOM 範圍單位對不上、品檢/提料異常ADMIN 開「BOM / 物料單位問題」對照主檔後修正 Excel 再匯入權重儲存失敗確認各權重為數字且總和=1 \ No newline at end of file diff --git a/docs/exports/MTMS_DO_Shipping_UserGuide.docx b/docs/exports/MTMS_DO_Shipping_UserGuide.docx new file mode 100644 index 0000000..dbfd09d --- /dev/null +++ b/docs/exports/MTMS_DO_Shipping_UserGuide.docx @@ -0,0 +1,149 @@ +MTMS 使用說明:送貨訂單 --> 放單 --> 成品出倉 + 本手冊依目前前端畫面與繁中文案整理,按鈕/選單名稱以畫面上「」內文字為準。 + 適用範圍:側欄「送貨訂單」、倉庫「成品出倉」(撳單/掃碼出倉/列印標籤)、加單與車線-X。 + 「一附」章節使用本機資料庫 fpsmsdb 的真實例子(查詢當下快照)。 + 產生日期:依原始碼現況(若畫面改版,請以實際 UI 為準)。 +這份手冊怎麼用 +符號意思「......」畫面上看得到的按鈕、選單、標題-->下一步操作角色分工(白話): +角色動作主要畫面看單、放單、加單、補貨「送貨訂單」/do撳單、掃碼提貨、列印 DN/標籤「成品出倉」/doworkbench調整提料順序等(ADMIN)「成品出倉管理」 「放單」!=「撳單」:放單=由送貨訂單產生提料票;撳單=倉庫領取該票開始出倉。 +建議閱讀順序:送貨訂單篩選 --> 放單 --> 撳單 --> 掃碼出倉 --> 填箱數列印。一、整體流程 +【送貨訂單 /do】篩選「2/F」「4/F」「車線-X」「加單」 + ↓ 「批量放單」或詳情「放單」 +【產生提料單/提票】狀態進入待撳單 + ↓ +【成品出倉 /doworkbench】「撳單/提料單詳情」 + ↓ 選日期/批量|單量/樓層票 --> 點車線「確認分配」 +【掃碼提料】「開始QR掃描」-->「提交所有已掃描項目」 + ↓ +【成品提貨記錄】輸入「箱數」-->「列印送貨單標籤」等 + ↓ +【查看提貨情況】核對「已完成」一附、本地資料庫實例 + DO「來貨狀態」:pending-->「待處理」、receiving-->「接收中」、completed-->「已完成」。 + 提票在「查看提貨情況」:pending-->「待撳單」、released-->「提貨中」、completed-->「已完成」。 +例 A:待放單/待出貨的送貨訂單(2026-07-30) +本機「預計送貨日期」2026-07-30 仍有多張「待處理」,例如:「門店訂單編號」「來貨狀態」店鋪TOUR03PO26070304待處理UR03TOCF28PO26070199待處理CF28TOCF28PO26070200待處理CF28TOCF02PO26070176待處理CF02TOCF18PO26070194待處理CF18怎麼練: +「送貨訂單」--> 選樓層分頁 -->「預計送貨日期」填 2026-07-30 --> 搜索。 +找上表編號點「詳情」看行項與「庫存可用」。 +測試庫才建議真的「放單」/「批量放單」(會產生提料票)。 +例 B:近一週 DO 狀態量級(本機 2026-07-28~08-05) +「來貨狀態」約筆數使用者常做待處理1,425篩選後「批量放單」已完成1,059查歷史/補貨原單接收中96出倉進行中對應例 C:提料票(撳單前「待撳單」風格樣本) +本機較早提票例(do_pick_order,狀態 pending≈待撳/待處理):「提票號碼」車線資訊店鋪「需求日期」放單類型TI-S-20260504-4F-001P06B_Sat_區1_港島東MC492026-05-04single(單量)TI-S-20260504-2F-001車線-F1MC492026-05-04singleTI-S-20260504-2F-001車線-XHP652026-05-04singleTI-S-20260505-4F-002P06B_Tue_區5_九龍中HP152026-05-05single怎麼練:「成品出倉」-->「撳單/提料單詳情」--> 日期選對應「是日/翌日...」;或「成品提貨記錄(全部)」用「提票號碼」搜索。注意「車線-X」會獨立分組。 +自行查核用 SQL(選用) +SELECT d.code, d.status, DATE(d.estimatedArrivalDate) AS eta, s.code AS shop +FROM delivery_order d +LEFT JOIN shop s ON s.id = d.shopId +WHERE d.deleted = 0 AND DATE(d.estimatedArrivalDate) = '2026-07-30' +ORDER BY d.id DESC +LIMIT 20; + +SELECT ticket_no, TruckLanceCode, ticket_status, ShopCode, + DATE(RequiredDeliveryDate) AS req_date, release_type +FROM do_pick_order +WHERE deleted = 0 AND RequiredDeliveryDate >= '2026-05-01' +ORDER BY id DESC +LIMIT 20;二、送貨訂單(/do) +2.1 入口與分頁 +側欄 「送貨訂單」。 +分頁用途「2/F」「4/F」依樓層票別看/放單「車線-X」無匹配車線或歸入 X 的訂單「加單」isExtra 加單;批量放單可合併「補貨」已完成原單補到目標單2.2 搜索欄 +「門店訂單編號」「店鋪名稱」「車線號碼」「預計送貨日期」「來貨狀態」 +狀態選項:「待處理」「接收中」「已完成」(及「全部」) +注意:「已填寫車線號碼時,請一併選擇預計送貨日期後再搜索。」/「需選擇預計送貨日期」 +2.3 結果表常見欄 +「詳情」「門店訂單編號」「店鋪名稱」「供應商名稱」「車線號碼」「訂單日期」「預計送貨日期」「來貨狀態」。2.4 詳情頁(/do/edit?id=) +標題:「編輯送貨訂單詳情」 +動作:「放單」「提料單分配」「分配2/F」「分配4/F」「放單2/F」「放單4/F」「返回」 +行表:「商品編號」「貨品名稱」「數量」「庫存可用」「庫存狀態」 +單張成功提示:「送貨訂單放單成功!提料單已建立。」 +三、放單詳解 +3.1 批量放單(常用) +在「送貨訂單」搜出目標日/樓層的列,勾選需要的店(可取消勾選排除)。 +點 「批量放單」。 +對話框顯示「已選擇店舖數量: N」;「確認」執行。 +「加單」分頁額外選項: +「確認合併放單」(合併同車線 --> TI-M- 合併票;文案含「合併同車線送貨訂單(TI-M- 合併票)」) +「確認不放合併放單」 +成功:「已完成批量放單」。 +Workbench 路徑按鈕亦可能顯示為「批量放單」(鍵名 Workbench Batch Release)。3.2 單張放單 +詳情頁「放單」,或先「分配2/F/4/F」再「放單2/F/4/F」。3.3 放單前/失敗常見提示 +提示處理「沒有選擇送貨訂單進行批量放單...」先勾選列「車線可用性警告」「問題送貨訂單」核對目標日是否有車線;或走「車線-X」「放單提料單失敗,請稍後再試。」稍後重試;查該店是否已放過四、成品出倉(/doworkbench) -- 主路徑 +4.1 入口 +「倉庫管理」--> 「成品出倉」(現行主選單指向 /doworkbench)。 +舊頁 /finishedGood 標題同為「成品出倉」,一般以 Workbench 為準。 +4.2 頁頂打印機 +「A4 打印機」「標籤打印機」「列印全部草稿 (N)」 +未選機:「請先選擇打印機」/「請先選擇標籤打印機」 +4.3 分頁一覽 +tab標籤0「撳單/提料單詳情」1「加單」(徽章:當日未完成加單票數)2「成品提貨記錄」3「成品提貨記錄(全部)」4「查看提貨情況」5「成品出倉出箱數量」6「送貨路線摘要」五、撳單(領票) +5.1 條件列 +「請選擇日期」:「是日」「翌日」「後日」 +「放單類型」:「批量」「單量」 +「票別(樓層)」:「2/F 票」「4/F 票」 +5.2 車線面板 +車線按鈕顯示「(未撳數/總單數)」、裝載序/出發時間等。 +點車線 -->「確認分配」(含「位置」「車線號碼」「裝載順序」「出發時間」「所需日期」「可用訂單」)-->「確認」。 +成功後進入提料明細掃碼。 +無單時:「該樓層未有需處理訂單」/「此樓層沒有可用的提料單」。 +「未完成提料單」可搜商店/車線/送貨單編號再「選擇」。 +限制:「請先完成目前的提料單,再提取下一張」。 +5.3 「車線-X」 +DO 與出倉皆有獨立「車線-X」區塊;無匹配車線時顯示「車線-X」。 +「以前」:今日前未完成的車線-X。 +出箱儀表會統計「車線-X 出箱數」。 +六、掃碼出倉(提料執行) +在已撳單的明細中看「所有提料單批號」「進度」。 +「開始QR掃描」/「停止QR掃描」;「掃描結果」正確時「二維碼驗證成功。」 +可「改數」「提交數量」;問題回報含不良/遺失等。 +「提交所有已掃描項目」;可選「列印空白頁數標籤」。 +無掃碼可直接完成的列可用「已完成」(Just Completed)。 +全部完成後通常導向「成品提貨記錄」(帶提票號)。 +掃碼常見錯誤 +提示處理「二維碼不符合當前訂單中的任何貨品。」確認掃的是本票貨品「此批號不可用...」「此批次尚未上架」換批或先完成上架「此批號單位不符...」「此批號已提貨...」換批「掃描批號已過期...」換未過期批「此批次貨品已被其他送貨單留起...」換批或協調留貨換批雙掃說明依畫面再掃一次確認七、加單專章 +7.1 在「送貨訂單」 +開「加單」分頁搜索與「批量放單」。 +合併選項見 §3.1(TI-M- 合併票)。 +7.2 在「成品出倉」 +Tab「加單」;進入前確認:「進入加單檢視?」 +說明:「加單檢視會依選定日期,將 isExtra 票依店鋪與車線顯示。」 +「目前是加單票,顯示與操作已切換為加單模式。」/「離開加單檢視」「返回一般指派分頁」。 +「合併加單提料單」:僅「未分配」且同店鋪、樓層(2/F、4/F 或車線-X)、車線、出發時間可合併。 +類型顯示可能為「合拼單」「加單」「批量」「單量」。 +八、補貨(「送貨訂單」-->「補貨」) +「補貨填表」「對單」-->「待提交列表」-->「提交」/「清空」。 +「送貨單號末四位」「原送貨單」「目標送貨單」「補貨數量」「原出貨數」「車線」。 +無車線時畫面可能顯示「車線-X」。 +「補貨進度追蹤」:待處理/處理中/已完成。 +限制例:「只有已送貨(completed)的送貨單可作為原送貨單。」「補貨數量必須大於零」。 +九、箱數與列印 +9.1 草稿/空白 +「列印全部草稿 (N)」--> 確認「確認列印全部草稿?(總數量:N份)」-->「成功列印」。 +提料中:「列印空白頁數標籤」-->「請輸入要列印的標籤數量:」。 +9.2 完成後正式列印(「成品提貨記錄」) +「查看詳情」或列表動作。 +「列印提料單」「列印送貨單標籤」「列印提料單和送貨單標籤」「補印標籤」。 +彈窗「請輸入總箱數」,欄位「箱數」(>=1)。 +成功:「成功列印」。 +9.3 補印 +「補印送貨單標籤」:「起始箱號」「結束箱號」「總箱數」 +校驗:起始>=1、結束>=起始、結束<=總箱數等。 +9.4 送貨路線摘要 +Tab「送貨路線摘要」--> 選「車線」-->「下載報告 (PDF)」。 +若未執完:「此車線仍有 N 張訂單未執拾。是否仍要列印 / 下載送貨路線摘要?」 +9.5 出箱數量 +Tab「成品出倉出箱數量」:按日「2/F 出箱數」「4/F 出箱數」「車線-X 出箱數」「總出箱數」(來自完成時填的箱數)。 +十、查看提貨情況與管理動作 +10.1 查詢 +「目標日期」「重新載入」「樓層」「狀態」(「待撳單」「提貨中」「已完成」)。 +欄含貨車/車線/裝載順序/提票號碼/負責員工/訂單項目數量等。 +10.2 管理(常需 ADMIN) +動作意義(畫面說明意涵)「撤銷領取」清空負責人,單據回待分配,他人可再領「強制完成提貨單」標完成並歸檔,不改已揀數量;適用已全部提交但系統未結案未授權:「僅管理員(ADMIN 權限)可使用」。 +十一、狀態對照(避免搞混三套名稱) +11.1 送貨訂單「來貨狀態」 +鍵畫面pending「待處理」receiving「接收中」completed「已完成」(部分流程)released / picking「已放單」/「提料中」11.2 「查看提貨情況」提票狀態 +鍵畫面白話pending「待撳單」已放單、尚未領取released「提貨中」已撳單/出倉中completed「已完成」提貨完成11.3 提料單通用(pickOrder) +「待處理」「已放單」「提料中」「已完成」 -- -- 與上表用詞接近但場景不同;操作時以目前所在分頁的 Chip 文案為準。十二、成品出倉管理(ADMIN) +「倉庫管理」-->「成品出倉管理」(/finishedGood/management)。 +「提料順序」:上移/下移/置頂/置底、「新增物品」「儲存」「重新載入」。 +「出貨倉位」「入貨倉位」等主檔維護。 +十三、路徑速查 +畫面路徑送貨訂單/doDO 詳情/放單/do/edit?id=成品出倉(主)/doworkbench成品出倉管理/finishedGood/management舊成品出倉/finishedGood(附錄對照用)十四、常見問題速查 +情況建議批量放單後倉庫看不到票核對日期「是日/翌日」、樓層票別、批量/單量、是否加單檢視車線按鈕 0 單換日期/樓層;查「車線-X」「未完成提料單」掃碼一直失敗批號是否上架、是否被留貨/過期/單位不符印不出標籤先選 A4/標籤打印機;完成後記得填「箱數」加單與正單混在一起明確進/出「加單」檢視;合併規則要同店同線同時段畫面突然英文少數錯誤字串尚未進 zh,以實機為準並回報補譯 \ No newline at end of file diff --git a/docs/exports/MTMS_JO_Pick_Production_PutAway_UserGuide.docx b/docs/exports/MTMS_JO_Pick_Production_PutAway_UserGuide.docx new file mode 100644 index 0000000..d5359a2 --- /dev/null +++ b/docs/exports/MTMS_JO_Pick_Production_PutAway_UserGuide.docx @@ -0,0 +1,105 @@ +MTMS 使用說明:工單提料/報工/上架 + 本手冊依目前前端畫面與繁中文案整理,按鈕/選單名稱以畫面上「」內文字為準。 + 適用範圍:放單之後的「工單提料」「工單生產流程」「上架掃碼」(品檢/上架)。 + 建單與排程請先看《MTMS 排程與工單 使用說明》;BOM 請看《MTMS BOM 使用說明》。 + 「一附」章節使用本機資料庫 fpsmsdb 的真實例子(查詢當下快照)。 + 產生日期:依原始碼現況(若畫面改版,請以實際 UI 為準)。 +這份手冊怎麼用 +符號意思「......」畫面上看得到的按鈕、選單、標題-->下一步操作建議閱讀順序: +放單(「搜索工單/ 建立工單」) +提料(「工單提料」) +對料 --> 工序 --> 完成工單(「工單生產流程」) +品檢 --> 上架(「品檢」+「上架掃碼」) +一、整體流程 +【/jo】「規劃中」──「放單」──►「待處理」/「提料中」 + ↓ +【/jodetail】「工單提料」──「查看詳情」──► 掃碼提料 ──「提交」/「提交所有已掃描項目」 + ↓ +【/productionProcess】「工單生產流程」 + ├─(可選)「工單對料」--> 二次掃碼 -->「確認所有提料」 + ├─「查看詳情」--> 各工序「開始」/「訂單完成」或「已完成」(Just Pass) + ↓ + 「完成工單」(確認:「確認要完成此工單嗎?」) + ↓ + 「品檢」-->「確定品檢結果」(可列印/下載 QR) + ↓ +【/putAway】「上架掃碼」:掃貨品 QR --> 掃倉庫 QR -->「確定及上架貨物」 + ↓ +「已上架工單」;工單「已完成」一附、本地資料庫實例 + 狀態中文:pending-->「待處理」、packaging/picking-->「提料中」、processing-->「生產中」、storing-->「待品檢入倉」、completed-->「已完成」。 +例 A:適合練提料(2026-07-29) +「工單編號」「狀態」成品「需求數量」建議下一步JO-260729-038待處理PP2277 烚意粉51「工單提料」開單掃碼JO-260729-008提料中PP2257 咖哩汁箱料粉1繼續提交提料JO-260729-013提料中PP1043 豆豉汁(2磅/包)309繼續提交提料JO-260729-044提料中PP2383 辣椒菜脯265繼續提交提料JO-260729-034生產中PP2302 酸甜蘿蔔粒箱料粉1「工單生產流程」做工序/完成工單怎麼練: +「搜索工單/ 建立工單」-->「預計生產日期」2026-07-29 -->「搜索」。 +「狀態」篩「待處理」找 JO-260729-038,或直接到「工單提料」找同日卡片。 +「生產中」單到「工單生產流程」練「查看詳情」/「完成工單」(完成會改資料,請用測試庫)。 +例 B:狀態與畫面入口對照 +「狀態」常用入口待處理/提料中「工單提料」生產中「工單生產流程」-->「工藝流程」待品檢/待品檢入倉「工單生產流程」-->「品檢」或「待QC上架工單」已完成「已上架工單」/工單搜索「已完成」自行查核用 SQL(選用) +SELECT jo.code, jo.status, i.code, i.name, jo.reqQty +FROM job_order jo +LEFT JOIN bom b ON b.id = jo.bomId +LEFT JOIN items i ON i.id = b.itemId +WHERE jo.deleted = 0 AND DATE(jo.planStart) = '2026-07-29' + AND jo.status IN ('pending','packaging','processing','storing') +ORDER BY jo.status, jo.code +LIMIT 20;二、放單(前置,在「搜索工單/ 建立工單」) +側欄「管理工單」-->「搜索工單/ 建立工單」(/jo)。 +找到「規劃中」工單 -->「放單」或「放單 (N)」。 +放單後狀態變「待處理」/「提料中」,即可去「工單提料」。 +詳情內可見庫存摘要字樣如「可提料項目數量:」「未能提料項目數量:」。 + 亦可在生產流程詳情內對仍屬規劃中的單按「放單」。 +三、工單提料(/jodetail) +3.1 入口與分頁 +側欄「管理工單」--> 「工單提料」;頁標題「工單提料」。 +Tab文案0「工單提料詳情」1「已完成工單記錄」2「物料提料狀態」3「膠茜數目使用數量」3.2 「工單提料詳情」列表 +品類:「全部」「飲料」「箱料粉」「其他」 +樓層:「2F」「3F」「4F」「沒有批號」等 +卡片常見:「工單」「批號」「提料單」「物品名稱」「需求數量」、狀態 Chip +點 「查看詳情」 進入掃碼提料 +3.3 掃碼提料步驟 +點「開始掃碼」(可「停止掃碼」)。 +掃描物料/批號 QR;必要時開「批號QR碼掃描」或「手動輸入」-->「提交」。 +畫面上應出現「QR碼驗證成功。」/「驗證成功!」;進度見「掃碼結果」「提交數量」。 +單行「提交」或一次「提交所有已掃描項目」(進行中「提交中...」)。 +完成後「返回列表」。 +3.4 「已完成工單記錄」 +「查看詳情」「打印版頭紙」(2F/3F/4F)、「打印數量」「打印機」 +「對料狀態」:「對料待處理」/「對料已完成」 +提示語例:「工單已完成提料和對料」 +3.5 提料常見錯誤 +提示處理「此批次已拒收,請掃描另一個批次。」換批「掃描的批次已被其他用戶完全提料。請掃描其他批次。」換可用批「物品數量不足」/數量大於需求/可用量改「提交數量」「請先選擇打印機」列印版頭紙前先選機四、工單生產流程(/productionProcess) +4.1 入口與頂層分頁 +側欄「管理工單」--> 「工單生產流程」。 +Tab文案0「工藝流程」1「待QC上架工單」2「已上架工單」3 - 6各類「儀表板 - ...」4.2 「工藝流程」卡片動作 +按鈕用途「查看詳情」進工序/BOM/對料等「工單對料」提料完成後二次掃批號確認「完成工單」整張 JO 完工(確認:「確認要完成此工單嗎?」;權限常限 ADMIN)「品檢」開品檢 Modal(條件滿足且有入庫行時才出現)詳情內 Tabs:「工單信息」「BOM 材料」「工藝流程」「工藝明細」「工單對料」;另有「返回列表」「取消工單」「刪除工單」等。 +4.3 工單對料(二次掃) +點「工單對料」。 +對已提料批再掃「批號QR碼掃描」(或「手動輸入」)。 +「驗證成功!」後,全部核對完點 「確認所有提料」。 +「返回列表」可能解除指派,勿中途亂退。 +4.4 工序報工(單步) +「查看詳情」-->「工藝流程」/「工藝明細」。 +待處理工序點 「開始」 --> Dialog「掃描操作員和設備」。 +「開始掃碼」:先操作員/員工,再設備 -->「提交並開始」。 +執行中可「暫停」/「繼續」(「暫停原因」);結束該步用 「訂單完成」(填「工序產出」「不良品」「損耗」)。 +若允許略過執行:按鈕「已完成」(Just Pass),確認「確認要通過此工序嗎?」。 + 勿混淆: 工序「訂單完成」=結束一步;卡片「完成工單」=結束整張工單。 +4.5 品檢 +條件滿足後點「品檢」。 +Modal 常見 Tab:「處理來貨及品檢」/「來貨及品檢詳情」。 +填結果後 「確定品檢結果」。 +可「打印機」「列印數量」「列印」「下載QR碼」(供之後上架掃碼)。 +校驗例:「請決定品檢結果」「有未完成品檢項目」「請輸入不合格數量」「請輸入到期日!」。 +亦可從 Tab「待QC上架工單」或提醒鈴深連結進入。五、上架掃碼(/putAway) +5.1 入口 +側欄「倉庫管理」--> 「上架掃碼」;頁標題「上架」。 +5.2 兩段掃碼 +待機:「等待掃瞄中,請掃瞄貨品二維碼開始上架程序」。 +掃貨品/來貨行 QR --> 開 Modal。 +填「是次上架數量」;再掃倉庫 QR(「掃瞄倉庫二維碼」/「請掃瞄倉庫二維碼」)。 +點 「確定及上架貨物」。 +可在「是次上架記錄」核對。 +失敗:「讀取不成功,請重新掃瞄」;數量:「上架數量不得大於 ...」「最小為1」等。入庫行狀態語意:「待上架」-->「已部分上架」-->「已上架」。完成後可在「已上架工單」看到。六、狀態對照(操作頁為準) +代碼畫面planning「規劃中」pending「待處理」packaging / picking「提料中」processing「生產中」pendingQC「待品檢」storing「待品檢入倉」completed「已完成」cancelled「已取消」工序行:「待處理」-->「進行中」-->(可「已暫停」)-->「完成」/「已完成」(Pass)。 +七、常見問題速查 +情況建議提料頁找不到單確認已「放單」;日期/樓層/品類篩選是否過窄對料按鈕灰/沒有提料單未完成、已指派他人、或對料已完成「完成工單」按不到權限或工序未齊;確認提示「確認要完成此工單嗎?」沒有「品檢」按鈕尚未完成工單/無 stock-in 行上架掃不到先品檢並列印/下載 QR;或用 ?stockInLineId= 深連結掃碼驗證失敗換批、確認未拒收、確認單位/可用量八、路徑速查 +畫面路徑搜索/放單/jo工單提料/jodetail工單生產流程/productionProcess上架掃碼/putAway \ No newline at end of file diff --git a/docs/exports/MTMS_M18_DATA_MAPPING.docx b/docs/exports/MTMS_M18_DATA_MAPPING.docx new file mode 100644 index 0000000..f72897f --- /dev/null +++ b/docs/exports/MTMS_M18_DATA_MAPPING.docx @@ -0,0 +1,67 @@ +MTMS (FPSMS) <--> M18 資料對照手冊 +本文件說明 MTMS / FPSMS 與 M18 之間的主檔與交易對應、同步方向,以及已知陷阱。區塊維護方式本手冊(說明、流程、陷阱)人手維護docs/generated/ 對照表腳本產生(見下方)重新產生自動表(改完 enum / mapping 後請跑): +python scripts/generate_m18_mapping_docs.py1. 系統與名詞 +名稱說明MTMS / FPSMS本後端 FPSMS-backend + 前端 FPSMS-frontendM18外部 ERP/主檔與採購/送貨來源系統PullM18 --> MTMS(product / vendor / unit / currency / BOM / business unit / PO / DO)PushMTMS --> M18(例如 GRN、BOM for shop)設定入口:m18/M18Config.kt(m18.config.)、scheduler 見 application.yml / application-prod.yml(scheduler.m18Sync、scheduler.m18Grn)。 +主程式目錄:src/main/java/com/ffii/fpsms/m18/。2. Master API 類型(StSearchType) +完整表見自動產生檔:--> generated/m18-stsearch-types.md摘要:M18 stSearchMTMS 落點proitemsvenshop(type=supplier)virDeptshop(type=shop)unituom_conversion(+ cunit)curcurrencyudfbomforshopbom / materials實作:M18MasterDataService。 +3. 貨品類型(最常查) +3.1 同步規則(自動表) +--> generated/m18-item-type-mapping.md程式:M18MasterDataService.saveProduct / saveProducts 依 pro.udfProducttype:Consumable Material --> consumables +Non-consumable Material --> non-consumables +Product --> fg +WIP --> sfg +Item --> item +(其他,含 CMB) --> mat <-- defaultEnum 定義:modules/master/web/models/NewItemRequest.kt(ItemType、M18ItemType)。3.2 UI 顯示(存貨) +存貨 Type 欄:t(itemType),翻譯在 FPSMS-frontend/src/i18n/zh/inventory.json。items.type存貨頁(zh)mat原料fg成品sfg / wip半成品consumables / cmb消耗品non-consumables / nm非消耗品/雜項 系統 沒有「產品」這個 items.type。M18 的 Product 對應 MTMS fg(成品)。 +3.3 可手動改嗎? +可以:Settings --> Items --> Edit --> Type(ProductDetails.tsx:fg / wip / mat / cmb / nm)。注意:之後若再跑 product sync,type 會依 M18 udfProducttype 覆寫(含再次落到 mat)。3.4 已知陷阱:CMB +M18 實務上可出現 "udfProducttype": "CMB"(例如蔗糖水 MG1852)。"CMB" != "Consumable Material" +也不等於前端的 cmb +--> sync 走 else --> mat --> 存貨顯示 原料 +若要顯示消耗品:需改 mapping(例如把 CMB 對到 consumables),或在 M18 改成已支援的字串;僅手動改 MTMS 可能被下次 sync 蓋掉。4. 供應商與店鋪 +方向M18MTMSPull vendorsvenshop,ShopType.SUPPLIER(supplier)Pull business unitsvirDeptshop,ShopType.SHOP(shop)鍵:shop.m18Id、shop.code。名稱優先 descZhTW --> descZhCN --> desc。 +ShopType:modules/master/enums/ShopType.kt。5. 單位(UoM) +M18MTMSUnit master (unit)uom_conversion(code、udfudesc、udfShortDesc、m18Id...)Cunit 明細M18CunitService.replaceForUnitItem 級採購/庫存/銷售單位在 sync product price 時寫入 item_uom(見 M18MasterDataService product 區塊)。 +PO/DO 行常同時保留:欄位意義qty / uomIdMTMS 業務單位(例如採購單位換算後)qtyM18 / uomIdM18M18 原始單位數量PO 換算邏輯見 M18PurchaseOrderService(convertQtyToPurchaseQty)。 +6. 貨幣、BOM +M18MTMSServiceCurrencycurrencysaveCurrenciesBOM (udfbomforshop)bom / bom_materialsaveBomsShop BOM 回寫 M18:M18BomForShopService(push)。 +7. 交易文件(摘要) +文件方向MTMS 主表筆記POM18 --> MTMSpurchase_order / purchase_order_linem18Id / data log;qty 可能換算DOM18 --> MTMSdelivery_order / delivery_order_line含 qtyM18、uomIdM18GRNMTMS --> M18stock-in --> M18 GRN API部分 m18CreatedUId 不送 GRN(見下)GRN 略過規則 +m18/M18GrnRules.kt:M18 PO createUid備註行為2569legato不 post GRN2676xtech不 post GRN8. Config 鍵(對照時常用) +見 M18Config / application-.yml:m18.config.seriesId.pp|pf|sc|se|sf|sr +m18.config.beId.pp|pf|toa +m18.config.supplier-not.material-po +m18.config.supplier.shop-po / oem-po +scheduler.m18Sync.enabled +scheduler.m18Grn.createEnabled +9. 驗證用 SQL 範例 +-- 某貨品目前 type(決定存貨顯示) +SELECT code, name, type, m18Id, m18LastModifyDate +FROM items +WHERE deleted = 0 AND code = 'MG1852'; + +-- 統計 type 分佈 +SELECT type, COUNT(*) AS cnt +FROM items +WHERE deleted = 0 +GROUP BY type +ORDER BY cnt DESC;若 M18 回傳 udfProducttype 可與上表比對;對不上表中「exact string」者皆會變 mat。10. 維護約定 +改 mapping:先改 Kotlin enum / when,再跑 python scripts/generate_m18_mapping_docs.py,把 docs/generated/ 一併 commit。 +改說明/陷阱:只改本檔,勿手改 docs/generated/。 +新發現的 M18 值(如新的 udfProducttype):記入 generated 腳本的 KNOWN_UNMAPPED_M18_VALUES,或補正式 mapping 後重生。 +PR 若動到 NewItemRequest.kt / M18MasterDataService product type 分支,review 應檢查 generated docs 是否已更新。 +11. 相關程式索引 +主題路徑Item / M18 type enumsmodules/master/web/models/NewItemRequest.ktProduct syncm18/service/M18MasterDataService.ktStSearchm18/model/M18MasterDataRequest.ktShop typemodules/master/enums/ShopType.ktGRN skipm18/M18GrnRules.ktPO syncm18/service/M18PurchaseOrderService.ktDO syncm18/service/M18DeliveryOrderService.ktBOM-->M18m18/service/M18BomForShopService.kt存貨 Type 顯示InventoryTable.tsx + i18n/zh/inventory.json物品 Type 下拉CreateItem/ProductDetails.tsx +M18 udfProducttype --> MTMS items.type +_Generated: 2026-08-01 10:01 UTC_Source of truthEnums: NewItemRequest.kt --> ItemType, M18ItemType +Sync: M18MasterDataService.saveProduct / saveProducts (when (pro.udfProducttype)) +UI labels (inventory): FPSMS-frontend/src/i18n/zh/inventory.json +Sync mapping +M18 udfProducttype (exact string)M18ItemTypeMTMS items.typeItemTypeInventory UI (zh)Consumable MaterialCONSUMABLESconsumablesCONSUMABLES消耗品Non-consumable MaterialNONCONSUMABLESnon-consumablesNONCONSUMABLES非消耗品ProductFGfgFG成品WIPSFGsfgSFG半成品ItemITEMitemITEM貨品(any other value / empty) -- matMATERIAL原料Enum inventories +M18ItemType +ConstantString valueUsed in sync when?CONSUMABLESConsumable MaterialyesNONCONSUMABLESNon-consumable MaterialyesFGProductyesSFGWIPyesITEMItemyesItemType (MTMS stored values) +Constantitems.typeInventory UI (zh)MATERIALmat原料CONSUMABLESconsumables消耗品NONCONSUMABLESnon-consumables非消耗品FGfg成品SFGsfg半成品ITEMitem貨品Known gaps (not auto-mapped) +M18 value seenEffectNotesCMB--> mat (else)Seen on M18 pro.udfProducttype (e.g. MG1852). Falls through to mat.Frontend Settings --> Items edit also offers cmb / wip / nm as local types; +those are not written by the current M18 udfProducttype mapper.Regenerate +python scripts/generate_m18_mapping_docs.pyM18 StSearchType (master list APIs) +_Generated: 2026-08-01 10:01 UTC_Source: m18/model/M18MasterDataRequest.ktConstantstSearch valueTypical MTMS sync targetPRODUCTproitems (+ item_uom via prices)VENDORvenshop (type=supplier)CUSTOMERcus(enum present; sync usage varies)UNITunituom_conversion (+ m18 cunit)CURRENCYcurcurrencyBOMudfbomforshopbom / bom_material (udfbomforshop)BUSINESS_UNITvirDeptshop (type=shop) \ No newline at end of file diff --git a/docs/exports/MTMS_M18_DATA_MAPPING.xlsx b/docs/exports/MTMS_M18_DATA_MAPPING.xlsx new file mode 100644 index 0000000..89968a7 Binary files /dev/null and b/docs/exports/MTMS_M18_DATA_MAPPING.xlsx differ diff --git a/docs/exports/MTMS_Schedule_JobOrder_UserGuide.docx b/docs/exports/MTMS_Schedule_JobOrder_UserGuide.docx new file mode 100644 index 0000000..0673f4c --- /dev/null +++ b/docs/exports/MTMS_Schedule_JobOrder_UserGuide.docx @@ -0,0 +1,177 @@ +MTMS 使用說明:排程 --> 開工單 + 本手冊依目前前端畫面與繁中文案整理,按鈕/選單名稱以畫面上「」內文字為準。 + 適用範圍:側欄「排程」、管理工單(搜索/建立、提料、生產流程)。 + 「一附」章節使用本機資料庫 fpsmsdb 的真實例子(查詢當下快照);其他環境請改日期再對。 + 產生日期:依原始碼現況(若畫面改版,請以實際 UI 為準)。 +這份手冊怎麼用 +符號意思「......」畫面上看得到的按鈕、選單、標題-->下一步操作節點流程中的一個階段(狀態/畫面)建議閱讀順序: +先排期(側欄「排程」) +再放單/建工單(「管理工單」-->「搜索工單/ 建立工單」) +提料 --> 生產 --> 完成 +一、整體流程(從排期到完工) +【排程】預測/查看排期 + ↓ 「自動生成工單」 或 手動「建立工單」 +【規劃中】工單已建立、尚未放單 + ↓ 「放單」 +【待提料/提料中】 + ↓ 「工單提料」掃碼提交 +【生產中】「工單生產流程」各工序開始/完成 + ↓ 「完成工單」 +【品檢/上架】「待QC上架工單」-->「已上架工單」也可不經排程,在「搜索工單/ 建立工單」直接「建立工單」(手動工單)。一附、本地資料庫實例(方便對照畫面) + 以下數字來自本機 fpsmsdb 查詢結果,用於說明「畫面上大概會看到什麼」。 + 不同環境/日期資料會不同;請用「搜索」條件改成你們當天日期再核對。 + 狀態中文依前端翻譯:planning-->「規劃中」、pending-->「待處理」、packaging-->「提料中」、processing-->「生產中」、storing-->「待品檢入倉」、completed-->「已完成」。 +例 A:已有排期、尚未產工單(適合練習「詳細」-->「自動生成工單」) +在「生產排程」用「生產日期」搜 2026-08-03,本機有一筆細排(production_schedule.id = 928,type = detailed):畫面概念本機資料「生產日期」2026-08-03「預計生產數」(約)17,334「成品款數」(約)47打開「詳細」後,明細列會類似(節錄): +「編號」「名稱」約「需求數量」約「存貨量」約需工單數PP1175鮮檸檬汁(P+4)1,4061,40074PP1224柚子蒜蓉汁140501PP0259牛肉水(2KG/包)415141PP2284油醋汁(1KG/包)28321PP2390熱情香果醬12164怎麼練: +「排程」--> 生產日期選 2026-08-03 -->「搜索」-->「詳細」。 +對照上表成品是否出現在明細。 +若環境允許,再試「自動生成工單」(會真正建 JO,請在測試庫操作)。 + 查詢當下:此排期尚未有透過 prodScheduleLineId 掛上的工單(適合示範「產工單前」)。 +例 B:排期已產工單且已完成(歷史成功路徑) +本機較早一筆:2026-06-17 細排(約預計生產 17,421、成品款數 33),曾產生多張 type = detailed 工單,例如:「工單編號」「狀態」「需求數量」成品JO-260617-008已完成568PP1234 日式咖哩汁JO-260617-012已完成15PP2288 香水檸檬汁P+3JO-260617-015已完成600PP1136 白粥JO-260617-024已完成28PP2284 油醋汁(1KG/包)JO-260617-030已完成508PP2290 韓式豬軟骨怎麼練: +「搜索工單/ 建立工單」-->「預計生產日期」填 2026-06-17 -->「搜索」。 +找上表工單編號,點「查看」看已完成工單長怎樣。 +對照:這類工單來自排程 release(type 在庫為 detailed),不是手動「建立工單」的 manual。 +例 C:手動「建立工單」(不經排程) +本機 2026-07-29 工單幾乎皆為手動(type = manual,且未掛排期行)。例子:「工單編號」「狀態」「需求數量」成品JO-260729-045待處理3PP2390 熱情香果醬JO-260729-038待處理51PP2277 烚意粉JO-260729-034生產中1PP2302 酸甜蘿蔔粒箱料粉JO-260729-044提料中265PP2383 辣椒菜脯怎麼練: +「預計生產日期」選 2026-07-29 -->「搜索」。 +用「狀態」篩「待處理」--> 應能看到類似 JO-260729-045。 +若該單仍「規劃中」,可練習「放單」;若已是「待處理」,可接「工單提料」。 +同日狀態分佈(本機快照):約 17 張「待處理」、24 張「提料中」、4 張「生產中」 -- -- 正好對應「放單後 --> 提料 --> 生產」不同節點。例 D:近兩週工單狀態分佈(看流程卡在哪) +本機最近約 14 天(未隱藏工單)概況:「狀態」約筆數使用者下一步常做什麼已完成359可當完成範本「查看」待處理42「工單提料」提料中37繼續掃碼/提交提料待品檢入倉33「工單生產流程」品檢/上架生產中4「工單生產流程」繼續工序例 E:還在「規劃中」的單(適合練刪除/放單) +本機仍有例如:JO-260427-039(沙薑醬 PP2205,需求約 291,「規劃中」)。若只需練習「查看」--> 看「放單」「刪除工單」按鈕是否出現。 +勿在正式/共用庫隨意刪除;測試庫才建議真的按「刪除工單」。 +自行查核用 SQL(選用) +-- 某日排期摘要 +SELECT id, DATE(produceAt) AS produce_date, type, + totalEstProdCount, totalFGType +FROM production_schedule +WHERE deleted = 0 AND DATE(produceAt) = '2026-08-03'; + +-- 該排期成品明細(前 20) +SELECT i.code, i.name, psl.prodQty, psl.stockQty, psl.needNoOfJobOrder +FROM production_schedule_line psl +JOIN production_schedule ps ON ps.id = psl.prodScheduleId +JOIN items i ON i.id = psl.itemId +WHERE ps.deleted = 0 AND psl.deleted = 0 + AND DATE(ps.produceAt) = '2026-08-03' +ORDER BY psl.itemPriority, i.code +LIMIT 20; + +-- 某日工單+狀態 +SELECT code, status, type, reqQty, DATE(planStart) AS plan_date +FROM job_order +WHERE deleted = 0 AND (isHidden = 0 OR isHidden IS NULL) + AND DATE(planStart) = '2026-07-29' +ORDER BY status, code;二、排程(側欄「排程」) +2.1 入口 +側欄點 「排程」 +進入頁面標題:「生產排程」(路徑通常為 /ps) + 說明:系統另有舊版「需求預測」「詳細排程」頁(/scheduling/...),目前側欄主入口是「排程」-->「生產排程」。以下以主入口為準。 +2.2 畫面上常見按鈕 +按鈕/功能用途(白話)「預測排期」依日期/天數計算產生預計排期「搜索」依「生產日期」等條件查已有排期「詳細」打開該筆排期的明細「自動生成工單」依此排期一次產生多張工單(在詳情裡)「關閉」關閉詳情視窗「排期設定」庫存/排期相關設定與匯入匯出「匯出計劃/物料需求Excel」匯出計劃與物料需求「匯出送貨單數量」匯出送貨單數量區間資料列表常見欄位:「生產日期」「預計生產數」「成品款數」等。 +2.3 操作步驟:做出排期(Happy path) +進入 「排程」 --> 「生產排程」。 +點 「預測排期」。 +在對話框 「準備生成預計排期」 中填: +「開始日期」 +「排期日數」 +點 「計算預測排期」。 +成功時畫面會提示類似 「成功計算排期!」;失敗會提示計算錯誤或不明狀況。 +選擇「生產日期」後點 「搜索」,在列表找到該日排期。 +點該列 「詳細」,打開 「排期詳細」。 +確認內容無誤後,點 「自動生成工單」 --> 系統依排期建立工單。 +點 「關閉」 結束。 +2.4 節點說明(排程) +節點使用者在做什麼下一個常見動作尚未有排期進「生產排程」但列表空/無當日資料「預測排期」已有排期列表用「搜索」找日期「詳細」排期詳細已打開檢查預計生產內容「自動生成工單」已生成工單工單出現在「搜索工單/ 建立工單」去「放單」2.5 排程常見問題 +情況建議處理「計算預測排期」失敗記下畫面錯誤訊息;檢查開始日期/天數;稍後再試或聯絡系統/IT「自動生成工單」失敗畫面可能顯示失敗訊息;確認排期內容是否完整、BOM/物料是否齊全找不到某日排期確認「生產日期」與「搜索」條件;必要時再跑一次「預測排期」想改數量再開工單主入口 /ps 詳情偏「一次自動生成」;若需逐行改量/發佈,需使用舊版「詳細排程」編輯頁(見附錄)2.6 附錄:舊版「詳細排程」/「需求預測」(進階) +若單位仍使用直連網址:畫面標題(約)重點按鈕需求預測列表「需求預測」「測試粗排」「搜索」「詳情」需求預測詳情「成品及物料需求預測詳情」多為檢視(依成品/依物料、「查看 BOM」)詳細排程列表「詳細排程」「詳細排程」(產生)、「匯出排程」「詳情」FG 生產排程「成品生產排程」/「FG 生產排程」列「發佈」、改「需求數量」後儲存、「生成工單」、「返回」注意(舊版細排詳情): +「生成工單」 常僅允許生產日期為今天;否則可能跳出英文提示(畫面未必有完整中文翻譯)。 +前端沒有「從某一筆需求預測一鍵跳到對應詳細排程」的按鈕;兩條線在畫面上是分開的。 +設定選單另有 「需求預測設定」(成品排除日、星期等),屬主檔設定,不是每日排期操作。三、工單:搜索/建立/放單 +3.1 入口 +側欄 「管理工單」 --> 「搜索工單/ 建立工單」頁面標題:「搜索工單/建立工單」3.2 畫面上常見按鈕 +按鈕用途「建立工單」手動開一張新工單「放單」/「放單 (N)」將「規劃中」工單放出,進入後續提料「重置」清空搜尋條件「查看」開「工單詳情」「取消工單」取消後工單從列表隱藏(非規劃中等情況)搜尋條件常見:「工單編號」「成品/半成品名稱」「預計生產日期」~「預計生產日期至」「工單類型」「狀態」。 +3.3 操作步驟:手動建立工單 +點 「建立工單」,打開標題為 「建立工單」 的視窗。 +填寫: +「BOM」 +「標準生產數」(通常唯讀) +「批數」 +「需求數量」(常由標準x批數自動帶出) +「工單類型」(選 BOM 後可能自動對應) +「生產優先序」(常見預設約 50,範圍約 1 - 100) +「預計生產日期」 +可勾選「記住為預設日期」 +點 「建立」。 +成功後通常提示資料已更新;新工單多為 「規劃中」 狀態。 +在列表勾選/找到該工單,點 「放單」(或上方「放單 (N)」批量)。 +3.4 操作步驟:從排程來的工單 +在「生產排程」詳情已按 「自動生成工單」(或舊版「生成工單」)。 +到 「搜索工單/ 建立工單」,用「預計生產日期」等搜尋。 +確認工單後執行 「放單」。 +3.5 工單詳情(「查看」) +標題:「工單詳情」常見分頁/區塊:「工單信息」 +「BOM 材料」 +「工藝流程」/「工藝明細」 +常見動作:按鈕何時可用(概念)說明「放單」多為「規劃中」放出工單「刪除工單」多為仍在「規劃中」確認後無法復原「取消工單」已非規劃中等確認後從列表隱藏;已上架通常不可取消「儲存」/「取消」編輯 Dialog存檔或放棄「返回列表」 -- 回搜尋頁庫存摘要常見字樣:「所需貨品項目數量:」「可提料項目數量:」「未能提料項目數量:」。 +刪除確認:「確認刪除工單」 +「確定要刪除此工單嗎?此操作無法復原。」 +取消確認:「確認取消工單」 +「確定要取消此工單嗎?工單將從列表中隱藏。」 +按鈕:「取消工單」/「取消」(後者為放棄這次取消操作) +3.6 工單狀態(搜尋/列表常見) +畫面上可能出現(實際以該列顯示為準):狀態(常見中文)白話「規劃中」已建立,尚未放單「待處理」/待提料相關已放單,等提料「提料中」/「進行中」正在提料或進行中「已開始工序」/「生產中」已進入生產工序「成品入倉中」/「待品檢入倉」生產後待品檢/入倉「已上架」已完成上架「已取消」已取消篩選下拉亦可能見:「待處理」「提料中」「已開始工序」「成品入倉中」「已上架」「已取消」等。 +3.7 工單建立/放單常見問題 +情況建議處理「建立」沒反應檢查 BOM、批數、日期、工單類型是否已填;看是否有必填未選「放單」失敗記下提示;檢查是否仍為可放單狀態、物料/權限想刪掉剛建錯的單「規劃中」-->「查看」-->「刪除工單」--> 確認已放單但不想做了「取消工單」--> 確認隱藏;已上架通常不能取消批量「放單 (N)」部分失敗依成功/失敗筆數訊息,對失敗單再個別處理四、工單提料 +4.1 入口 +「管理工單」 --> 「工單提料」4.2 常見分頁 +「工單提料詳情」 +「已完成工單記錄」 +「物料提料狀態」 +(及其他與膠茜/用量相關分頁,以畫面為準) +4.3 操作步驟(概念) +在「工單提料詳情」找到已放單、待提料的工單。 +點 「查看詳情」 進入提料執行畫面。 +依畫面使用 「開始掃碼」、掃描物料/批號。 +需要時點 「提交」/「提交所有已掃描項目」/「確認」。 +完成後可在「已完成工單記錄」核對。 +對料相關:生產流程中可能有 「工單對料」,並有 「確認所有提料」。4.4 提料常見問題 +情況建議處理批號不符依 Dialog 提示按「確認」,改掃正確批號庫存不足/過期依畫面提示;先補貨或調批號,勿強行提交掃錯要重來用畫面上的「取消」/清除已掃項目(以當頁按鈕為準)後重掃提料狀態常見:「待提料」「已掃碼」「對料待處理」「對料已完成」;提料單「已放單」「已完成」。 +五、工單生產流程 +5.1 入口 +「管理工單」 --> 「工單生產流程」5.2 常見分頁/區塊 +「工藝流程」 +「待QC上架工單」 +「已上架工單」 +「儀表板 - 工單狀態」等 +5.3 工序操作(Happy path) +在「工藝流程」找到工單,點 「查看詳情」。 +(可選)「工單對料」 確認物料。 +各工序: +「開始」 開工序 +可依畫面掃 操作員/設備(未掃可能提示「請先掃描操作員編號」「請掃描設備編號」) +可 「暫停」 +「完成步驟」/「通過」/「已完成」(Just Pass 等,以畫面為準) +確認通過時可能問:「確認要通過此工序嗎?」 +全部就緒後點 「完成工單」,確認:「確認要完成此工單嗎?」 +需要時做 「品檢」,再到「待QC上架工單」完成上架,最後在「已上架工單」可見。 +其他可能按鈕:「提交並開始」「提交包裝袋消耗」等。5.4 工序狀態(常見) +「待處理」「進行中」「已暫停」「完成」/「已完成」「通過」「未開始」「已停止」「已取消」等。5.5 生產常見問題 +情況建議處理「完成工單」按不了通常尚有工序未完成或條件未滿足;先完成各步驟掃碼驗證失敗「驗證失敗. 請檢查操作員和設備.」--> 重掃正確編號API/系統錯誤「發生錯誤,請稍後再試。」--> 稍後重試或聯絡支援做到一半要停用「暫停」;勿與「取消工單」混淆(取消是整張工單層級)六、取消、刪除、返回 -- -- 怎麼選? +你想做的事建議用哪個按鈕結果建錯、還在規劃中,不要了「刪除工單」刪除,不可復原已放單/進行中,不要繼續「取消工單」確認後從列表隱藏只是關掉視窗、不改資料「關閉」/「取消」/「返回」「返回列表」不取消工單本身工序暫停一下「暫停」工單仍在,之後可再「開始」七、快速檢查清單(每日) +「排程」--> 需要時「預測排期」-->「搜索」-->「詳細」-->「自動生成工單」 +「搜索工單/ 建立工單」--> 核對當日工單 -->「放單」 +「工單提料」--> 掃碼提交 +「工單生產流程」--> 各工序開始/完成 -->「完成工單」--> 品檢/上架 +手動補單:同頁「建立工單」-->「建立」-->「放單」。八、文件維護說明(給內部) +文案來源:前端 i18n/zh/navigation.json、jo.json、schedule.json、productionProcess.json,以及「生產排程」頁硬編碼繁中。 +主程式入口: +排程:FPSMS-frontend/src/app/(main)/ps/page.tsx +工單搜尋/建立:.../jo/page.tsx、JoWorkbenchSearch、JoCreateFormModal +提料:.../jodetail/ +生產:.../productionProcess/ +若按鈕改名或流程改版,請同步改本手冊,並重新匯出 Word。 +重新匯出 Word(需已安裝套件):pip install python-docx +python scripts/export_user_guide_office.py產出:docs/exports/MTMS_Schedule_JobOrder_UserGuide.docx \ No newline at end of file diff --git a/docs/generated/m18-item-type-mapping.md b/docs/generated/m18-item-type-mapping.md new file mode 100644 index 0000000..cc83c1c --- /dev/null +++ b/docs/generated/m18-item-type-mapping.md @@ -0,0 +1,60 @@ + + +# M18 `udfProducttype` → MTMS `items.type` + +_Generated: 2026-08-01 10:01 UTC_ + +**Source of truth** + +- Enums: `NewItemRequest.kt` → `ItemType`, `M18ItemType` +- Sync: `M18MasterDataService.saveProduct` / `saveProducts` (`when (pro.udfProducttype)`) +- UI labels (inventory): `FPSMS-frontend/src/i18n/zh/inventory.json` + +## Sync mapping + +| M18 `udfProducttype` (exact string) | `M18ItemType` | MTMS `items.type` | `ItemType` | Inventory UI (zh) | +|---|---|---|---|---| +| `Consumable Material` | `CONSUMABLES` | `consumables` | `CONSUMABLES` | 消耗品 | +| `Non-consumable Material` | `NONCONSUMABLES` | `non-consumables` | `NONCONSUMABLES` | 非消耗品 | +| `Product` | `FG` | `fg` | `FG` | 成品 | +| `WIP` | `SFG` | `sfg` | `SFG` | 半成品 | +| `Item` | `ITEM` | `item` | `ITEM` | 貨品 | +| *(any other value / empty)* | — | `mat` | `MATERIAL` | 原料 | + +## Enum inventories + +### `M18ItemType` + +| Constant | String value | Used in sync `when`? | +|---|---|---| +| `CONSUMABLES` | `Consumable Material` | yes | +| `NONCONSUMABLES` | `Non-consumable Material` | yes | +| `FG` | `Product` | yes | +| `SFG` | `WIP` | yes | +| `ITEM` | `Item` | yes | + +### `ItemType` (MTMS stored values) + +| Constant | `items.type` | Inventory UI (zh) | +|---|---|---| +| `MATERIAL` | `mat` | 原料 | +| `CONSUMABLES` | `consumables` | 消耗品 | +| `NONCONSUMABLES` | `non-consumables` | 非消耗品 | +| `FG` | `fg` | 成品 | +| `SFG` | `sfg` | 半成品 | +| `ITEM` | `item` | 貨品 | + +## Known gaps (not auto-mapped) + +| M18 value seen | Effect | Notes | +|---|---|---| +| `CMB` | → `mat` (else) | Seen on M18 pro.udfProducttype (e.g. MG1852). Falls through to mat. | + +Frontend Settings → Items edit also offers `cmb` / `wip` / `nm` as local types; +those are **not** written by the current M18 `udfProducttype` mapper. + +## Regenerate + +```bash +python scripts/generate_m18_mapping_docs.py +``` diff --git a/docs/generated/m18-stsearch-types.md b/docs/generated/m18-stsearch-types.md new file mode 100644 index 0000000..b863129 --- /dev/null +++ b/docs/generated/m18-stsearch-types.md @@ -0,0 +1,17 @@ + + +# M18 `StSearchType` (master list APIs) + +_Generated: 2026-08-01 10:01 UTC_ + +**Source:** `m18/model/M18MasterDataRequest.kt` + +| Constant | `stSearch` value | Typical MTMS sync target | +|---|---|---| +| `PRODUCT` | `pro` | items (+ item_uom via prices) | +| `VENDOR` | `ven` | shop (`type=supplier`) | +| `CUSTOMER` | `cus` | (enum present; sync usage varies) | +| `UNIT` | `unit` | uom_conversion (+ m18 cunit) | +| `CURRENCY` | `cur` | currency | +| `BOM` | `udfbomforshop` | bom / bom_material (udfbomforshop) | +| `BUSINESS_UNIT` | `virDept` | shop (`type=shop`) | diff --git a/docs/user-guides/MTMS_BOM_使用說明.md b/docs/user-guides/MTMS_BOM_使用說明.md new file mode 100644 index 0000000..5106c49 --- /dev/null +++ b/docs/user-guides/MTMS_BOM_使用說明.md @@ -0,0 +1,256 @@ +# MTMS 使用說明:BOM(匯入/啟停/應用) + +> 本手冊依目前前端畫面與繁中文案整理,**按鈕/選單名稱以畫面上「」內文字為準**。 +> 適用範圍:側欄「設定」下的 BOM 相關頁、建立工單時選 BOM、排程「自動生成工單」。 +> **「一附」章節**使用本機資料庫 `fpsmsdb` 的真實例子(查詢當下快照);其他環境請改條件再對。 +> 產生日期:依原始碼現況(若畫面改版,請以實際 UI 為準)。 + +--- + +## 這份手冊怎麼用 + +| 符號 | 意思 | +|------|------| +| 「……」 | 畫面上看得到的按鈕、選單、標題 | +| → | 下一步操作 | + +建議閱讀順序: + +1. **先匯入/啟用 BOM**(「設定」→「匯入 BOM」) +2. **再檢查單位問題**(ADMIN:「BOM / 物料單位問題」) +3. **再建工單/排程產單**(選 active BOM) + +--- + +## 一、整體流程(BOM 從主檔到工單) + +```text +【匯入 BOM】Excel 上傳並檢查 →「確認匯入」 + ↓ +【BOM 明細】確認「啟用」/必要時「停用」 + ↓(選做)「BOM 權重得分」調整加權 + ↓(ADMIN)「BOM / 物料單位問題」巡檢 + ↓ +【建工單】「建立工單」選「BOM」 + 或【排程】「自動生成工單」(僅含具 BOM 的物料) +``` + +--- + +## 一附、本地資料庫實例(方便對照畫面) + +> 以下數字來自本機 `fpsmsdb` 查詢結果。**不同環境/日期資料會不同**。 +> BOM 狀態:`active`→「啟用」、`inactive`→「停用」。本機約有 **214** 筆啟用、**3** 筆停用。 + +### 例 A:啟用中的成品 BOM(適合在「BOM 明細」搜索) + +| 「物品編號」 | 「物品名稱」 | 「BOM 狀態」 | 約材料筆數 | +|--------------|--------------|--------------|------------| +| PP2167 | 牛丼 | 啟用 | 10 | +| PP2277 | 烚意粉 | 啟用 | 3 | +| PP2390 | 熱情香果醬 | 啟用 | 4 | +| PP1224 | 柚子蒜蓉汁 | 啟用 | 5 | +| PP1136 | 白粥 | 啟用 | 9 | +| PP0259 | 牛肉水(2KG/包) | 啟用 | 0(無材料列,排程/提料前宜先確認) | + +**怎麼練:** + +1. 「設定」→「匯入 BOM」→ Tab「BOM 明細」。 +2. 「物品編號」填 **PP2167** →「搜索」。 +3. 載入後看「基本資訊」「材料清單」「製程與設備」;「BOM 狀態」應為「啟用」。 + +### 例 B:材料清單長什麼樣(PP2167 牛丼) + +| 「物品編號」 | 「物品名稱」 | 約「基本數量」 | +|--------------|--------------|----------------| +| MA0471 | 日式超薄肥牛 | 272.16 | +| FA0164 | 洋蔥絲 | 145.15 | +| GI3236 | 清水 | 60.00 | +| PP2214 | 丼飯汁(2磅/包) | 41.73 | +| MG1507 | 江泉牌燒肉汁(1.8L/支) | 9.00 | +| MG1299 | 李錦記特選老抽(8L/桶) | 8.00 | +| MG0302 | 佛手牌味粉(10lb/罐) | 4.54 | +| MG0239 | 韓國雪白幼砂糖(30kg/包) | 4.54 | + +### 例 C:用 BOM 建工單(接《排程與工單》手冊) + +本機 **2026-07-29** 手動工單多掛在 active BOM 上,例如: + +| 「工單編號」 | 「狀態」 | 成品 BOM | 「需求數量」 | +|--------------|----------|----------|--------------| +| JO-260729-038 | 待處理 | PP2277 烚意粉 | 51 | +| JO-260729-044 | 提料中 | PP2383 辣椒菜脯 | 265 | + +**怎麼練:**「搜索工單/ 建立工單」→「建立工單」→「BOM」下拉選 **PP2277**(僅顯示啟用中的 BOM)。 + +### 自行查核用 SQL(選用) + +```sql +SELECT b.code, i.name, b.status, + (SELECT COUNT(*) FROM bom_material bm + WHERE bm.bomId = b.id AND IFNULL(bm.deleted,0)=0) AS mats +FROM bom b +JOIN items i ON i.id = b.itemId +WHERE b.deleted = 0 AND b.code = 'PP2167'; + +SELECT i.code, i.name, bm.qty +FROM bom_material bm +JOIN items i ON i.id = bm.itemId +JOIN bom b ON b.id = bm.bomId +WHERE b.code = 'PP2167' AND IFNULL(bm.deleted,0)=0; +``` + +--- + +## 二、匯入 BOM(「設定」→「匯入 BOM」) + +### 2.1 入口 + +- 側欄 **「設定」** → **「匯入 BOM」**(路徑通常為 `/settings/importBom`) +- 頁內標題可能顯示英文 **Import BOM**;側欄與 Tab 以繁中「匯入 BOM」為準。 + +### 2.2 兩個分頁 + +| Tab | 用途 | +|-----|------| +| 「匯入 BOM」 | 上傳 Excel、檢查、確認寫入 | +| 「BOM 明細」 | 查已有 BOM、啟用/停用 | + +### 2.3 操作步驟:上傳並匯入 + +1. 開 Tab **「匯入 BOM」**。 +2. **「選擇 BOM Excel 檔案」** →「選擇檔案」或「選擇資料夾」(可多選 `.xlsx`)。 +3. 確認「已選 N 個檔案」後,點 **「上傳並檢查」**。 + - 進行中:「上傳與檢查中…」、進度「已檢查 x / y 個檔案…」。 +4. 檢視結果: + - 「正確 BOM 列表(可匯入)」 + - 「問題 BOM 列表」 +5. 必要時勾選類型相關選項(如「飲料」「箱料粉」等)或用「搜索檔名」篩選。 +6. 確認無誤後點 **「確認匯入」**。 +7. 成功提示類似 **「匯入完成」**;亦可先「下載檢查結果 Excel」留底。 +8. 若要重來:點「返回重選檔案」。 + +### 2.4 常見錯誤(匯入) + +| 畫面提示 | 建議處理 | +|----------|----------| +| 「請至少選擇一個 .xlsx 檔案」 | 先選檔再上傳 | +| 「上傳或檢查失敗,請稍後再試。」/伺服器 500 | 檢查網路/檔案格式後重試 | +| 檔名重複相關提示(_2、_3…) | 依提示整理檔名後重傳 | +| 「匯入失敗,請查看主控台。」 | 記下失敗檔,修正 Excel 後只重傳問題檔 | + +--- + +## 三、BOM 明細:查詢與啟停 + +### 3.1 搜索 + +1. Tab **「BOM 明細」**。 +2. 「物品編號」/「物品名稱」→「搜索」/「重置」。 +3. 多筆時:「找到多筆 BOM,請選擇一筆載入明細」(按鈕上會標「成品|半成品」「啟用|停用」)。 + +### 3.2 可改什麼 + +| 區塊 | 說明 | +|------|------| +| 「BOM 狀態」 | 「啟用」/「停用」→「儲存狀態」 | +| 「基本資訊」 | 產出數量、類型、過敏原、色深/浮沉/濃淡、時段、複雜度、基礎得分等(多為檢視) | +| 「材料清單」 | 物品編號/名稱、基本/庫存/銷售數量與單位 | +| 「製程與設備」 | 製程檢視 | + +> **注意:** 完整欄位線上「編輯/儲存」目前前端關閉(`SHOW_BOM_FULL_EDIT = false`)。日常維護以 **Excel 匯入** 與 **啟用/停用** 為主。 + +### 3.3 何時「停用」 + +- 配方作廢、暫不允許再建工單時,將「BOM 狀態」改「停用」並「儲存狀態」。 +- 「建立工單」的 BOM 下拉**只列出啟用**項目;停用後新單選不到該 BOM。 + +--- + +## 四、BOM 權重得分 + +### 4.1 入口 + +- 「設定」→ **「BOM 權重得分」**(`/settings/bomWeighting`) + +### 4.2 分頁 + +| Tab | 用途 | +|-----|------| +| 「BOM 加權」 | 調整各評分項目的「權重」(總和須=1) | +| 「BOM得分」 | 查看各貨品「基礎得分」 | + +### 4.3 操作 + +1. 「BOM 加權」→「編輯」→改「權重」→「儲存」。 +2. 校驗失敗時:「權重必須為數字」或「權重總和必須等於 1(目前總和: x)」。 +3. 成功:「更新成功(已重新計算 N 筆 BOM 基礎分)」。 +4. 到「BOM得分」核對「貨品編號」「物品名稱」「基礎得分」。 + +--- + +## 五、BOM/物料單位問題(ADMIN) + +### 5.1 入口 + +- 「設定」→ **「BOM / 物料單位問題」**(僅 **ADMIN**;`/settings/masterDataIssues`) +- 頁標題常顯示:「BOM/貨品單位問題」 + +### 5.2 操作 + +1. Tab「BOM」或「貨品」。 +2. 「搜索」「類型」(「全部」「BOM 總表」「BOM 原材料」)。 +3. 「重新檢查」更新清單;「複製清單」方便貼到表單/郵件。 +4. 點列開詳情:「問題」「應為」「實際」→「關閉」。 + +### 5.3 常見問題文案(節錄) + +- 「BOM 編號為空」「BOM 名稱為空」 +- 「BOM 產出單位與成品銷售單位不一致」 +- 「BOM 原料銷售/基本/庫存單位與貨品主檔不一致」 +- 「BOM 編號與關聯貨品不一致」 + +側欄紅點例:`BOM N 筆 · 貨品 M 筆`。空狀態:「目前沒有問題。」 + +--- + +## 六、應用:建立工單時選 BOM + +### 6.1 手動建立 + +1. 「管理工單」→「搜索工單/ 建立工單」→「建立工單」。 +2. **「BOM」**(必填)選成品/半成品;同名時可能標「(成品)」「(半成品)」。 +3. 「標準生產數」×「批數」=「需求數量」(單位來自 BOM 產出 UOM)。 +4. 「預計生產日期」等填妥 →「建立」。 +5. 成功常提示「成功更新資料」。未選 BOM 時可能出現「請選擇 BOM」/`Bom required!`。 + +### 6.2 排程自動生成 + +1. 「排程」→「生產排程」→「詳細」。 +2. 「自動生成工單」(僅處理**具 BOM** 的物料;說明文案類似「選擇日期範圍(僅含具 BOM 的物料…)」)。 +3. 舊細排頁另有「生成工單」「查看 BOM」(材料表含「編號」「名稱」「可用數量」「需求數量」)。 + +詳見《MTMS 排程與工單 使用說明》。 + +--- + +## 七、權限與路徑速查 + +| 畫面 | 路徑 | 備註 | +|------|------|------| +| 匯入 BOM | `/settings/importBom` | 「設定」下 | +| BOM 權重得分 | `/settings/bomWeighting` | | +| BOM/物料單位問題 | `/settings/masterDataIssues` | **ADMIN** | +| 建立工單 | `/jo` | 選 active BOM | +| 排程產單 | `/ps` | 需 BOM | + +--- + +## 八、常見問題速查 + +| 情況 | 建議 | +|------|------| +| 建工單下拉找不到某成品 | 到「BOM 明細」確認是否「啟用」;或尚未匯入 | +| 排程產單跳過某物料 | 該物料可能無 BOM/不在排期 BOM 範圍 | +| 單位對不上、品檢/提料異常 | ADMIN 開「BOM / 物料單位問題」對照主檔後修正 Excel 再匯入 | +| 權重儲存失敗 | 確認各權重為數字且總和=1 | diff --git a/docs/user-guides/MTMS_工單提料報工上架_使用說明.md b/docs/user-guides/MTMS_工單提料報工上架_使用說明.md new file mode 100644 index 0000000..406f16c --- /dev/null +++ b/docs/user-guides/MTMS_工單提料報工上架_使用說明.md @@ -0,0 +1,259 @@ +# MTMS 使用說明:工單提料/報工/上架 + +> 本手冊依目前前端畫面與繁中文案整理,**按鈕/選單名稱以畫面上「」內文字為準**。 +> 適用範圍:放單之後的「工單提料」「工單生產流程」「上架掃碼」(品檢/上架)。 +> 建單與排程請先看《MTMS 排程與工單 使用說明》;BOM 請看《MTMS BOM 使用說明》。 +> **「一附」章節**使用本機資料庫 `fpsmsdb` 的真實例子(查詢當下快照)。 +> 產生日期:依原始碼現況(若畫面改版,請以實際 UI 為準)。 + +--- + +## 這份手冊怎麼用 + +| 符號 | 意思 | +|------|------| +| 「……」 | 畫面上看得到的按鈕、選單、標題 | +| → | 下一步操作 | + +建議閱讀順序: + +1. **放單**(「搜索工單/ 建立工單」) +2. **提料**(「工單提料」) +3. **對料 → 工序 → 完成工單**(「工單生產流程」) +4. **品檢 → 上架**(「品檢」+「上架掃碼」) + +--- + +## 一、整體流程 + +```text +【/jo】「規劃中」──「放單」──►「待處理」/「提料中」 + ↓ +【/jodetail】「工單提料」──「查看詳情」──► 掃碼提料 ──「提交」/「提交所有已掃描項目」 + ↓ +【/productionProcess】「工單生產流程」 + ├─(可選)「工單對料」→ 二次掃碼 →「確認所有提料」 + ├─「查看詳情」→ 各工序「開始」/「訂單完成」或「已完成」(Just Pass) + ↓ + 「完成工單」(確認:「確認要完成此工單嗎?」) + ↓ + 「品檢」→「確定品檢結果」(可列印/下載 QR) + ↓ +【/putAway】「上架掃碼」:掃貨品 QR → 掃倉庫 QR →「確定及上架貨物」 + ↓ +「已上架工單」;工單「已完成」 +``` + +--- + +## 一附、本地資料庫實例 + +> 狀態中文:`pending`→「待處理」、`packaging`/`picking`→「提料中」、`processing`→「生產中」、`storing`→「待品檢入倉」、`completed`→「已完成」。 + +### 例 A:適合練提料(2026-07-29) + +| 「工單編號」 | 「狀態」 | 成品 | 「需求數量」 | 建議下一步 | +|--------------|----------|------|--------------|------------| +| JO-260729-038 | 待處理 | PP2277 烚意粉 | 51 | 「工單提料」開單掃碼 | +| JO-260729-008 | 提料中 | PP2257 咖哩汁箱料粉 | 1 | 繼續提交提料 | +| JO-260729-013 | 提料中 | PP1043 豆豉汁(2磅/包) | 309 | 繼續提交提料 | +| JO-260729-044 | 提料中 | PP2383 辣椒菜脯 | 265 | 繼續提交提料 | +| JO-260729-034 | 生產中 | PP2302 酸甜蘿蔔粒箱料粉 | 1 | 「工單生產流程」做工序/完成工單 | + +**怎麼練:** + +1. 「搜索工單/ 建立工單」→「預計生產日期」**2026-07-29** →「搜索」。 +2. 「狀態」篩「待處理」找 JO-260729-038,或直接到「工單提料」找同日卡片。 +3. 「生產中」單到「工單生產流程」練「查看詳情」/「完成工單」(完成會改資料,請用測試庫)。 + +### 例 B:狀態與畫面入口對照 + +| 「狀態」 | 常用入口 | +|----------|----------| +| 待處理/提料中 | 「工單提料」 | +| 生產中 | 「工單生產流程」→「工藝流程」 | +| 待品檢/待品檢入倉 | 「工單生產流程」→「品檢」或「待QC上架工單」 | +| 已完成 | 「已上架工單」/工單搜索「已完成」 | + +### 自行查核用 SQL(選用) + +```sql +SELECT jo.code, jo.status, i.code, i.name, jo.reqQty +FROM job_order jo +LEFT JOIN bom b ON b.id = jo.bomId +LEFT JOIN items i ON i.id = b.itemId +WHERE jo.deleted = 0 AND DATE(jo.planStart) = '2026-07-29' + AND jo.status IN ('pending','packaging','processing','storing') +ORDER BY jo.status, jo.code +LIMIT 20; +``` + +--- + +## 二、放單(前置,在「搜索工單/ 建立工單」) + +1. 側欄「管理工單」→「搜索工單/ 建立工單」(`/jo`)。 +2. 找到「規劃中」工單 →「放單」或「放單 (N)」。 +3. 放單後狀態變「待處理」/「提料中」,即可去「工單提料」。 +4. 詳情內可見庫存摘要字樣如「可提料項目數量:」「未能提料項目數量:」。 + +> 亦可在生產流程詳情內對仍屬規劃中的單按「放單」。 + +--- + +## 三、工單提料(`/jodetail`) + +### 3.1 入口與分頁 + +- 側欄「管理工單」→ **「工單提料」**;頁標題「工單提料」。 + +| Tab | 文案 | +|-----|------| +| 0 | 「工單提料詳情」 | +| 1 | 「已完成工單記錄」 | +| 2 | 「物料提料狀態」 | +| 3 | 「膠茜數目使用數量」 | + +### 3.2 「工單提料詳情」列表 + +- 品類:「全部」「飲料」「箱料粉」「其他」 +- 樓層:「2F」「3F」「4F」「沒有批號」等 +- 卡片常見:「工單」「批號」「提料單」「物品名稱」「需求數量」、狀態 Chip +- 點 **「查看詳情」** 進入掃碼提料 + +### 3.3 掃碼提料步驟 + +1. 點「開始掃碼」(可「停止掃碼」)。 +2. 掃描物料/批號 QR;必要時開「批號QR碼掃描」或「手動輸入」→「提交」。 +3. 畫面上應出現「QR碼驗證成功。」/「驗證成功!」;進度見「掃碼結果」「提交數量」。 +4. 單行「提交」或一次「提交所有已掃描項目」(進行中「提交中...」)。 +5. 完成後「返回列表」。 + +### 3.4 「已完成工單記錄」 + +- 「查看詳情」「打印版頭紙」(2F/3F/4F)、「打印數量」「打印機」 +- 「對料狀態」:「對料待處理」/「對料已完成」 +- 提示語例:「工單已完成提料和對料」 + +### 3.5 提料常見錯誤 + +| 提示 | 處理 | +|------|------| +| 「此批次已拒收,請掃描另一個批次。」 | 換批 | +| 「掃描的批次已被其他用戶完全提料。請掃描其他批次。」 | 換可用批 | +| 「物品數量不足」/數量大於需求/可用量 | 改「提交數量」 | +| 「請先選擇打印機」 | 列印版頭紙前先選機 | + +--- + +## 四、工單生產流程(`/productionProcess`) + +### 4.1 入口與頂層分頁 + +- 側欄「管理工單」→ **「工單生產流程」**。 + +| Tab | 文案 | +|-----|------| +| 0 | 「工藝流程」 | +| 1 | 「待QC上架工單」 | +| 2 | 「已上架工單」 | +| 3–6 | 各類「儀表板 - …」 | + +### 4.2 「工藝流程」卡片動作 + +| 按鈕 | 用途 | +|------|------| +| 「查看詳情」 | 進工序/BOM/對料等 | +| 「工單對料」 | 提料完成後二次掃批號確認 | +| 「完成工單」 | 整張 JO 完工(確認:「確認要完成此工單嗎?」;權限常限 ADMIN) | +| 「品檢」 | 開品檢 Modal(條件滿足且有入庫行時才出現) | + +詳情內 Tabs:「工單信息」「BOM 材料」「工藝流程」「工藝明細」「工單對料」;另有「返回列表」「取消工單」「刪除工單」等。 + +### 4.3 工單對料(二次掃) + +1. 點「工單對料」。 +2. 對已提料批再掃「批號QR碼掃描」(或「手動輸入」)。 +3. 「驗證成功!」後,全部核對完點 **「確認所有提料」**。 +4. 「返回列表」可能解除指派,勿中途亂退。 + +### 4.4 工序報工(單步) + +1. 「查看詳情」→「工藝流程」/「工藝明細」。 +2. 待處理工序點 **「開始」** → Dialog「掃描操作員和設備」。 +3. 「開始掃碼」:先操作員/員工,再設備 →「提交並開始」。 +4. 執行中可「暫停」/「繼續」(「暫停原因」);結束該步用 **「訂單完成」**(填「工序產出」「不良品」「損耗」)。 +5. 若允許略過執行:按鈕「已完成」(Just Pass),確認「確認要通過此工序嗎?」。 + +> **勿混淆:** 工序「訂單完成」=結束**一步**;卡片「完成工單」=結束**整張工單**。 + +### 4.5 品檢 + +1. 條件滿足後點「品檢」。 +2. Modal 常見 Tab:「處理來貨及品檢」/「來貨及品檢詳情」。 +3. 填結果後 **「確定品檢結果」**。 +4. 可「打印機」「列印數量」「列印」「下載QR碼」(供之後上架掃碼)。 +5. 校驗例:「請決定品檢結果」「有未完成品檢項目」「請輸入不合格數量」「請輸入到期日!」。 + +亦可從 Tab「待QC上架工單」或提醒鈴深連結進入。 + +--- + +## 五、上架掃碼(`/putAway`) + +### 5.1 入口 + +- 側欄「倉庫管理」→ **「上架掃碼」**;頁標題「上架」。 + +### 5.2 兩段掃碼 + +1. 待機:「等待掃瞄中,請掃瞄貨品二維碼開始上架程序」。 +2. 掃**貨品/來貨行** QR → 開 Modal。 +3. 填「是次上架數量」;再掃**倉庫** QR(「掃瞄倉庫二維碼」/「請掃瞄倉庫二維碼」)。 +4. 點 **「確定及上架貨物」**。 +5. 可在「是次上架記錄」核對。 + +失敗:「讀取不成功,請重新掃瞄」;數量:「上架數量不得大於 …」「最小為1」等。 + +入庫行狀態語意:「待上架」→「已部分上架」→「已上架」。完成後可在「已上架工單」看到。 + +--- + +## 六、狀態對照(操作頁為準) + +| 代碼 | 畫面 | +|------|------| +| planning | 「規劃中」 | +| pending | 「待處理」 | +| packaging / picking | 「提料中」 | +| processing | 「生產中」 | +| pendingQC | 「待品檢」 | +| storing | 「待品檢入倉」 | +| completed | 「已完成」 | +| cancelled | 「已取消」 | + +工序行:「待處理」→「進行中」→(可「已暫停」)→「完成」/「已完成」(Pass)。 + +--- + +## 七、常見問題速查 + +| 情況 | 建議 | +|------|------| +| 提料頁找不到單 | 確認已「放單」;日期/樓層/品類篩選是否過窄 | +| 對料按鈕灰/沒有 | 提料單未完成、已指派他人、或對料已完成 | +| 「完成工單」按不到 | 權限或工序未齊;確認提示「確認要完成此工單嗎?」 | +| 沒有「品檢」按鈕 | 尚未完成工單/無 stock-in 行 | +| 上架掃不到 | 先品檢並列印/下載 QR;或用 `?stockInLineId=` 深連結 | +| 掃碼驗證失敗 | 換批、確認未拒收、確認單位/可用量 | + +--- + +## 八、路徑速查 + +| 畫面 | 路徑 | +|------|------| +| 搜索/放單 | `/jo` | +| 工單提料 | `/jodetail` | +| 工單生產流程 | `/productionProcess` | +| 上架掃碼 | `/putAway` | diff --git a/docs/user-guides/MTMS_排程與工單_使用說明.md b/docs/user-guides/MTMS_排程與工單_使用說明.md new file mode 100644 index 0000000..849f945 --- /dev/null +++ b/docs/user-guides/MTMS_排程與工單_使用說明.md @@ -0,0 +1,459 @@ +# MTMS 使用說明:排程 → 開工單 + +> 本手冊依目前前端畫面與繁中文案整理,**按鈕/選單名稱以畫面上「」內文字為準**。 +> 適用範圍:側欄「排程」、管理工單(搜索/建立、提料、生產流程)。 +> **「一附」章節**使用本機資料庫 `fpsmsdb` 的真實例子(查詢當下快照);其他環境請改日期再對。 +> 產生日期:依原始碼現況(若畫面改版,請以實際 UI 為準)。 + +--- + +## 這份手冊怎麼用 + +| 符號 | 意思 | +|------|------| +| 「……」 | 畫面上看得到的按鈕、選單、標題 | +| → | 下一步操作 | +| 節點 | 流程中的一個階段(狀態/畫面) | + +建議閱讀順序: + +1. **先排期**(側欄「排程」) +2. **再放單/建工單**(「管理工單」→「搜索工單/ 建立工單」) +3. **提料 → 生產 → 完成** + +--- + +## 一、整體流程(從排期到完工) + +```text +【排程】預測/查看排期 + ↓ 「自動生成工單」 或 手動「建立工單」 +【規劃中】工單已建立、尚未放單 + ↓ 「放單」 +【待提料/提料中】 + ↓ 「工單提料」掃碼提交 +【生產中】「工單生產流程」各工序開始/完成 + ↓ 「完成工單」 +【品檢/上架】「待QC上架工單」→「已上架工單」 +``` + +也可**不經排程**,在「搜索工單/ 建立工單」直接「建立工單」(手動工單)。 + +--- + +## 一附、本地資料庫實例(方便對照畫面) + +> 以下數字來自本機 `fpsmsdb` 查詢結果,用於說明「畫面上大概會看到什麼」。 +> **不同環境/日期資料會不同**;請用「搜索」條件改成你們當天日期再核對。 +> 狀態中文依前端翻譯:`planning`→「規劃中」、`pending`→「待處理」、`packaging`→「提料中」、`processing`→「生產中」、`storing`→「待品檢入倉」、`completed`→「已完成」。 + +### 例 A:已有排期、尚未產工單(適合練習「詳細」→「自動生成工單」) + +在「生產排程」用「生產日期」搜 **2026-08-03**,本機有一筆細排(`production_schedule.id = 928`,`type = detailed`): + +| 畫面概念 | 本機資料 | +|----------|----------| +| 「生產日期」 | 2026-08-03 | +| 「預計生產數」(約) | 17,334 | +| 「成品款數」(約) | 47 | + +打開「詳細」後,明細列會類似(節錄): + +| 「編號」 | 「名稱」 | 約「需求數量」 | 約「存貨量」 | 約需工單數 | +|----------|----------|----------------|--------------|------------| +| PP1175 | 鮮檸檬汁(P+4) | 1,406 | 1,400 | 74 | +| PP1224 | 柚子蒜蓉汁 | 140 | 50 | 1 | +| PP0259 | 牛肉水(2KG/包) | 41 | 51 | 41 | +| PP2284 | 油醋汁(1KG/包) | 28 | 32 | 1 | +| PP2390 | 熱情香果醬 | 12 | 16 | 4 | + +**怎麼練:** + +1. 「排程」→ 生產日期選 **2026-08-03** →「搜索」→「詳細」。 +2. 對照上表成品是否出現在明細。 +3. 若環境允許,再試「自動生成工單」(會真正建 JO,請在測試庫操作)。 + +> 查詢當下:此排期**尚未**有透過 `prodScheduleLineId` 掛上的工單(適合示範「產工單前」)。 + +### 例 B:排期已產工單且已完成(歷史成功路徑) + +本機較早一筆:**2026-06-17** 細排(約預計生產 17,421、成品款數 33),曾產生多張 `type = detailed` 工單,例如: + +| 「工單編號」 | 「狀態」 | 「需求數量」 | 成品 | +|--------------|----------|--------------|------| +| JO-260617-008 | 已完成 | 568 | PP1234 日式咖哩汁 | +| JO-260617-012 | 已完成 | 15 | PP2288 香水檸檬汁P+3 | +| JO-260617-015 | 已完成 | 600 | PP1136 白粥 | +| JO-260617-024 | 已完成 | 28 | PP2284 油醋汁(1KG/包) | +| JO-260617-030 | 已完成 | 508 | PP2290 韓式豬軟骨 | + +**怎麼練:** + +1. 「搜索工單/ 建立工單」→「預計生產日期」填 **2026-06-17** →「搜索」。 +2. 找上表工單編號,點「查看」看已完成工單長怎樣。 +3. 對照:這類工單來自排程 release(`type` 在庫為 `detailed`),不是手動「建立工單」的 `manual`。 + +### 例 C:手動「建立工單」(不經排程) + +本機 **2026-07-29** 工單幾乎皆為手動(`type = manual`,且未掛排期行)。例子: + +| 「工單編號」 | 「狀態」 | 「需求數量」 | 成品 | +|--------------|----------|--------------|------| +| JO-260729-045 | 待處理 | 3 | PP2390 熱情香果醬 | +| JO-260729-038 | 待處理 | 51 | PP2277 烚意粉 | +| JO-260729-034 | 生產中 | 1 | PP2302 酸甜蘿蔔粒箱料粉 | +| JO-260729-044 | 提料中 | 265 | PP2383 辣椒菜脯 | + +**怎麼練:** + +1. 「預計生產日期」選 **2026-07-29** →「搜索」。 +2. 用「狀態」篩「待處理」→ 應能看到類似 JO-260729-045。 +3. 若該單仍「規劃中」,可練習「放單」;若已是「待處理」,可接「工單提料」。 + +同日狀態分佈(本機快照):約 17 張「待處理」、24 張「提料中」、4 張「生產中」——正好對應「放單後 → 提料 → 生產」不同節點。 + +### 例 D:近兩週工單狀態分佈(看流程卡在哪) + +本機最近約 14 天(未隱藏工單)概況: + +| 「狀態」 | 約筆數 | 使用者下一步常做什麼 | +|----------|--------|----------------------| +| 已完成 | 359 | 可當完成範本「查看」 | +| 待處理 | 42 | 「工單提料」 | +| 提料中 | 37 | 繼續掃碼/提交提料 | +| 待品檢入倉 | 33 | 「工單生產流程」品檢/上架 | +| 生產中 | 4 | 「工單生產流程」繼續工序 | + +### 例 E:還在「規劃中」的單(適合練刪除/放單) + +本機仍有例如:**JO-260427-039**(沙薑醬 PP2205,需求約 291,「規劃中」)。 + +- 若只需練習「查看」→ 看「放單」「刪除工單」按鈕是否出現。 +- **勿在正式/共用庫隨意刪除**;測試庫才建議真的按「刪除工單」。 + +### 自行查核用 SQL(選用) + +```sql +-- 某日排期摘要 +SELECT id, DATE(produceAt) AS produce_date, type, + totalEstProdCount, totalFGType +FROM production_schedule +WHERE deleted = 0 AND DATE(produceAt) = '2026-08-03'; + +-- 該排期成品明細(前 20) +SELECT i.code, i.name, psl.prodQty, psl.stockQty, psl.needNoOfJobOrder +FROM production_schedule_line psl +JOIN production_schedule ps ON ps.id = psl.prodScheduleId +JOIN items i ON i.id = psl.itemId +WHERE ps.deleted = 0 AND psl.deleted = 0 + AND DATE(ps.produceAt) = '2026-08-03' +ORDER BY psl.itemPriority, i.code +LIMIT 20; + +-- 某日工單+狀態 +SELECT code, status, type, reqQty, DATE(planStart) AS plan_date +FROM job_order +WHERE deleted = 0 AND (isHidden = 0 OR isHidden IS NULL) + AND DATE(planStart) = '2026-07-29' +ORDER BY status, code; +``` + +--- + +## 二、排程(側欄「排程」) + +### 2.1 入口 + +- 側欄點 **「排程」** +- 進入頁面標題:**「生產排程」**(路徑通常為 `/ps`) + +> 說明:系統另有舊版「需求預測」「詳細排程」頁(`/scheduling/...`),**目前側欄主入口是「排程」→「生產排程」**。以下以主入口為準。 + +### 2.2 畫面上常見按鈕 + +| 按鈕/功能 | 用途(白話) | +|------------|--------------| +| 「預測排期」 | 依日期/天數**計算產生**預計排期 | +| 「搜索」 | 依「生產日期」等條件查已有排期 | +| 「詳細」 | 打開該筆排期的明細 | +| 「自動生成工單」 | 依此排期**一次產生多張工單**(在詳情裡) | +| 「關閉」 | 關閉詳情視窗 | +| 「排期設定」 | 庫存/排期相關設定與匯入匯出 | +| 「匯出計劃/物料需求Excel」 | 匯出計劃與物料需求 | +| 「匯出送貨單數量」 | 匯出送貨單數量區間資料 | + +列表常見欄位:「生產日期」「預計生產數」「成品款數」等。 + +### 2.3 操作步驟:做出排期(Happy path) + +1. 進入 **「排程」** → **「生產排程」**。 +2. 點 **「預測排期」**。 +3. 在對話框 **「準備生成預計排期」** 中填: + - 「開始日期」 + - 「排期日數」 +4. 點 **「計算預測排期」**。 +5. 成功時畫面會提示類似 **「成功計算排期!」**;失敗會提示計算錯誤或不明狀況。 +6. 選擇「生產日期」後點 **「搜索」**,在列表找到該日排期。 +7. 點該列 **「詳細」**,打開 **「排期詳細」**。 +8. 確認內容無誤後,點 **「自動生成工單」** → 系統依排期建立工單。 +9. 點 **「關閉」** 結束。 + +### 2.4 節點說明(排程) + +| 節點 | 使用者在做什麼 | 下一個常見動作 | +|------|----------------|----------------| +| 尚未有排期 | 進「生產排程」但列表空/無當日資料 | 「預測排期」 | +| 已有排期列表 | 用「搜索」找日期 | 「詳細」 | +| 排期詳細已打開 | 檢查預計生產內容 | 「自動生成工單」 | +| 已生成工單 | 工單出現在「搜索工單/ 建立工單」 | 去「放單」 | + +### 2.5 排程常見問題 + +| 情況 | 建議處理 | +|------|----------| +| 「計算預測排期」失敗 | 記下畫面錯誤訊息;檢查開始日期/天數;稍後再試或聯絡系統/IT | +| 「自動生成工單」失敗 | 畫面可能顯示失敗訊息;確認排期內容是否完整、BOM/物料是否齊全 | +| 找不到某日排期 | 確認「生產日期」與「搜索」條件;必要時再跑一次「預測排期」 | +| 想改數量再開工單 | 主入口 `/ps` 詳情偏「一次自動生成」;若需逐行改量/發佈,需使用舊版「詳細排程」編輯頁(見附錄) | + +### 2.6 附錄:舊版「詳細排程」/「需求預測」(進階) + +若單位仍使用直連網址: + +| 畫面 | 標題(約) | 重點按鈕 | +|------|------------|----------| +| 需求預測列表 | 「需求預測」 | 「測試粗排」「搜索」「詳情」 | +| 需求預測詳情 | 「成品及物料需求預測詳情」 | 多為**檢視**(依成品/依物料、「查看 BOM」) | +| 詳細排程列表 | 「詳細排程」 | 「詳細排程」(產生)、「匯出排程」「詳情」 | +| FG 生產排程 | 「成品生產排程」/「FG 生產排程」 | 列「發佈」、改「需求數量」後儲存、**「生成工單」**、「返回」 | + +注意(舊版細排詳情): + +- **「生成工單」** 常僅允許**生產日期為今天**;否則可能跳出英文提示(畫面未必有完整中文翻譯)。 +- 前端**沒有**「從某一筆需求預測一鍵跳到對應詳細排程」的按鈕;兩條線在畫面上是分開的。 + +設定選單另有 **「需求預測設定」**(成品排除日、星期等),屬主檔設定,不是每日排期操作。 + +--- + +## 三、工單:搜索/建立/放單 + +### 3.1 入口 + +側欄 **「管理工單」** → **「搜索工單/ 建立工單」** + +頁面標題:**「搜索工單/建立工單」** + +### 3.2 畫面上常見按鈕 + +| 按鈕 | 用途 | +|------|------| +| 「建立工單」 | 手動開一張新工單 | +| 「放單」/「放單 (N)」 | 將「規劃中」工單放出,進入後續提料 | +| 「重置」 | 清空搜尋條件 | +| 「查看」 | 開「工單詳情」 | +| 「取消工單」 | 取消後工單從列表隱藏(非規劃中等情況) | + +搜尋條件常見:「工單編號」「成品/半成品名稱」「預計生產日期」~「預計生產日期至」「工單類型」「狀態」。 + +### 3.3 操作步驟:手動建立工單 + +1. 點 **「建立工單」**,打開標題為 **「建立工單」** 的視窗。 +2. 填寫: + - 「BOM」 + - 「標準生產數」(通常唯讀) + - 「批數」 + - 「需求數量」(常由標準×批數自動帶出) + - 「工單類型」(選 BOM 後可能自動對應) + - 「生產優先序」(常見預設約 50,範圍約 1–100) + - 「預計生產日期」 + - 可勾選「記住為預設日期」 +3. 點 **「建立」**。 +4. 成功後通常提示資料已更新;新工單多為 **「規劃中」** 狀態。 +5. 在列表勾選/找到該工單,點 **「放單」**(或上方「放單 (N)」批量)。 + +### 3.4 操作步驟:從排程來的工單 + +1. 在「生產排程」詳情已按 **「自動生成工單」**(或舊版「生成工單」)。 +2. 到 **「搜索工單/ 建立工單」**,用「預計生產日期」等搜尋。 +3. 確認工單後執行 **「放單」**。 + +### 3.5 工單詳情(「查看」) + +標題:**「工單詳情」** + +常見分頁/區塊: + +- 「工單信息」 +- 「BOM 材料」 +- 「工藝流程」/「工藝明細」 + +常見動作: + +| 按鈕 | 何時可用(概念) | 說明 | +|------|------------------|------| +| 「放單」 | 多為「規劃中」 | 放出工單 | +| 「刪除工單」 | 多為仍在「規劃中」 | 確認後**無法復原** | +| 「取消工單」 | 已非規劃中等 | 確認後從列表**隱藏**;已上架通常不可取消 | +| 「儲存」/「取消」 | 編輯 Dialog | 存檔或放棄 | +| 「返回列表」 | — | 回搜尋頁 | + +庫存摘要常見字樣:「所需貨品項目數量:」「可提料項目數量:」「未能提料項目數量:」。 + +刪除確認: + +- 「確認刪除工單」 +- 「確定要刪除此工單嗎?此操作無法復原。」 + +取消確認: + +- 「確認取消工單」 +- 「確定要取消此工單嗎?工單將從列表中隱藏。」 +- 按鈕:「取消工單」/「取消」(後者為放棄這次取消操作) + +### 3.6 工單狀態(搜尋/列表常見) + +畫面上可能出現(實際以該列顯示為準): + +| 狀態(常見中文) | 白話 | +|------------------|------| +| 「規劃中」 | 已建立,尚未放單 | +| 「待處理」/待提料相關 | 已放單,等提料 | +| 「提料中」/「進行中」 | 正在提料或進行中 | +| 「已開始工序」/「生產中」 | 已進入生產工序 | +| 「成品入倉中」/「待品檢入倉」 | 生產後待品檢/入倉 | +| 「已上架」 | 已完成上架 | +| 「已取消」 | 已取消 | + +篩選下拉亦可能見:「待處理」「提料中」「已開始工序」「成品入倉中」「已上架」「已取消」等。 + +### 3.7 工單建立/放單常見問題 + +| 情況 | 建議處理 | +|------|----------| +| 「建立」沒反應 | 檢查 BOM、批數、日期、工單類型是否已填;看是否有必填未選 | +| 「放單」失敗 | 記下提示;檢查是否仍為可放單狀態、物料/權限 | +| 想刪掉剛建錯的單 | 「規劃中」→「查看」→「刪除工單」→ 確認 | +| 已放單但不想做了 | 「取消工單」→ 確認隱藏;**已上架**通常不能取消 | +| 批量「放單 (N)」部分失敗 | 依成功/失敗筆數訊息,對失敗單再個別處理 | + +--- + +## 四、工單提料 + +### 4.1 入口 + +**「管理工單」** → **「工單提料」** + +### 4.2 常見分頁 + +- 「工單提料詳情」 +- 「已完成工單記錄」 +- 「物料提料狀態」 +- (及其他與膠茜/用量相關分頁,以畫面為準) + +### 4.3 操作步驟(概念) + +1. 在「工單提料詳情」找到已放單、待提料的工單。 +2. 點 **「查看詳情」** 進入提料執行畫面。 +3. 依畫面使用 **「開始掃碼」**、掃描物料/批號。 +4. 需要時點 **「提交」**/**「提交所有已掃描項目」**/**「確認」**。 +5. 完成後可在「已完成工單記錄」核對。 + +對料相關:生產流程中可能有 **「工單對料」**,並有 **「確認所有提料」**。 + +### 4.4 提料常見問題 + +| 情況 | 建議處理 | +|------|----------| +| 批號不符 | 依 Dialog 提示按「確認」,改掃正確批號 | +| 庫存不足/過期 | 依畫面提示;先補貨或調批號,勿強行提交 | +| 掃錯要重來 | 用畫面上的「取消」/清除已掃項目(以當頁按鈕為準)後重掃 | + +提料狀態常見:「待提料」「已掃碼」「對料待處理」「對料已完成」;提料單「已放單」「已完成」。 + +--- + +## 五、工單生產流程 + +### 5.1 入口 + +**「管理工單」** → **「工單生產流程」** + +### 5.2 常見分頁/區塊 + +- 「工藝流程」 +- 「待QC上架工單」 +- 「已上架工單」 +- 「儀表板 - 工單狀態」等 + +### 5.3 工序操作(Happy path) + +1. 在「工藝流程」找到工單,點 **「查看詳情」**。 +2. (可選)**「工單對料」** 確認物料。 +3. 各工序: + - **「開始」** 開工序 + - 可依畫面掃 **操作員**/**設備**(未掃可能提示「請先掃描操作員編號」「請掃描設備編號」) + - 可 **「暫停」** + - **「完成步驟」**/**「通過」**/**「已完成」**(Just Pass 等,以畫面為準) + - 確認通過時可能問:**「確認要通過此工序嗎?」** +4. 全部就緒後點 **「完成工單」**,確認:**「確認要完成此工單嗎?」** +5. 需要時做 **「品檢」**,再到「待QC上架工單」完成上架,最後在「已上架工單」可見。 + +其他可能按鈕:「提交並開始」「提交包裝袋消耗」等。 + +### 5.4 工序狀態(常見) + +「待處理」「進行中」「已暫停」「完成」/「已完成」「通過」「未開始」「已停止」「已取消」等。 + +### 5.5 生產常見問題 + +| 情況 | 建議處理 | +|------|----------| +| 「完成工單」按不了 | 通常尚有工序未完成或條件未滿足;先完成各步驟 | +| 掃碼驗證失敗 | 「驗證失敗. 請檢查操作員和設備.」→ 重掃正確編號 | +| API/系統錯誤 | 「發生錯誤,請稍後再試。」→ 稍後重試或聯絡支援 | +| 做到一半要停 | 用「暫停」;勿與「取消工單」混淆(取消是整張工單層級) | + +--- + +## 六、取消、刪除、返回——怎麼選? + +| 你想做的事 | 建議用哪個按鈕 | 結果 | +|------------|----------------|------| +| 建錯、還在規劃中,不要了 | 「刪除工單」 | 刪除,**不可復原** | +| 已放單/進行中,不要繼續 | 「取消工單」 | 確認後從列表**隱藏** | +| 只是關掉視窗、不改資料 | 「關閉」/「取消」/「返回」「返回列表」 | 不取消工單本身 | +| 工序暫停一下 | 「暫停」 | 工單仍在,之後可再「開始」 | + +--- + +## 七、快速檢查清單(每日) + +1. 「排程」→ 需要時「預測排期」→「搜索」→「詳細」→「自動生成工單」 +2. 「搜索工單/ 建立工單」→ 核對當日工單 →「放單」 +3. 「工單提料」→ 掃碼提交 +4. 「工單生產流程」→ 各工序開始/完成 →「完成工單」→ 品檢/上架 + +手動補單:同頁「建立工單」→「建立」→「放單」。 + +--- + +## 八、文件維護說明(給內部) + +- 文案來源:前端 `i18n/zh/navigation.json`、`jo.json`、`schedule.json`、`productionProcess.json`,以及「生產排程」頁硬編碼繁中。 +- 主程式入口: + - 排程:`FPSMS-frontend/src/app/(main)/ps/page.tsx` + - 工單搜尋/建立:`.../jo/page.tsx`、`JoWorkbenchSearch`、`JoCreateFormModal` + - 提料:`.../jodetail/` + - 生產:`.../productionProcess/` +- 若按鈕改名或流程改版,請同步改本手冊,並重新匯出 Word。 + +重新匯出 Word(需已安裝套件): + +```bash +pip install python-docx +python scripts/export_user_guide_office.py +``` + +產出:`docs/exports/MTMS_Schedule_JobOrder_UserGuide.docx` diff --git a/docs/user-guides/MTMS_送貨訂單與出貨_使用說明.md b/docs/user-guides/MTMS_送貨訂單與出貨_使用說明.md new file mode 100644 index 0000000..2c5ae41 --- /dev/null +++ b/docs/user-guides/MTMS_送貨訂單與出貨_使用說明.md @@ -0,0 +1,375 @@ +# MTMS 使用說明:送貨訂單 → 放單 → 成品出倉 + +> 本手冊依目前前端畫面與繁中文案整理,**按鈕/選單名稱以畫面上「」內文字為準**。 +> 適用範圍:側欄「送貨訂單」、倉庫「成品出倉」(撳單/掃碼出倉/列印標籤)、加單與車線-X。 +> **「一附」章節**使用本機資料庫 `fpsmsdb` 的真實例子(查詢當下快照)。 +> 產生日期:依原始碼現況(若畫面改版,請以實際 UI 為準)。 + +--- + +## 這份手冊怎麼用 + +| 符號 | 意思 | +|------|------| +| 「……」 | 畫面上看得到的按鈕、選單、標題 | +| → | 下一步操作 | + +**角色分工(白話):** + +| 角色動作 | 主要畫面 | +|----------|----------| +| 看單、放單、加單、補貨 | 「送貨訂單」`/do` | +| 撳單、掃碼提貨、列印 DN/標籤 | 「成品出倉」`/doworkbench` | +| 調整提料順序等(ADMIN) | 「成品出倉管理」 | + +> **「放單」≠「撳單」**:放單=由送貨訂單產生提料票;撳單=倉庫領取該票開始出倉。 + +建議閱讀順序:送貨訂單篩選 → 放單 → 撳單 → 掃碼出倉 → 填箱數列印。 + +--- + +## 一、整體流程 + +```text +【送貨訂單 /do】篩選「2/F」「4/F」「車線-X」「加單」 + ↓ 「批量放單」或詳情「放單」 +【產生提料單/提票】狀態進入待撳單 + ↓ +【成品出倉 /doworkbench】「撳單/提料單詳情」 + ↓ 選日期/批量|單量/樓層票 → 點車線「確認分配」 +【掃碼提料】「開始QR掃描」→「提交所有已掃描項目」 + ↓ +【成品提貨記錄】輸入「箱數」→「列印送貨單標籤」等 + ↓ +【查看提貨情況】核對「已完成」 +``` + +--- + +## 一附、本地資料庫實例 + +> DO「來貨狀態」:`pending`→「待處理」、`receiving`→「接收中」、`completed`→「已完成」。 +> 提票在「查看提貨情況」:`pending`→「待撳單」、`released`→「提貨中」、`completed`→「已完成」。 + +### 例 A:待放單/待出貨的送貨訂單(2026-07-30) + +本機「預計送貨日期」**2026-07-30** 仍有多張「待處理」,例如: + +| 「門店訂單編號」 | 「來貨狀態」 | 店鋪 | +|------------------|--------------|------| +| TOUR03PO26070304 | 待處理 | UR03 | +| TOCF28PO26070199 | 待處理 | CF28 | +| TOCF28PO26070200 | 待處理 | CF28 | +| TOCF02PO26070176 | 待處理 | CF02 | +| TOCF18PO26070194 | 待處理 | CF18 | + +**怎麼練:** + +1. 「送貨訂單」→ 選樓層分頁 →「預計送貨日期」填 **2026-07-30** → 搜索。 +2. 找上表編號點「詳情」看行項與「庫存可用」。 +3. 測試庫才建議真的「放單」/「批量放單」(會產生提料票)。 + +### 例 B:近一週 DO 狀態量級(本機 2026-07-28~08-05) + +| 「來貨狀態」 | 約筆數 | 使用者常做 | +|--------------|--------|------------| +| 待處理 | 1,425 | 篩選後「批量放單」 | +| 已完成 | 1,059 | 查歷史/補貨原單 | +| 接收中 | 96 | 出倉進行中對應 | + +### 例 C:提料票(撳單前「待撳單」風格樣本) + +本機較早提票例(`do_pick_order`,狀態 pending≈待撳/待處理): + +| 「提票號碼」 | 車線資訊 | 店鋪 | 「需求日期」 | 放單類型 | +|--------------|----------|------|--------------|----------| +| TI-S-20260504-4F-001 | P06B_Sat_區1_港島東 | MC49 | 2026-05-04 | single(單量) | +| TI-S-20260504-2F-001 | 車線-F1 | MC49 | 2026-05-04 | single | +| TI-S-20260504-2F-001 | 車線-X | HP65 | 2026-05-04 | single | +| TI-S-20260505-4F-002 | P06B_Tue_區5_九龍中 | HP15 | 2026-05-05 | single | + +**怎麼練:**「成品出倉」→「撳單/提料單詳情」→ 日期選對應「是日/翌日…」;或「成品提貨記錄(全部)」用「提票號碼」搜索。注意「車線-X」會獨立分組。 + +### 自行查核用 SQL(選用) + +```sql +SELECT d.code, d.status, DATE(d.estimatedArrivalDate) AS eta, s.code AS shop +FROM delivery_order d +LEFT JOIN shop s ON s.id = d.shopId +WHERE d.deleted = 0 AND DATE(d.estimatedArrivalDate) = '2026-07-30' +ORDER BY d.id DESC +LIMIT 20; + +SELECT ticket_no, TruckLanceCode, ticket_status, ShopCode, + DATE(RequiredDeliveryDate) AS req_date, release_type +FROM do_pick_order +WHERE deleted = 0 AND RequiredDeliveryDate >= '2026-05-01' +ORDER BY id DESC +LIMIT 20; +``` + +--- + +## 二、送貨訂單(`/do`) + +### 2.1 入口與分頁 + +- 側欄 **「送貨訂單」**。 + +| 分頁 | 用途 | +|------|------| +| 「2/F」「4/F」 | 依樓層票別看/放單 | +| 「車線-X」 | 無匹配車線或歸入 X 的訂單 | +| 「加單」 | isExtra 加單;批量放單可合併 | +| 「補貨」 | 已完成原單補到目標單 | + +### 2.2 搜索欄 + +- 「門店訂單編號」「店鋪名稱」「車線號碼」「預計送貨日期」「來貨狀態」 +- 狀態選項:「待處理」「接收中」「已完成」(及「全部」) +- 注意:「已填寫車線號碼時,請一併選擇預計送貨日期後再搜索。」/「需選擇預計送貨日期」 + +### 2.3 結果表常見欄 + +「詳情」「門店訂單編號」「店鋪名稱」「供應商名稱」「車線號碼」「訂單日期」「預計送貨日期」「來貨狀態」。 + +### 2.4 詳情頁(`/do/edit?id=`) + +- 標題:「編輯送貨訂單詳情」 +- 動作:「放單」「提料單分配」「分配2/F」「分配4/F」「放單2/F」「放單4/F」「返回」 +- 行表:「商品編號」「貨品名稱」「數量」「庫存可用」「庫存狀態」 +- 單張成功提示:「送貨訂單放單成功!提料單已建立。」 + +--- + +## 三、放單詳解 + +### 3.1 批量放單(常用) + +1. 在「送貨訂單」搜出目標日/樓層的列,勾選需要的店(可取消勾選排除)。 +2. 點 **「批量放單」**。 +3. 對話框顯示「已選擇店舖數量: N」;「確認」執行。 +4. **「加單」分頁**額外選項: + - 「確認合併放單」(合併同車線 → TI-M- 合併票;文案含「合併同車線送貨訂單(TI-M- 合併票)」) + - 「確認不放合併放單」 +5. 成功:「已完成批量放單」。 + +Workbench 路徑按鈕亦可能顯示為「批量放單」(鍵名 Workbench Batch Release)。 + +### 3.2 單張放單 + +詳情頁「放單」,或先「分配2/F/4/F」再「放單2/F/4/F」。 + +### 3.3 放單前/失敗常見提示 + +| 提示 | 處理 | +|------|------| +| 「沒有選擇送貨訂單進行批量放單…」 | 先勾選列 | +| 「車線可用性警告」「問題送貨訂單」 | 核對目標日是否有車線;或走「車線-X」 | +| 「放單提料單失敗,請稍後再試。」 | 稍後重試;查該店是否已放過 | + +--- + +## 四、成品出倉(`/doworkbench`)— 主路徑 + +### 4.1 入口 + +- 「倉庫管理」→ **「成品出倉」**(現行主選單指向 `/doworkbench`)。 +- 舊頁 `/finishedGood` 標題同為「成品出倉」,一般以 Workbench 為準。 + +### 4.2 頁頂打印機 + +- 「A4 打印機」「標籤打印機」「列印全部草稿 (N)」 +- 未選機:「請先選擇打印機」/「請先選擇標籤打印機」 + +### 4.3 分頁一覽 + +| tab | 標籤 | +|-----|------| +| 0 | 「撳單/提料單詳情」 | +| 1 | 「加單」(徽章:當日未完成加單票數) | +| 2 | 「成品提貨記錄」 | +| 3 | 「成品提貨記錄(全部)」 | +| 4 | 「查看提貨情況」 | +| 5 | 「成品出倉出箱數量」 | +| 6 | 「送貨路線摘要」 | + +--- + +## 五、撳單(領票) + +### 5.1 條件列 + +- 「請選擇日期」:「是日」「翌日」「後日」 +- 「放單類型」:「批量」「單量」 +- 「票別(樓層)」:「2/F 票」「4/F 票」 + +### 5.2 車線面板 + +1. 車線按鈕顯示「(未撳數/總單數)」、裝載序/出發時間等。 +2. 點車線 →「確認分配」(含「位置」「車線號碼」「裝載順序」「出發時間」「所需日期」「可用訂單」)→「確認」。 +3. 成功後進入提料明細掃碼。 +4. 無單時:「該樓層未有需處理訂單」/「此樓層沒有可用的提料單」。 +5. 「未完成提料單」可搜商店/車線/送貨單編號再「選擇」。 +6. **限制:**「請先完成目前的提料單,再提取下一張」。 + +### 5.3 「車線-X」 + +- DO 與出倉皆有獨立「車線-X」區塊;無匹配車線時顯示「車線-X」。 +- 「以前」:今日前未完成的車線-X。 +- 出箱儀表會統計「車線-X 出箱數」。 + +--- + +## 六、掃碼出倉(提料執行) + +1. 在已撳單的明細中看「所有提料單批號」「進度」。 +2. 「開始QR掃描」/「停止QR掃描」;「掃描結果」正確時「二維碼驗證成功。」 +3. 可「改數」「提交數量」;問題回報含不良/遺失等。 +4. 「提交所有已掃描項目」;可選「列印空白頁數標籤」。 +5. 無掃碼可直接完成的列可用「已完成」(Just Completed)。 +6. 全部完成後通常導向「成品提貨記錄」(帶提票號)。 + +### 掃碼常見錯誤 + +| 提示 | 處理 | +|------|------| +| 「二維碼不符合當前訂單中的任何貨品。」 | 確認掃的是本票貨品 | +| 「此批號不可用…」「此批次尚未上架」 | 換批或先完成上架 | +| 「此批號單位不符…」「此批號已提貨…」 | 換批 | +| 「掃描批號已過期…」 | 換未過期批 | +| 「此批次貨品已被其他送貨單留起…」 | 換批或協調留貨 | +| 換批雙掃說明 | 依畫面再掃一次確認 | + +--- + +## 七、加單專章 + +### 7.1 在「送貨訂單」 + +- 開「加單」分頁搜索與「批量放單」。 +- 合併選項見 §3.1(TI-M- 合併票)。 + +### 7.2 在「成品出倉」 + +1. Tab「加單」;進入前確認:「進入加單檢視?」 +2. 說明:「加單檢視會依選定日期,將 isExtra 票依店鋪與車線顯示。」 +3. 「目前是加單票,顯示與操作已切換為加單模式。」/「離開加單檢視」「返回一般指派分頁」。 +4. 「合併加單提料單」:僅「未分配」且同店鋪、樓層(2/F、4/F 或車線-X)、車線、出發時間可合併。 +5. 類型顯示可能為「合拼單」「加單」「批量」「單量」。 + +--- + +## 八、補貨(「送貨訂單」→「補貨」) + +1. 「補貨填表」「對單」→「待提交列表」→「提交」/「清空」。 +2. 「送貨單號末四位」「原送貨單」「目標送貨單」「補貨數量」「原出貨數」「車線」。 +3. 無車線時畫面可能顯示「車線-X」。 +4. 「補貨進度追蹤」:待處理/處理中/已完成。 +5. 限制例:「只有已送貨(completed)的送貨單可作為原送貨單。」「補貨數量必須大於零」。 + +--- + +## 九、箱數與列印 + +### 9.1 草稿/空白 + +- 「列印全部草稿 (N)」→ 確認「確認列印全部草稿?(總數量:N份)」→「成功列印」。 +- 提料中:「列印空白頁數標籤」→「請輸入要列印的標籤數量:」。 + +### 9.2 完成後正式列印(「成品提貨記錄」) + +1. 「查看詳情」或列表動作。 +2. 「列印提料單」「列印送貨單標籤」「列印提料單和送貨單標籤」「補印標籤」。 +3. 彈窗「請輸入總箱數」,欄位「箱數」(≥1)。 +4. 成功:「成功列印」。 + +### 9.3 補印 + +- 「補印送貨單標籤」:「起始箱號」「結束箱號」「總箱數」 +- 校驗:起始≥1、結束≥起始、結束≤總箱數等。 + +### 9.4 送貨路線摘要 + +- Tab「送貨路線摘要」→ 選「車線」→「下載報告 (PDF)」。 +- 若未執完:「此車線仍有 N 張訂單未執拾。是否仍要列印 / 下載送貨路線摘要?」 + +### 9.5 出箱數量 + +- Tab「成品出倉出箱數量」:按日「2/F 出箱數」「4/F 出箱數」「車線-X 出箱數」「總出箱數」(來自完成時填的箱數)。 + +--- + +## 十、查看提貨情況與管理動作 + +### 10.1 查詢 + +- 「目標日期」「重新載入」「樓層」「狀態」(「待撳單」「提貨中」「已完成」)。 +- 欄含貨車/車線/裝載順序/提票號碼/負責員工/訂單項目數量等。 + +### 10.2 管理(常需 ADMIN) + +| 動作 | 意義(畫面說明意涵) | +|------|----------------------| +| 「撤銷領取」 | 清空負責人,單據回待分配,他人可再領 | +| 「強制完成提貨單」 | 標完成並歸檔,不改已揀數量;適用已全部提交但系統未結案 | + +未授權:「僅管理員(ADMIN 權限)可使用」。 + +--- + +## 十一、狀態對照(避免搞混三套名稱) + +### 11.1 送貨訂單「來貨狀態」 + +| 鍵 | 畫面 | +|----|------| +| pending | 「待處理」 | +| receiving | 「接收中」 | +| completed | 「已完成」 | +| (部分流程)released / picking | 「已放單」/「提料中」 | + +### 11.2 「查看提貨情況」提票狀態 + +| 鍵 | 畫面 | 白話 | +|----|------|------| +| pending | 「待撳單」 | 已放單、尚未領取 | +| released | 「提貨中」 | 已撳單/出倉中 | +| completed | 「已完成」 | 提貨完成 | + +### 11.3 提料單通用(pickOrder) + +「待處理」「已放單」「提料中」「已完成」——與上表用詞接近但場景不同;操作時以**目前所在分頁**的 Chip 文案為準。 + +--- + +## 十二、成品出倉管理(ADMIN) + +- 「倉庫管理」→「成品出倉管理」(`/finishedGood/management`)。 +- 「提料順序」:上移/下移/置頂/置底、「新增物品」「儲存」「重新載入」。 +- 「出貨倉位」「入貨倉位」等主檔維護。 + +--- + +## 十三、路徑速查 + +| 畫面 | 路徑 | +|------|------| +| 送貨訂單 | `/do` | +| DO 詳情/放單 | `/do/edit?id=` | +| 成品出倉(主) | `/doworkbench` | +| 成品出倉管理 | `/finishedGood/management` | +| 舊成品出倉 | `/finishedGood`(附錄對照用) | + +--- + +## 十四、常見問題速查 + +| 情況 | 建議 | +|------|------| +| 批量放單後倉庫看不到票 | 核對日期「是日/翌日」、樓層票別、批量/單量、是否加單檢視 | +| 車線按鈕 0 單 | 換日期/樓層;查「車線-X」「未完成提料單」 | +| 掃碼一直失敗 | 批號是否上架、是否被留貨/過期/單位不符 | +| 印不出標籤 | 先選 A4/標籤打印機;完成後記得填「箱數」 | +| 加單與正單混在一起 | 明確進/出「加單」檢視;合併規則要同店同線同時段 | +| 畫面突然英文 | 少數錯誤字串尚未進 zh,以實機為準並回報補譯 | diff --git a/scripts/export_m18_mapping_office.py b/scripts/export_m18_mapping_office.py new file mode 100644 index 0000000..e006e5a --- /dev/null +++ b/scripts/export_m18_mapping_office.py @@ -0,0 +1,345 @@ +#!/usr/bin/env python3 +# -*- coding: utf-8 -*- +""" +Export MTMS ↔ M18 mapping docs to Word (.docx) and Excel (.xlsx). + +Prereqs: + pip install python-docx openpyxl + +Usage (from repo root): + python scripts/export_m18_mapping_office.py + +Outputs: + docs/exports/MTMS_M18_DATA_MAPPING.docx + docs/exports/MTMS_M18_DATA_MAPPING.xlsx +""" + +from __future__ import annotations + +import re +from datetime import datetime, timezone +from pathlib import Path + +from docx import Document +from docx.enum.text import WD_ALIGN_PARAGRAPH +from docx.oxml.ns import qn +from docx.shared import Cm, Pt, RGBColor +from openpyxl import Workbook +from openpyxl.styles import Alignment, Border, Font, PatternFill, Side +from openpyxl.utils import get_column_letter + +ROOT = Path(__file__).resolve().parents[1] +DOCS = ROOT / "docs" +GEN = DOCS / "generated" +OUT = DOCS / "exports" +HANDBOOK = DOCS / "MTMS_M18_DATA_MAPPING.md" +ITEM_TYPE_MD = GEN / "m18-item-type-mapping.md" +STSEARCH_MD = GEN / "m18-stsearch-types.md" + + +def strip_md_inline(s: str) -> str: + s = s.strip() + s = re.sub(r"\[([^\]]+)\]\([^)]+\)", r"\1", s) # links + s = s.replace("**", "").replace("`", "").replace("*", "") + return s.strip() + + +def parse_md_tables(text: str) -> list[tuple[str, list[str], list[list[str]]]]: + """ + Return list of (section_title, headers, rows) for each markdown table. + section_title = nearest preceding ## / ### heading. + """ + lines = text.splitlines() + current_h = "" + tables: list[tuple[str, list[str], list[list[str]]]] = [] + i = 0 + while i < len(lines): + line = lines[i] + if line.startswith("#"): + current_h = strip_md_inline(re.sub(r"^#+\s*", "", line)) + i += 1 + continue + if line.strip().startswith("|") and i + 1 < len(lines) and re.match( + r"^\|[\s\-:|]+\|$", lines[i + 1].strip() + ): + header = [strip_md_inline(c) for c in line.strip().strip("|").split("|")] + i += 2 + rows: list[list[str]] = [] + while i < len(lines) and lines[i].strip().startswith("|"): + row = [strip_md_inline(c) for c in lines[i].strip().strip("|").split("|")] + rows.append(row) + i += 1 + tables.append((current_h, header, rows)) + continue + i += 1 + return tables + + +def add_runs_with_code(paragraph, text: str) -> None: + """Simple split on backticks for monospace-ish plain text.""" + parts = re.split(r"`([^`]+)`", text) + for idx, part in enumerate(parts): + if not part: + continue + run = paragraph.add_run(part) + run.font.name = "Calibri" + run._element.rPr.rFonts.set(qn("w:eastAsia"), "Microsoft JhengHei") + if idx % 2 == 1: + run.font.name = "Consolas" + run.font.size = Pt(9) + + +def md_to_docx(md_path: Path, out_path: Path, extra_md_files: list[Path] | None = None) -> None: + doc = Document() + section = doc.sections[0] + section.top_margin = Cm(2) + section.bottom_margin = Cm(2) + section.left_margin = Cm(2.2) + section.right_margin = Cm(2.2) + + style = doc.styles["Normal"] + style.font.name = "Calibri" + style.font.size = Pt(11) + style._element.rPr.rFonts.set(qn("w:eastAsia"), "Microsoft JhengHei") + + files = [md_path] + (extra_md_files or []) + first = True + for path in files: + if not path.is_file(): + continue + if not first: + doc.add_page_break() + first = False + _append_md_file(doc, path) + + footer = doc.sections[0].footer.paragraphs[0] + footer.text = ( + f"MTMS ↔ M18 mapping · exported {datetime.now(timezone.utc).strftime('%Y-%m-%d %H:%M UTC')}" + ) + footer.alignment = WD_ALIGN_PARAGRAPH.CENTER + + out_path.parent.mkdir(parents=True, exist_ok=True) + doc.save(out_path) + print(f"Wrote {out_path.relative_to(ROOT)}") + + +def _append_md_file(doc: Document, path: Path) -> None: + lines = path.read_text(encoding="utf-8").splitlines() + i = 0 + in_code = False + code_buf: list[str] = [] + + while i < len(lines): + line = lines[i] + + if line.startswith("") + lines.append("") + lines.append("# M18 `udfProducttype` → MTMS `items.type`") + lines.append("") + lines.append(f"_Generated: {datetime.now(timezone.utc).strftime('%Y-%m-%d %H:%M UTC')}_") + lines.append("") + lines.append("**Source of truth**") + lines.append("") + lines.append("- Enums: `NewItemRequest.kt` → `ItemType`, `M18ItemType`") + lines.append("- Sync: `M18MasterDataService.saveProduct` / `saveProducts` (`when (pro.udfProducttype)`)") + lines.append("- UI labels (inventory): `FPSMS-frontend/src/i18n/zh/inventory.json`") + lines.append("") + lines.append("## Sync mapping") + lines.append("") + lines.append("| M18 `udfProducttype` (exact string) | `M18ItemType` | MTMS `items.type` | `ItemType` | Inventory UI (zh) |") + lines.append("|---|---|---|---|---|") + + for m18_const, item_const in when_pairs: + if m18_const == "__else__": + mtms_val = item_by_const.get(item_const, "?") + lines.append( + f"| *(any other value / empty)* | — | `{mtms_val}` | `{item_const}` | {ui.get(mtms_val, '—')} |" + ) + continue + m18_val = m18_by_const.get(m18_const, "?") + mtms_val = item_by_const.get(item_const, "?") + lines.append( + f"| `{m18_val}` | `{m18_const}` | `{mtms_val}` | `{item_const}` | {ui.get(mtms_val, '—')} |" + ) + + lines.append("") + lines.append("## Enum inventories") + lines.append("") + lines.append("### `M18ItemType`") + lines.append("") + lines.append("| Constant | String value | Used in sync `when`? |") + lines.append("|---|---|---|") + for c, v in m18_types: + used = "yes" if c in mapped_m18_consts else "**no**" + lines.append(f"| `{c}` | `{v}` | {used} |") + + lines.append("") + lines.append("### `ItemType` (MTMS stored values)") + lines.append("") + lines.append("| Constant | `items.type` | Inventory UI (zh) |") + lines.append("|---|---|---|") + for c, v in item_types: + lines.append(f"| `{c}` | `{v}` | {ui.get(v, '—')} |") + + lines.append("") + lines.append("## Known gaps (not auto-mapped)") + lines.append("") + lines.append("| M18 value seen | Effect | Notes |") + lines.append("|---|---|---|") + for val, note in KNOWN_UNMAPPED_M18_VALUES: + lines.append(f"| `{val}` | → `mat` (else) | {note} |") + lines.append("") + lines.append("Frontend Settings → Items edit also offers `cmb` / `wip` / `nm` as local types;") + lines.append("those are **not** written by the current M18 `udfProducttype` mapper.") + lines.append("") + lines.append("## Regenerate") + lines.append("") + lines.append("```bash") + lines.append("python scripts/generate_m18_mapping_docs.py") + lines.append("```") + lines.append("") + + OUT_DIR.mkdir(parents=True, exist_ok=True) + path = OUT_DIR / "m18-item-type-mapping.md" + path.write_text("\n".join(lines), encoding="utf-8") + print(f"Wrote {path.relative_to(ROOT)}") + + +def write_stsearch_doc(st_types: list[tuple[str, str]]) -> None: + lines: list[str] = [] + lines.append("") + lines.append("") + lines.append("# M18 `StSearchType` (master list APIs)") + lines.append("") + lines.append(f"_Generated: {datetime.now(timezone.utc).strftime('%Y-%m-%d %H:%M UTC')}_") + lines.append("") + lines.append("**Source:** `m18/model/M18MasterDataRequest.kt`") + lines.append("") + lines.append("| Constant | `stSearch` value | Typical MTMS sync target |") + lines.append("|---|---|---|") + hints = { + "PRODUCT": "items (+ item_uom via prices)", + "VENDOR": "shop (`type=supplier`)", + "CUSTOMER": "(enum present; sync usage varies)", + "UNIT": "uom_conversion (+ m18 cunit)", + "CURRENCY": "currency", + "BOM": "bom / bom_material (udfbomforshop)", + "BUSINESS_UNIT": "shop (`type=shop`)", + } + for c, v in st_types: + lines.append(f"| `{c}` | `{v}` | {hints.get(c, '—')} |") + lines.append("") + + path = OUT_DIR / "m18-stsearch-types.md" + path.write_text("\n".join(lines), encoding="utf-8") + print(f"Wrote {path.relative_to(ROOT)}") + + +def main() -> None: + new_item = NEW_ITEM_REQUEST.read_text(encoding="utf-8") + master_req = MASTER_DATA_REQUEST.read_text(encoding="utf-8") + service = MASTER_DATA_SERVICE.read_text(encoding="utf-8") + + item_types = parse_kotlin_string_enum(new_item, "ItemType") + m18_types = parse_kotlin_string_enum(new_item, "M18ItemType") + st_types = parse_kotlin_string_enum(master_req, "StSearchType") + # StSearchType uses `value` not always matching parse — enum uses (val value: String) + # Our regex still works for NAME("x") + when_pairs = parse_producttype_when_branches(service) + ui = load_ui_labels() + + write_item_type_doc(item_types, m18_types, when_pairs, ui) + write_stsearch_doc(st_types) + + +if __name__ == "__main__": + main() diff --git a/src/main/java/com/ffii/fpsms/config/security/SecurityConfig.java b/src/main/java/com/ffii/fpsms/config/security/SecurityConfig.java index 9158d12..842f330 100644 --- a/src/main/java/com/ffii/fpsms/config/security/SecurityConfig.java +++ b/src/main/java/com/ffii/fpsms/config/security/SecurityConfig.java @@ -74,6 +74,11 @@ public class SecurityConfig { return new BCryptPasswordEncoder(); } + /** + * FP-MTMS Version Checklist | Functions Ref. No. 51 | v1.0.0 | 2026-08-05 + * FP-MTMS Version Checklist | Functions Ref. No. 3 | v1.0.5 | 2026-08-05 + * (stockAdjustment/submit → INVENTORY_ADJUST only) + */ @Bean @Order(1) public SecurityFilterChain filterChain(HttpSecurity http) throws Exception { @@ -112,7 +117,17 @@ public class SecurityConfig { .requestMatchers(HttpMethod.GET, "/label-printer-monitor/odometer-stats") .hasAnyAuthority("TESTING", "ADMIN") .requestMatchers(HttpMethod.POST, "/stockAdjustment/submit") - .hasAnyAuthority("ADMIN", "INVENTORY_ADJUST") + .hasAuthority("INVENTORY_ADJUST") + .requestMatchers(HttpMethod.GET, "/inventoryLotLine/trace") + .hasAuthority("ITEM_TRACING") + .requestMatchers(HttpMethod.GET, "/inventoryLotLine/trace/location/**") + .hasAuthority("ITEM_TRACING") + /* 工單 生產流程 完成工單 */ + .requestMatchers(HttpMethod.POST, "/product-process/Demo/ProcessLine/complete/**") + .hasAuthority("PRODUCT_PROCESS") + /* 工序「已完成」(Just Pass):僅 ADMIN */ + .requestMatchers(HttpMethod.POST, "/product-process/Demo/ProcessLine/pass/**") + .hasAuthority("ADMIN") .anyRequest().authenticated()) .httpBasic(httpBasic -> httpBasic.authenticationEntryPoint( (request, response, authException) -> sendUnauthorizedJson(response, "Unauthorized", "UNAUTHORIZED"))) diff --git a/src/main/java/com/ffii/fpsms/modules/deliveryOrder/service/DoFloorSupplierSettingsService.kt b/src/main/java/com/ffii/fpsms/modules/deliveryOrder/service/DoFloorSupplierSettingsService.kt index 9b43642..76f0acf 100644 --- a/src/main/java/com/ffii/fpsms/modules/deliveryOrder/service/DoFloorSupplierSettingsService.kt +++ b/src/main/java/com/ffii/fpsms/modules/deliveryOrder/service/DoFloorSupplierSettingsService.kt @@ -69,6 +69,28 @@ open class DoFloorSupplierSettingsService( return null } + /** + * FP-MTMS Version Checklist | Functions Ref. No. 37 | v1.0.1 | 2026-08-03 + * Warehouse store scope for workbench lot suggestions (`2/F` / `4/F`). + * Uses ticket [dbStoreId] when set; when null (Truck X), derives floor from linked DO + * supplier codes (4F settings list wins; else 2F). Does not write DOPO.storeId. + */ + open fun resolveSuggestionStoreId(dbStoreId: String?, supplierCodes: Collection): String { + val raw = dbStoreId?.trim()?.takeIf { it.isNotEmpty() } + if (raw != null) { + return when (raw.replace("/", "").uppercase(Locale.ROOT)) { + "2F" -> "2/F" + "4F" -> "4/F" + else -> raw + } + } + val (s2, s4) = loadDoFloorSupplierLists() + val any4F = supplierCodes.any { code -> + preferredStoreFloorForSupplier(code, s2, s4) == "4F" + } + return if (any4F) "4/F" else "2/F" + } + data class SqlPreferredFloorCases( /** 例如 `CASE WHEN s.code IN (...) THEN '4F' ... END`(單行,可嵌入原生 SQL) */ val floorStringCase: String, diff --git a/src/main/java/com/ffii/fpsms/modules/deliveryOrder/service/DoReleaseCoordinatorService.kt b/src/main/java/com/ffii/fpsms/modules/deliveryOrder/service/DoReleaseCoordinatorService.kt index 7b3ca0a..e2ab2f4 100644 --- a/src/main/java/com/ffii/fpsms/modules/deliveryOrder/service/DoReleaseCoordinatorService.kt +++ b/src/main/java/com/ffii/fpsms/modules/deliveryOrder/service/DoReleaseCoordinatorService.kt @@ -706,8 +706,11 @@ class DoReleaseCoordinatorService( val existing = candidates.firstOrNull { c -> c.truckId == first.truckId && c.truckDepartureTime == first.truckDepartureTime && - c.truckLanceCode == first.truckLanceCode - } ?: candidates.minByOrNull { it.id ?: Long.MAX_VALUE } + c.truckLanceCode == first.truckLanceCode && + (storeId != null || doPickOrderMatchesTruckXPreferredFloor(c, first.preferredFloor)) + } ?: candidates + .filter { storeId != null || doPickOrderMatchesTruckXPreferredFloor(it, first.preferredFloor) } + .minByOrNull { it.id ?: Long.MAX_VALUE } if (existing != null) { println( " DEBUG: Merging batch into existing DoPickOrder id=${existing.id}, ticket=${existing.ticketNo} " + @@ -754,6 +757,33 @@ class DoReleaseCoordinatorService( println(" DEBUG: Created ${results.size} DoPickOrderLine records") } + + /** + * Truck X (`storeId` null): only merge when every linked DO supplier preferred floor + * matches [preferredFloor] (4F list wins; else 2F). Empty tickets are allowed. + */ + private fun doPickOrderMatchesTruckXPreferredFloor( + dpo: DoPickOrder, + preferredFloor: String?, + ): Boolean { + val target = when (preferredFloor?.trim()?.uppercase(java.util.Locale.ROOT)?.replace("/", "")) { + "4F" -> "4F" + else -> "2F" + } + val id = dpo.id ?: return true + val lines = doPickOrderLineRepository.findByDoPickOrderIdAndDeletedFalse(id) + if (lines.isEmpty()) return true + val (_, s4f) = doFloorSupplierSettingsService.loadDoFloorSupplierLists() + fun floorOf(supplierCode: String?): String = + if (!supplierCode.isNullOrBlank() && s4f.contains(supplierCode.trim())) "4F" else "2F" + for (line in lines) { + val doId = line.doOrderId ?: continue + val dorder = deliveryOrderRepository.findById(doId).orElse(null) ?: continue + if (floorOf(dorder.supplier?.code) != target) return false + } + return true + } + private fun checkPickOrderHasStockIssues(doPickOrderId: Long): Boolean { try { // 1. 获取 do_pick_order 的所有 pick orders 数量 @@ -858,7 +888,8 @@ class DoReleaseCoordinatorService( .firstOrNull { it.requiredDeliveryDate == result.estimatedArrivalDate && it.truckDepartureTime == result.truckDepartureTime && - it.truckLanceCode == result.truckLanceCode + it.truckLanceCode == result.truckLanceCode && + (storeId != null || doPickOrderMatchesTruckXPreferredFloor(it, result.preferredFloor)) } if (existingDoPickOrder != null) { diff --git a/src/main/java/com/ffii/fpsms/modules/deliveryOrder/service/DoWorkbenchDopoAssignmentService.kt b/src/main/java/com/ffii/fpsms/modules/deliveryOrder/service/DoWorkbenchDopoAssignmentService.kt index 4fa089f..fa55cbe 100644 --- a/src/main/java/com/ffii/fpsms/modules/deliveryOrder/service/DoWorkbenchDopoAssignmentService.kt +++ b/src/main/java/com/ffii/fpsms/modules/deliveryOrder/service/DoWorkbenchDopoAssignmentService.kt @@ -23,6 +23,7 @@ open class DoWorkbenchDopoAssignmentService( private val pickOrderRepository: PickOrderRepository, private val suggestedPickLotWorkbenchService: SuggestedPickLotWorkbenchService, private val stockOutLineWorkbenchService: StockOutLineWorkbenchService, + private val doFloorSupplierSettingsService: DoFloorSupplierSettingsService, ) { @Transactional @@ -95,6 +96,7 @@ open class DoWorkbenchDopoAssignmentService( } /** + * FP-MTMS Version Checklist | Functions Ref. No. 37 | v1.0.1 | 2026-08-03 * Same UX as [DoPickOrderAssignmentService.assignByLane] but candidates come from * [delivery_order_pick_order] (+ unassigned [pick_order]), not [do_pick_order]. */ @@ -145,7 +147,7 @@ open class DoWorkbenchDopoAssignmentService( params["loadingSequence"] = request.loadingSequence } if (isisExtraReleaseType(request.releaseType)) { - sql.append(WorkbenchReleaseTypeSupport.legacyIsExtraSql()) + sql.append(WorkbenchReleaseTypeSupport.etraFamilySql()) } else { sql.append(WorkbenchReleaseTypeSupport.assignFilterSql(request.releaseType)) } @@ -206,7 +208,10 @@ open class DoWorkbenchDopoAssignmentService( ) } - /** Legacy lane assign (V1): old FG-style (no atomic conflict guard). */ + /** + * FP-MTMS Version Checklist | Functions Ref. No. 37 | v1.0.1 | 2026-08-03 + * Legacy lane assign (V1): old FG-style (no atomic conflict guard). + */ @Transactional open fun assignByLaneForWorkbenchV1(request: AssignByLaneRequest): MessageResponse { val user = userRepository.findById(request.userId).orElse(null) @@ -253,7 +258,7 @@ open class DoWorkbenchDopoAssignmentService( params["loadingSequence"] = request.loadingSequence } if (isisExtraReleaseType(request.releaseType)) { - sql.append(WorkbenchReleaseTypeSupport.legacyIsExtraSql()) + sql.append(WorkbenchReleaseTypeSupport.etraFamilySql()) } else { sql.append(WorkbenchReleaseTypeSupport.assignFilterSql(request.releaseType)) } @@ -395,18 +400,19 @@ open class DoWorkbenchDopoAssignmentService( ) } - // 下方逻辑先保持不变(你后续再做第二阶段批次化) + // Suggestion store scope: use DOPO.storeId when set; Truck X (null) → 2/F or 4/F from DO suppliers. val storeIdRow = jdbcDao.queryForList( "SELECT storeId FROM fpsmsdb.delivery_order_pick_order WHERE id = :id AND deleted = 0", mapOf("id" to dopoId), ).firstOrNull() val storeIdKey = storeIdRow?.keys?.find { it.equals("storeId", true) } - val storeId = storeIdKey?.let { storeIdRow[it]?.toString() }?.trim()?.takeIf { it.isNotEmpty() } - + val dbStoreId = storeIdKey?.let { storeIdRow[it]?.toString() }?.trim()?.takeIf { it.isNotEmpty() } + val suggestionStoreId = resolveSuggestionStoreIdForDopo(dopoId, dbStoreId) + for (poId in poIds) { suggestedPickLotWorkbenchService.primeNextSingleLotSuggestionsForPickOrder( pickOrderId = poId, - storeId = storeId, + storeId = suggestionStoreId, excludeWarehouseCodes = null, ) stockOutLineWorkbenchService.ensureStockOutLinesForPickOrderNoHold(poId, userId) @@ -434,4 +440,33 @@ open class DoWorkbenchDopoAssignmentService( ) ) } + + /** + * FP-MTMS Version Checklist | Functions Ref. No. 37 | v1.0.1 | 2026-08-03 + * Truck X: derive 2/F|4/F from linked DO suppliers when DOPO.storeId is null. + */ + private fun resolveSuggestionStoreIdForDopo(dopoId: Long, dbStoreId: String?): String { + if (!dbStoreId.isNullOrBlank()) { + return doFloorSupplierSettingsService.resolveSuggestionStoreId(dbStoreId, emptyList()) + } + val rows = try { + jdbcDao.queryForList( + """ + SELECT DISTINCT s.code AS supplierCode + FROM fpsmsdb.pick_order po + INNER JOIN fpsmsdb.delivery_order d ON d.id = po.doId AND d.deleted = 0 + LEFT JOIN fpsmsdb.shop s ON s.id = d.supplierId AND s.deleted = 0 + WHERE po.deliveryOrderPickOrderId = :dopoId AND po.deleted = 0 + """.trimIndent(), + mapOf("dopoId" to dopoId), + ) + } catch (_: Exception) { + emptyList() + } + val codes = rows.mapNotNull { row -> + val k = row.keys.find { it.equals("supplierCode", true) } ?: return@mapNotNull null + row[k]?.toString() + } + return doFloorSupplierSettingsService.resolveSuggestionStoreId(null, codes) + } } diff --git a/src/main/java/com/ffii/fpsms/modules/deliveryOrder/service/DoWorkbenchMainService.kt b/src/main/java/com/ffii/fpsms/modules/deliveryOrder/service/DoWorkbenchMainService.kt index 492d7f1..5c2bc1e 100644 --- a/src/main/java/com/ffii/fpsms/modules/deliveryOrder/service/DoWorkbenchMainService.kt +++ b/src/main/java/com/ffii/fpsms/modules/deliveryOrder/service/DoWorkbenchMainService.kt @@ -45,6 +45,7 @@ import com.ffii.fpsms.modules.stock.entity.enum.InventoryLotLineStatus import com.ffii.fpsms.modules.stock.entity.projection.StockOutLineInfo import com.ffii.fpsms.modules.stock.service.StockOutLineWorkbenchService import com.ffii.fpsms.modules.stock.service.SuggestedPickLotWorkbenchService +import com.ffii.fpsms.modules.stock.service.StockLedgerLotSnapshot import com.ffii.fpsms.modules.stock.service.WorkbenchStockOutLinePickProgress import com.ffii.fpsms.modules.stock.web.model.StockOutLineStatus import org.springframework.stereotype.Service @@ -134,6 +135,7 @@ open class DoWorkbenchMainService( private val itemsRepository: ItemsRepository, private val transactionManager: PlatformTransactionManager, private val doReplenishmentService: DoReplenishmentService, + private val doFloorSupplierSettingsService: DoFloorSupplierSettingsService, ) { @PersistenceContext private lateinit var entityManager: EntityManager @@ -265,6 +267,7 @@ open class DoWorkbenchMainService( } /** + * FP-MTMS Version Checklist | Functions Ref. No. 38 | v1.0.0 | 2026-08-03 * Workbench scan-pick (DO FG): * 1) Post outbound on scanned inventory lot line first; on failure return a clear message. * 2) If the lot runs out before this stock-out line’s chunk is filled and the user did not pass a short [qty], @@ -323,6 +326,7 @@ open class DoWorkbenchMainService( sol.handledBy = request.userId if (sol.startTime == null) sol.startTime = LocalDateTime.now() sol.endTime = LocalDateTime.now() + maybeMarkIssueJustComplete(sol, request.justComplete == true) stockOutLIneRepository.saveAndFlush(sol) val polZero = sol.pickOrderLine updateJoPickOrderHandledByIfJobOrder( @@ -617,6 +621,7 @@ if (sol.startTime == null) sol.startTime = LocalDateTime.now() if (solEndStatus.equals(StockOutLineStatus.COMPLETE.status, ignoreCase = true)) { sol.endTime = LocalDateTime.now() } +maybeMarkIssueJustComplete(sol, request.justComplete == true) stockOutLIneRepository.save(sol) stockOutLIneRepository.flush() sol.id?.let { suggestedPickLotWorkbenchService.linkSplToStockOutLineAfterWorkbenchPick(it) } @@ -850,6 +855,9 @@ return MessageResponse( ) } + // Truck X keeps storeId null; split count by supplier preferred floor (same as DO Search tabs). + val truckXFloorKey = actualStoreId.replace("/", "").trim().uppercase(java.util.Locale.ROOT) + val truckXFloorClause = truckXPreferredFloorExistsSql(truckXFloorKey) val defaultCountSql = """ SELECT COUNT(DISTINCT dop.id) AS c FROM fpsmsdb.delivery_order_pick_order dop @@ -862,6 +870,7 @@ return MessageResponse( SELECT 1 FROM fpsmsdb.pick_order po WHERE po.deliveryOrderPickOrderId = dop.id AND po.deleted = 0 ) + $truckXFloorClause $releaseFilterClause """.trimIndent() @@ -882,43 +891,114 @@ return MessageResponse( } /** - * Workbench Etra view: all `delivery_order_pick_order` with `releaseType` = isExtra (case-insensitive), - * for one [requiredDeliveryDate], grouped by shop then by truck / time / loading sequence. + * FP-MTMS Version Checklist | Functions Ref. No. 37 | v1.0.1 | 2026-08-03 + * Workbench Etra view: `isExtra` / `isExtrabatch` / `isExtrasingle` tickets for one day, + * grouped by shop then truck / time / loading sequence. + * Truck X (`車線-X`, DB `storeId` null) is included and split by supplier preferred floor + * into display `storeId` `2/F`/`4/F` (response only; not written to DB). */ open fun getWorkbenchEtraLaneSummary(requiredDate: LocalDate?): List { val targetDate = requiredDate ?: LocalDate.now() val defaultTruck = truckRepository.findById(5577L).orElse(null) - val defaultTruckLaneCode = defaultTruck?.truckLanceCode ?: "" + val defaultTruckLaneCode = (defaultTruck?.truckLanceCode ?: "車線-X").trim() + val (_, s4f) = doFloorSupplierSettingsService.loadDoFloorSupplierLists() + val in4 = s4f.joinToString(", ") { "'" + it.replace("'", "''") + "'" } + .ifEmpty { "'__NO_4F_SUPPLIER__'" } + val floorExpr = "CASE WHEN s.code IN ($in4) THEN '4F' ELSE '2F' END" + val etraReleaseFilter = WorkbenchReleaseTypeSupport.etraFamilySql("dop.releaseType") val sql = """ SELECT - dop.shopCode AS shopCode, - dop.shopName AS shopName, - dop.storeId AS storeId, - dop.truckDepartureTime AS truckDepartureTime, - dop.truckLanceCode AS truckLanceCode, - dop.loadingSequence AS loadingSequence, - COUNT(DISTINCT dop.id) AS total_cnt, - SUM(CASE WHEN dop.handledBy IS NULL THEN 1 ELSE 0 END) AS unassigned_cnt, + base.shopCode AS shopCode, + base.shopName AS shopName, + base.displayStoreId AS storeId, + base.truckDepartureTime AS truckDepartureTime, + base.truckLanceCode AS truckLanceCode, + base.loadingSequence AS loadingSequence, + COUNT(DISTINCT base.id) AS total_cnt, + SUM(CASE WHEN base.handledBy IS NULL THEN 1 ELSE 0 END) AS unassigned_cnt, GROUP_CONCAT( - DISTINCT NULLIF(TRIM(dop.handlerName), '') - ORDER BY dop.handlerName + DISTINCT NULLIF(TRIM(base.handlerName), '') + ORDER BY base.handlerName SEPARATOR ', ' ) AS handler_names - FROM fpsmsdb.delivery_order_pick_order dop - WHERE dop.deleted = 0 - AND LOWER(COALESCE(dop.releaseType, '')) = 'isExtra' - AND dop.requiredDeliveryDate = :requiredDate - AND dop.ticketStatus IN ('pending', 'released') - AND EXISTS ( - SELECT 1 FROM fpsmsdb.pick_order po - WHERE po.deliveryOrderPickOrderId = dop.id AND po.deleted = 0 - ) - GROUP BY dop.shopCode, dop.shopName, dop.storeId, dop.truckDepartureTime, dop.truckLanceCode, dop.loadingSequence + FROM ( + SELECT + dop.id AS id, + dop.shopCode AS shopCode, + dop.shopName AS shopName, + dop.handledBy AS handledBy, + dop.handlerName AS handlerName, + dop.truckDepartureTime AS truckDepartureTime, + dop.truckLanceCode AS truckLanceCode, + dop.loadingSequence AS loadingSequence, + CASE + WHEN (dop.storeId IS NULL OR TRIM(dop.storeId) = '') + AND TRIM(dop.truckLanceCode) = :defaultTruckLane + THEN CASE + WHEN EXISTS ( + SELECT 1 + FROM fpsmsdb.pick_order po + INNER JOIN fpsmsdb.delivery_order d ON d.id = po.doId AND d.deleted = 0 + LEFT JOIN fpsmsdb.shop s ON s.id = d.supplierId AND s.deleted = 0 + WHERE po.deliveryOrderPickOrderId = dop.id AND po.deleted = 0 + AND ($floorExpr) = '4F' + ) + AND NOT EXISTS ( + SELECT 1 + FROM fpsmsdb.pick_order po2 + INNER JOIN fpsmsdb.delivery_order d2 ON d2.id = po2.doId AND d2.deleted = 0 + LEFT JOIN fpsmsdb.shop s2 ON s2.id = d2.supplierId AND s2.deleted = 0 + WHERE po2.deliveryOrderPickOrderId = dop.id AND po2.deleted = 0 + AND (CASE WHEN s2.code IN ($in4) THEN '4F' ELSE '2F' END) <> '4F' + ) + THEN '4/F' + WHEN EXISTS ( + SELECT 1 + FROM fpsmsdb.pick_order po + INNER JOIN fpsmsdb.delivery_order d ON d.id = po.doId AND d.deleted = 0 + LEFT JOIN fpsmsdb.shop s ON s.id = d.supplierId AND s.deleted = 0 + WHERE po.deliveryOrderPickOrderId = dop.id AND po.deleted = 0 + AND ($floorExpr) = '2F' + ) + AND NOT EXISTS ( + SELECT 1 + FROM fpsmsdb.pick_order po2 + INNER JOIN fpsmsdb.delivery_order d2 ON d2.id = po2.doId AND d2.deleted = 0 + LEFT JOIN fpsmsdb.shop s2 ON s2.id = d2.supplierId AND s2.deleted = 0 + WHERE po2.deliveryOrderPickOrderId = dop.id AND po2.deleted = 0 + AND (CASE WHEN s2.code IN ($in4) THEN '4F' ELSE '2F' END) <> '2F' + ) + THEN '2/F' + ELSE NULL + END + ELSE dop.storeId + END AS displayStoreId + FROM fpsmsdb.delivery_order_pick_order dop + WHERE dop.deleted = 0 + $etraReleaseFilter + AND dop.requiredDeliveryDate = :requiredDate + AND dop.ticketStatus IN ('pending', 'released') + AND EXISTS ( + SELECT 1 FROM fpsmsdb.pick_order po + WHERE po.deliveryOrderPickOrderId = dop.id AND po.deleted = 0 + ) + ) base + WHERE base.displayStoreId IS NOT NULL + OR TRIM(COALESCE(base.truckLanceCode, '')) <> :defaultTruckLane + GROUP BY + base.shopCode, base.shopName, base.displayStoreId, + base.truckDepartureTime, base.truckLanceCode, base.loadingSequence """.trimIndent() val rawRows: List> = try { - jdbcDao.queryForList(sql, mapOf("requiredDate" to targetDate)) + jdbcDao.queryForList( + sql, + mapOf( + "requiredDate" to targetDate, + "defaultTruckLane" to defaultTruckLaneCode, + ), + ) } catch (e: Exception) { println("❌ getWorkbenchEtraLaneSummary: ${e.message}") emptyList() @@ -961,8 +1041,11 @@ return MessageResponse( val aggs = rawRows.mapNotNull { row -> val lance = cellStr(row, "truckLanceCode") ?: return@mapNotNull null - if (lance == defaultTruckLaneCode) return@mapNotNull null val storeIdCol = cellStr(row, "storeId") + // Truck X must have derived display floor (2/F or 4/F); drop mixed/unknown. + if (lance == defaultTruckLaneCode && storeIdCol.isNullOrBlank()) { + return@mapNotNull null + } val ttKey = row.keys.find { it.equals("truckDepartureTime", true) } val ttVal = ttKey?.let { row[it] } val sortTime = when (ttVal) { @@ -996,13 +1079,14 @@ return MessageResponse( } return byShop.entries - .map { (key, group) -> + .map { (_, group) -> val head = group.first() val lanes = group .sortedWith( compareBy { it.sortTime } .thenBy { it.lance } .thenBy { it.loadingSequence ?: 999 } + .thenBy { it.storeId ?: "" } ) .map { val is4F = it.storeId?.replace("/", "")?.trim()?.equals("4F", ignoreCase = true) == true @@ -1033,13 +1117,22 @@ return MessageResponse( storeId: String?, truck: String?, releaseTypeFilter: String? = null, + floor: String? = null, ): List = - queryWorkbenchReleasedDopoList(shopName, storeId, truck, beforeToday = true, releaseTypeFilter = releaseTypeFilter) + queryWorkbenchReleasedDopoList( + shopName, + storeId, + truck, + beforeToday = true, + releaseTypeFilter = releaseTypeFilter, + floor = floor, + ) /** * @param requiredDeliveryDate when null, uses [LocalDate.now] (calendar today). * When set, filters `dop.requiredDeliveryDate = :targetDate` (workbench date picker / select day). - * @param releaseTypeFilter when `isExtra` (case-insensitive), only `delivery_order_pick_order.releaseType = isExtra` rows. + * @param releaseTypeFilter when `isExtra`/`etra`, filters Etra family (`isExtra` / `isExtrabatch` / `isExtrasingle`). + * @param floor optional `2F`/`4F`: for Truck X (null storeId) filter by DO supplier preferred floor. */ open fun findWorkbenchReleasedDeliveryOrderPickOrdersForSelectionToday( shopName: String?, @@ -1047,6 +1140,7 @@ return MessageResponse( truck: String?, requiredDeliveryDate: LocalDate? = null, releaseTypeFilter: String? = null, + floor: String? = null, ): List = queryWorkbenchReleasedDopoList( shopName, @@ -1055,6 +1149,7 @@ return MessageResponse( beforeToday = false, equalsDeliveryDate = requiredDeliveryDate, releaseTypeFilter = releaseTypeFilter, + floor = floor, ) /** @@ -1648,6 +1743,134 @@ return MessageResponse( ) } + /** + * FP-MTMS Version Checklist | Functions Ref. No. 37 | v1.0.1 | 2026-08-03 + * Store scope for workbench lot (re)suggestions. + * Prefer [requestStoreId]; else DOPO.storeId; Truck X (both null) → 2/F|4/F from DO suppliers. + */ + private fun resolveWorkbenchSuggestionStoreId(pickOrderId: Long, requestStoreId: String?): String { + if (!requestStoreId.isNullOrBlank()) { + return doFloorSupplierSettingsService.resolveSuggestionStoreId(requestStoreId, emptyList()) + } + val header = try { + jdbcDao.queryForList( + """ + SELECT dop.storeId AS storeId + FROM fpsmsdb.pick_order po + INNER JOIN fpsmsdb.delivery_order_pick_order dop + ON dop.id = po.deliveryOrderPickOrderId AND dop.deleted = 0 + WHERE po.id = :poId AND po.deleted = 0 + LIMIT 1 + """.trimIndent(), + mapOf("poId" to pickOrderId), + ).firstOrNull() + } catch (_: Exception) { + null + } + val dbStoreId = header?.let { row -> + val k = row.keys.find { it.equals("storeId", true) } ?: return@let null + row[k]?.toString()?.trim()?.takeIf { it.isNotEmpty() } + } + if (!dbStoreId.isNullOrBlank()) { + return doFloorSupplierSettingsService.resolveSuggestionStoreId(dbStoreId, emptyList()) + } + val supplierRows = try { + jdbcDao.queryForList( + """ + SELECT DISTINCT s.code AS supplierCode + FROM fpsmsdb.pick_order po + INNER JOIN fpsmsdb.delivery_order d ON d.id = po.doId AND d.deleted = 0 + LEFT JOIN fpsmsdb.shop s ON s.id = d.supplierId AND s.deleted = 0 + WHERE po.id = :poId AND po.deleted = 0 + """.trimIndent(), + mapOf("poId" to pickOrderId), + ) + } catch (_: Exception) { + emptyList() + } + val codes = supplierRows.mapNotNull { row -> + val k = row.keys.find { it.equals("supplierCode", true) } ?: return@mapNotNull null + row[k]?.toString() + } + return doFloorSupplierSettingsService.resolveSuggestionStoreId(null, codes) + } + + /** + * FP-MTMS Version Checklist | Functions Ref. No. 37 | v1.0.1 | 2026-08-03 + * UI / lot-print / scan-pick store scope for a DOPO. + * Truck X keeps DB storeId null; response may expose derived 2/F|4/F (not written to DB). + */ + private fun resolveDisplayStoreIdForDopo(dopoId: Long, dbStoreId: String?): String { + if (!dbStoreId.isNullOrBlank()) { + return doFloorSupplierSettingsService.resolveSuggestionStoreId(dbStoreId, emptyList()) + } + val supplierRows = try { + jdbcDao.queryForList( + """ + SELECT DISTINCT s.code AS supplierCode + FROM fpsmsdb.pick_order po + INNER JOIN fpsmsdb.delivery_order d ON d.id = po.doId AND d.deleted = 0 + LEFT JOIN fpsmsdb.shop s ON s.id = d.supplierId AND s.deleted = 0 + WHERE po.deliveryOrderPickOrderId = :dopoId AND po.deleted = 0 + """.trimIndent(), + mapOf("dopoId" to dopoId), + ) + } catch (_: Exception) { + emptyList() + } + val codes = supplierRows.mapNotNull { row -> + val k = row.keys.find { it.equals("supplierCode", true) } ?: return@mapNotNull null + row[k]?.toString() + } + return doFloorSupplierSettingsService.resolveSuggestionStoreId(null, codes) + } + + /** + * FP-MTMS Version Checklist | Functions Ref. No. 37 | v1.0.1 | 2026-08-03 + * Copy DOPO header map with Truck X display storeId filled for hierarchical fgInfo. + */ + private fun enrichDopoInfoStoreIdForUi(doPickOrderInfo: Map, dopoId: Long): Map { + val storeKey = doPickOrderInfo.keys.find { it.equals("store_id", true) } ?: "store_id" + val dbStoreId = doPickOrderInfo[storeKey]?.toString()?.trim()?.takeIf { it.isNotEmpty() } + if (!dbStoreId.isNullOrBlank()) return doPickOrderInfo + val resolved = resolveDisplayStoreIdForDopo(dopoId, null) + return doPickOrderInfo.toMutableMap().apply { this[storeKey] = resolved } + } + + /** + * EXISTS clause: Truck X ticket has at least one DO whose supplier preferred floor matches [floorKey] + * (`2F` / `4F`), and **no** linked DO on the other floor. + * Aligns with [DoFloorSupplierSettingsService.preferredStoreFloorForSupplier] + * (4F list wins; otherwise 2F). Empty string = no extra filter. + */ + private fun truckXPreferredFloorExistsSql(floorKey: String?): String { + val key = floorKey?.trim()?.uppercase(java.util.Locale.ROOT)?.replace("/", "").orEmpty() + if (key != "2F" && key != "4F") return "" + val (_, s4f) = doFloorSupplierSettingsService.loadDoFloorSupplierLists() + val in4 = s4f.joinToString(", ") { "'" + it.replace("'", "''") + "'" } + .ifEmpty { "'__NO_4F_SUPPLIER__'" } + val floorExpr = "CASE WHEN s.code IN ($in4) THEN '4F' ELSE '2F' END" + return """ + AND EXISTS ( + SELECT 1 + FROM fpsmsdb.pick_order po + INNER JOIN fpsmsdb.delivery_order d ON d.id = po.doId AND d.deleted = 0 + LEFT JOIN fpsmsdb.shop s ON s.id = d.supplierId AND s.deleted = 0 + WHERE po.deliveryOrderPickOrderId = dop.id AND po.deleted = 0 + AND ($floorExpr) = '$key' + ) + AND NOT EXISTS ( + SELECT 1 + FROM fpsmsdb.pick_order po2 + INNER JOIN fpsmsdb.delivery_order d2 ON d2.id = po2.doId AND d2.deleted = 0 + LEFT JOIN fpsmsdb.shop s2 ON s2.id = d2.supplierId AND s2.deleted = 0 + WHERE po2.deliveryOrderPickOrderId = dop.id AND po2.deleted = 0 + AND (CASE WHEN s2.code IN ($in4) THEN '4F' ELSE '2F' END) <> '$key' + ) + """.trimIndent() + } + + /** FP-MTMS Version Checklist | Functions Ref. No. 37 | v1.0.1 | 2026-08-03 */ private fun queryWorkbenchReleasedDopoList( shopName: String?, storeId: String?, @@ -1655,6 +1878,7 @@ return MessageResponse( beforeToday: Boolean, equalsDeliveryDate: LocalDate? = null, releaseTypeFilter: String? = null, + floor: String? = null, ): List { val today = LocalDate.now() val params = mutableMapOf() @@ -1706,8 +1930,14 @@ return MessageResponse( params["shopPat"] = "%${shopName.trim()}%" } val rtNorm = releaseTypeFilter?.trim()?.lowercase().orEmpty() - if (rtNorm == "isExtra") { - sqlBuilder.append(" AND LOWER(COALESCE(dop.releaseType, '')) = 'isExtra' ") + if (rtNorm == "isextra" || rtNorm == "etra" || rtNorm == "etrafamily") { + // Etra modal: standalone isExtra + merged isExtrabatch / isExtrasingle + sqlBuilder.append(WorkbenchReleaseTypeSupport.etraFamilySql("dop.releaseType")) + } + // Optional floor: used for Truck X (null storeId) display split; does not write storeId. + val floorClause = truckXPreferredFloorExistsSql(floor) + if (floorClause.isNotBlank()) { + sqlBuilder.append(" ").append(floorClause).append(" ") } sqlBuilder.append(" ORDER BY dop.requiredDeliveryDate, dop.truckDepartureTime, dop.truckLanceCode, dop.id ") val rows: List> = try { @@ -1889,7 +2119,7 @@ return MessageResponse( payload = assembleHierarchicalFgPayload( jdbcDao = jdbcDao, doPickOrderId = doPickOrderId, - doPickOrderInfo = doPickOrderInfoSafe, + doPickOrderInfo = enrichDopoInfoStoreIdForUi(doPickOrderInfoSafe, doPickOrderId), pickOrdersInfo = pickOrdersInfo, availableQtyInOutOnly = true, timingSink = ::mark, @@ -1954,13 +2184,12 @@ return MessageResponse( return mapOf("fgInfo" to null, "pickOrders" to emptyList()) } - // Completed-record detail should keep each PO as an independent group - // (ticket can link multiple POs, e.g. 20 + 25), not merged into a single 45-line bucket. + val doPickOrderInfoForUi = enrichDopoInfoStoreIdForUi(doPickOrderInfo, deliveryOrderPickOrderId) val perPoPayloads = pickOrdersInfo.map { poInfo -> assembleHierarchicalFgPayload( jdbcDao = jdbcDao, doPickOrderId = deliveryOrderPickOrderId, - doPickOrderInfo = doPickOrderInfo, + doPickOrderInfo = doPickOrderInfoForUi, pickOrdersInfo = listOf(poInfo), availableQtyInOutOnly = true, ) @@ -2044,6 +2273,13 @@ return MessageResponse( } else { emptyList() } + val dopoId = (row["doPickOrderId"] as? Number)?.toLong() ?: 0L + val dbStoreId = row["storeId"]?.toString()?.trim()?.takeIf { it.isNotEmpty() } + val displayStoreId = if (dopoId > 0L) { + resolveDisplayStoreIdForDopo(dopoId, dbStoreId) + } else { + dbStoreId.orEmpty() + } mapOf( "doPickOrderId" to (row["doPickOrderId"] ?: 0L), @@ -2068,7 +2304,7 @@ return MessageResponse( "truckLanceCode" to (row["truckLanceCode"] ?: ""), "DepartureTime" to (row["DepartureTime"]?.toString() ?: ""), "ticketNo" to (row["ticketNo"] ?: ""), - "storeId" to (row["storeId"] ?: ""), + "storeId" to displayStoreId, "qrCodeData" to (row["doPickOrderId"] ?: 0L), ) } @@ -2150,6 +2386,7 @@ return MessageResponse( var postMs = 0L try { if (pickOrderId != null) { + val suggestionStoreId = resolveWorkbenchSuggestionStoreId(pickOrderId, requestStoreId) val resuggestExcludeWarehouseCodes = workbenchResuggestExcludeWarehouseCodes( pickOrderId = pickOrderId, poType = null, @@ -2162,7 +2399,7 @@ return MessageResponse( suggestedPickLotWorkbenchService.setNoHoldSuggestionsForPickOrderLineNextSingleLot( pickOrderLineId = polId, targetQty = explicitRemainder, - storeId = requestStoreId, + storeId = suggestionStoreId, excludeInventoryLotLineId = scannedIllId, excludeWarehouseCodes = resuggestExcludeWarehouseCodes, ) @@ -2196,7 +2433,7 @@ return MessageResponse( rebuildMs = measureTimeMillis { suggestedPickLotWorkbenchService.rebuildNoHoldSuggestionsForPickOrderLine( pickOrderLineId = polId, - storeId = requestStoreId, + storeId = suggestionStoreId, excludeWarehouseCodes = resuggestExcludeWarehouseCodes, ) } @@ -2353,6 +2590,9 @@ return MessageResponse( val onHandAfter = (inventory.onHandQty ?: BigDecimal.ZERO).toDouble() val previousBalance = onHandAfter + deltaQty.toDouble() val newBalance = previousBalance - deltaQty.toDouble() + val ill = sol.inventoryLotLine?.id?.let { inventoryLotLineRepository.findById(it).orElse(sol.inventoryLotLine) } + ?: sol.inventoryLotLine + val lotAfter = ill?.let { StockLedgerLotSnapshot.availableQty(it) } val ledger = StockLedger().apply { this.stockOutLine = sol this.inventory = inventory @@ -2362,14 +2602,54 @@ return MessageResponse( this.type = "NOR" this.itemId = solItem.id this.itemCode = solItem.code - this.uomId = itemUomRespository.findByItemIdAndStockUnitIsTrueAndDeletedIsFalse(solItem.id!!)?.uom?.id - ?: inventory.uom?.id + this.uomId = StockLedgerLotSnapshot.resolveLedgerUomId( + ill, + itemUomRespository.findByItemIdAndStockUnitIsTrueAndDeletedIsFalse(solItem.id!!)?.uom?.id, + inventory.uom?.id, + ) this.date = LocalDate.now() } + StockLedgerLotSnapshot.apply( + ledger = ledger, + inventoryLotLine = ill, + lotAvailableAfterMove = lotAfter, + signedDelta = deltaQty.negate(), + ) // Do not flush per pick; transaction flush is sufficient and avoids slow roundtrips/locks. stockLedgerRepository.save(ledger) } + /** + * FP-MTMS Version Checklist | Functions Ref. No. 38 | v1.0.0 | 2026-08-03 + * DO user-audit: Just Complete is reportable only when the item still has pickable stock + * at complete time (AVAILABLE, not expired, in−out > 0). + * + * Legitimate (do not flag): suggested lot exhausted → system splits remainder SOL → JC qty=0 + * with no other available lots. + * Issue (flag): between split and JC a new/other available lot exists but user still JC. + */ + private fun maybeMarkIssueJustComplete(sol: StockOutLine, justComplete: Boolean) { + if (!justComplete) return + val pol = sol.pickOrderLine ?: return + val po = pol.pickOrder + val isDo = po?.type == PickOrderType.DELIVERY_ORDER + if (!isDo) return + + val itemId = pol.item?.id ?: sol.item?.id ?: return + val today = LocalDate.now() + val excludeIllId = sol.inventoryLotLine?.id + val hasPickableLot = inventoryLotLineRepository.findAllByItemIdIn(listOf(itemId)) + .asSequence() + .filter { !it.deleted } + .filter { excludeIllId == null || it.id != excludeIllId } + .filter { it.status == InventoryLotLineStatus.AVAILABLE } + .filter { it.inventoryLot?.expiryDate?.isBefore(today) != true } + .any { StockLedgerLotSnapshot.availableQty(it) > BigDecimal.ZERO } + if (hasPickableLot) { + sol.isIssueJustComplete = true + } + } + private fun resolveWorkbenchPreviousBalance( itemId: Long, inventory: Inventory, diff --git a/src/main/java/com/ffii/fpsms/modules/deliveryOrder/service/DoWorkbenchReleaseService.kt b/src/main/java/com/ffii/fpsms/modules/deliveryOrder/service/DoWorkbenchReleaseService.kt index a557e76..c16ee15 100644 --- a/src/main/java/com/ffii/fpsms/modules/deliveryOrder/service/DoWorkbenchReleaseService.kt +++ b/src/main/java/com/ffii/fpsms/modules/deliveryOrder/service/DoWorkbenchReleaseService.kt @@ -94,6 +94,7 @@ open class DoWorkbenchReleaseService( private val jdbcDao: JdbcDao, private val suggestedPickLotWorkbenchService: SuggestedPickLotWorkbenchService, private val stockOutLineWorkbenchService: StockOutLineWorkbenchService, + private val doFloorSupplierSettingsService: DoFloorSupplierSettingsService, ) { private val poolSize = Runtime.getRuntime().availableProcessors() private val executor = Executors.newFixedThreadPool(kotlin.math.min(poolSize, 4)) @@ -523,7 +524,9 @@ open class DoWorkbenchReleaseService( params: MutableMap, ) { if (storeId.isNullOrBlank()) { + // Truck X: storeId stays null, but do not merge across supplier preferred floors (2F vs 4F). sql.append(" AND (storeId IS NULL OR TRIM(COALESCE(storeId, '')) = '') ") + appendTruckXPreferredFloorMatchClauses(sql, first.preferredFloor) } else { sql.append(" AND storeId = :storeId ") params["storeId"] = storeId @@ -552,6 +555,59 @@ open class DoWorkbenchReleaseService( } } + /** + * For Truck X headers (`storeId` null): only merge into a ticket whose linked DO suppliers + * all resolve to the same preferred floor as [preferredFloor] (4F list wins; else 2F), + * matching normal lane separation by floor / [resolveStoreId]. + */ + private fun appendTruckXPreferredFloorMatchClauses( + sql: StringBuilder, + preferredFloor: String?, + ) { + val floor = when (preferredFloor?.trim()?.uppercase(java.util.Locale.ROOT)?.replace("/", "")) { + "4F" -> "4F" + else -> "2F" + } + val (_, s4f) = doFloorSupplierSettingsService.loadDoFloorSupplierLists() + val in4 = s4f.joinToString(", ") { "'" + it.replace("'", "''") + "'" } + .ifEmpty { "'__NO_4F_SUPPLIER__'" } + val floorExpr = "CASE WHEN s.code IN ($in4) THEN '4F' ELSE '2F' END" + // Reject tickets that already have any DO on a different floor. + sql.append( + """ + AND NOT EXISTS ( + SELECT 1 + FROM fpsmsdb.pick_order po + INNER JOIN fpsmsdb.delivery_order d ON d.id = po.doId AND d.deleted = 0 + LEFT JOIN fpsmsdb.shop s ON s.id = d.supplierId AND s.deleted = 0 + WHERE po.deliveryOrderPickOrderId = delivery_order_pick_order.id + AND po.deleted = 0 + AND ($floorExpr) <> '$floor' + ) + """.trimIndent() + ) + // Prefer an existing same-floor ticket; allow empty header (no pick_order yet). + sql.append( + """ + AND ( + NOT EXISTS ( + SELECT 1 FROM fpsmsdb.pick_order po0 + WHERE po0.deliveryOrderPickOrderId = delivery_order_pick_order.id AND po0.deleted = 0 + ) + OR EXISTS ( + SELECT 1 + FROM fpsmsdb.pick_order po + INNER JOIN fpsmsdb.delivery_order d ON d.id = po.doId AND d.deleted = 0 + LEFT JOIN fpsmsdb.shop s ON s.id = d.supplierId AND s.deleted = 0 + WHERE po.deliveryOrderPickOrderId = delivery_order_pick_order.id + AND po.deleted = 0 + AND ($floorExpr) = '$floor' + ) + ) + """.trimIndent() + ) + } + private fun mapRowToMergeableTicket(row: Map): MergeableWorkbenchTicket? { fun cell(name: String): String? { val key = row.keys.find { it.equals(name, true) } ?: return null diff --git a/src/main/java/com/ffii/fpsms/modules/deliveryOrder/service/WorkbenchReleaseTypeSupport.kt b/src/main/java/com/ffii/fpsms/modules/deliveryOrder/service/WorkbenchReleaseTypeSupport.kt index ac433ba..e892303 100644 --- a/src/main/java/com/ffii/fpsms/modules/deliveryOrder/service/WorkbenchReleaseTypeSupport.kt +++ b/src/main/java/com/ffii/fpsms/modules/deliveryOrder/service/WorkbenchReleaseTypeSupport.kt @@ -2,7 +2,7 @@ package com.ffii.fpsms.modules.deliveryOrder.service /** * Workbench [delivery_order_pick_order.releaseType] values and SQL filters. - * Legacy `isExtra` tickets are excluded from merge; only used for legacy Etra views. + * Etra UI uses [etraFamilySql] (standalone `isExtra` + merged `isExtrabatch` / `isExtrasingle`). */ object WorkbenchReleaseTypeSupport { const val BATCH = "batch" @@ -15,20 +15,23 @@ object WorkbenchReleaseTypeSupport { fun singleFamilyTypes(): List = listOf(SINGLE, IS_EXTRA_SINGLE) + /** FP-MTMS Version Checklist | Functions Ref. No. 37 | v1.0.1 | 2026-08-03 */ fun summaryFilterSql(releaseType: String, column: String = "dop.releaseType"): String = when (releaseType.trim().lowercase()) { "batch" -> batchFamilySql(column) "single" -> singleFamilySql(column) - "isextra" -> legacyIsExtraSql(column) + // Etra UI / list: standalone + merged extra family + "isextra", "etra", "etrafamily" -> etraFamilySql(column) else -> "" } + /** FP-MTMS Version Checklist | Functions Ref. No. 37 | v1.0.1 | 2026-08-03 */ fun assignFilterSql(releaseType: String?, column: String = "dop.releaseType"): String { val n = releaseType?.trim()?.lowercase().orEmpty() return when (n) { "batch" -> batchFamilySql(column) "single" -> singleFamilySql(column) - "isextra" -> legacyIsExtraSql(column) + "isextra", "etra", "etrafamily" -> etraFamilySql(column) else -> "" } } @@ -42,6 +45,13 @@ object WorkbenchReleaseTypeSupport { fun legacyIsExtraSql(column: String = "dop.releaseType"): String = " AND LOWER(COALESCE($column, '')) = 'isextra' " + /** + * FP-MTMS Version Checklist | Functions Ref. No. 37 | v1.0.1 | 2026-08-03 + * Standalone `isExtra` plus merged `isExtrabatch` / `isExtrasingle` (Etra workbench). + */ + fun etraFamilySql(column: String = "dop.releaseType"): String = + " AND LOWER(COALESCE($column, '')) IN ('isextra', 'isextrabatch', 'isextrasingle') " + fun newHeaderReleaseType(isExtraRelease: Boolean, isSingleRelease: Boolean): String = when { isExtraRelease && isSingleRelease -> IS_EXTRA_SINGLE isExtraRelease -> IS_EXTRA_BATCH diff --git a/src/main/java/com/ffii/fpsms/modules/deliveryOrder/web/DoWorkbenchController.kt b/src/main/java/com/ffii/fpsms/modules/deliveryOrder/web/DoWorkbenchController.kt index 702db0a..9ffbd45 100644 --- a/src/main/java/com/ffii/fpsms/modules/deliveryOrder/web/DoWorkbenchController.kt +++ b/src/main/java/com/ffii/fpsms/modules/deliveryOrder/web/DoWorkbenchController.kt @@ -115,12 +115,15 @@ class DoWorkbenchController( @RequestParam(required = false) storeId: String?, @RequestParam(required = false) truck: String?, @RequestParam(required = false) releaseType: String?, + /** Optional `2F`/`4F`: Truck X list split by DO supplier preferred floor (storeId stays null). */ + @RequestParam(required = false) floor: String?, ): List { return doWorkbenchMainService.findWorkbenchReleasedDeliveryOrderPickOrdersForSelection( shopName, storeId, truck, releaseTypeFilter = releaseType, + floor = floor, ) } @@ -131,6 +134,8 @@ class DoWorkbenchController( @RequestParam(required = false) truck: String?, @RequestParam(required = false) @DateTimeFormat(iso = DateTimeFormat.ISO.DATE) requiredDate: LocalDate?, @RequestParam(required = false) releaseType: String?, + /** Optional `2F`/`4F`: Truck X list split by DO supplier preferred floor (storeId stays null). */ + @RequestParam(required = false) floor: String?, ): List { return doWorkbenchMainService.findWorkbenchReleasedDeliveryOrderPickOrdersForSelectionToday( shopName, @@ -138,6 +143,7 @@ class DoWorkbenchController( truck, requiredDeliveryDate = requiredDate, releaseTypeFilter = releaseType, + floor = floor, ) } diff --git a/src/main/java/com/ffii/fpsms/modules/deliveryOrder/web/models/DoDetailResponse.kt b/src/main/java/com/ffii/fpsms/modules/deliveryOrder/web/models/DoDetailResponse.kt index 6a82ef9..9c9260c 100644 --- a/src/main/java/com/ffii/fpsms/modules/deliveryOrder/web/models/DoDetailResponse.kt +++ b/src/main/java/com/ffii/fpsms/modules/deliveryOrder/web/models/DoDetailResponse.kt @@ -68,7 +68,7 @@ data class LaneBtn( val truckDepartureTime: String? = null, ) -/** All Etra (`releaseType=isExtra`) tickets for a day, grouped by shop then truck (no 2F/4F split in UI). */ +/** Etra family tickets (`isExtra` / `isExtrabatch` / `isExtrasingle`) for a day, grouped by shop then truck. */ data class WorkbenchEtraShopLaneGroup( val shopCode: String?, val shopName: String?, diff --git a/src/main/java/com/ffii/fpsms/modules/deliveryOrder/web/models/WorkbenchScanPickRequest.kt b/src/main/java/com/ffii/fpsms/modules/deliveryOrder/web/models/WorkbenchScanPickRequest.kt index f53b9db..a3e0474 100644 --- a/src/main/java/com/ffii/fpsms/modules/deliveryOrder/web/models/WorkbenchScanPickRequest.kt +++ b/src/main/java/com/ffii/fpsms/modules/deliveryOrder/web/models/WorkbenchScanPickRequest.kt @@ -3,6 +3,7 @@ package com.ffii.fpsms.modules.deliveryOrder.web.models import java.math.BigDecimal /** + * FP-MTMS Version Checklist | Functions Ref. No. 38 | v1.0.0 | 2026-08-03 * Workbench v1: scan lot and post pick immediately (no separate submit step). * [qty] optional: when null, posts up to remaining quantity for this stock-out line chunk; when set, may exceed that * chunk and is capped only by available quantity on the scanned inventory lot line (overscan / UI edit). @@ -25,6 +26,11 @@ data class WorkbenchScanPickRequest( */ val inventoryLotLineId: Long? = null, val qty: BigDecimal? = null, + /** + * When true, UI used Just Complete (no QR). Backend may set [StockOutLine.isIssueJustComplete] + * for DO user-audit reporting. + */ + val justComplete: Boolean? = false, val userId: Long = 1L, ) diff --git a/src/main/java/com/ffii/fpsms/modules/jobOrder/service/JoPickOrderService.kt b/src/main/java/com/ffii/fpsms/modules/jobOrder/service/JoPickOrderService.kt index 98d510f..d0383a4 100644 --- a/src/main/java/com/ffii/fpsms/modules/jobOrder/service/JoPickOrderService.kt +++ b/src/main/java/com/ffii/fpsms/modules/jobOrder/service/JoPickOrderService.kt @@ -231,6 +231,7 @@ open class JoPickOrderService( } return joPickOrderRecordRepository.saveAll(joPickOrderRecords) } + /** FP-MTMS Version Checklist | Functions Ref. No. 44 | v1.0.0 | 2026-08-03 */ open fun getAllJobOrderLotsWithDetailsHierarchical(userId: Long): JobOrderLotsHierarchicalResponse { println("=== Debug: getAllJobOrderLotsWithDetailsHierarchical ===") println("today: ${LocalDate.now()}") @@ -557,6 +558,7 @@ open class JoPickOrderService( } // Get completed job order pick orders (for second tab) // Fix the getCompletedJobOrderLotsHierarchical method + /** FP-MTMS Version Checklist | Functions Ref. No. 44 | v1.0.0 | 2026-08-03 */ open fun getCompletedJobOrderLotsHierarchical(userId: Long): Map { println("=== Debug: getCompletedJobOrderLotsHierarchical (repo version) ===") println("today: ${LocalDate.now()}") @@ -625,13 +627,20 @@ open class JoPickOrderService( .filter { it.deleted == false } } else emptyList() - val inventoryLotLineIds = suggestedPickLots.mapNotNull { it.suggestedLotLine?.id } + // Prefer actualPickLotLine for display (after scan/switch); fallback suggested + val inventoryLotLineIds = suggestedPickLots + .mapNotNull { it.actualPickLotLine?.id ?: it.suggestedLotLine?.id } + .distinct() val inventoryLotLines = if (inventoryLotLineIds.isNotEmpty()) { inventoryLotLineRepository .findAllByIdIn(inventoryLotLineIds) .filter { it.deleted == false } } else emptyList() + + val inventoryLotLineById = inventoryLotLines + .filter { it.id != null } + .associateBy { it.id!! } val inventoryLotIds = inventoryLotLines.mapNotNull { it.inventoryLot?.id }.distinct() val inventoryLots = if (inventoryLotIds.isNotEmpty()) { @@ -664,16 +673,19 @@ open class JoPickOrderService( } val lots = lineSuggestedLots.mapNotNull { spl -> - val ill = spl.suggestedLotLine ?: return@mapNotNull null - if (ill.deleted == true) return@mapNotNull null + val displayIllId = (spl.actualPickLotLine?.takeIf { it.deleted != true }?.id) + ?: (spl.suggestedLotLine?.takeIf { it.deleted != true }?.id) + ?: return@mapNotNull null + val ill = inventoryLotLineById[displayIllId] ?: return@mapNotNull null val il = ill.inventoryLot ?: return@mapNotNull null if (il.deleted == true) return@mapNotNull null val warehouse = ill.warehouse - val sol = stockOutLines.firstOrNull { - it.pickOrderLine?.id == pol.id && it.inventoryLotLine?.id == ill.id - } + val sol = spl.stockOutLine?.takeIf { it.deleted != true && it.pickOrderLine?.id == pol.id } + ?: stockOutLines.firstOrNull { + it.pickOrderLine?.id == pol.id && it.inventoryLotLine?.id == ill.id + } val availableQty = if (sol?.status == "rejected") { null @@ -1140,7 +1152,7 @@ open fun recordSecondScanIssue(request: SecondScanIssueRequest): MessageResponse il.lotNo as lotNo, w.name as storeLocation FROM fpsmsdb.suggested_pick_lot spl - JOIN fpsmsdb.inventory_lot_line ill ON spl.suggestedLotLineId = ill.id + JOIN fpsmsdb.inventory_lot_line ill ON ill.id = COALESCE(spl.actualPickLotLineId, spl.suggestedLotLineId) JOIN fpsmsdb.inventory_lot il ON ill.inventoryLotId = il.id LEFT JOIN fpsmsdb.warehouse w ON w.id = ill.warehouseId WHERE spl.pickOrderLineId = :pickOrderLineId @@ -1506,7 +1518,7 @@ open fun getCompletedJobOrderPickOrderLotDetails(pickOrderId: Long): List - // 通过关系链:InventoryLotLine -> InventoryLot -> StockInLine -> id - ill.inventoryLot?.stockInLine?.id?.let { stockInLineId -> - illId to stockInLineId - } + val stockInLinesByInventoryLotLineId = buildMap { + (inventoryLotLines + stockOutInventoryLotLines).forEach { ill -> + val id = ill.id ?: return@forEach + if (!containsKey(id)) { + ill.inventoryLot?.stockInLine?.id?.let { put(id, it) } } - .toMap() - } else { - emptyMap() + } } // 获取 jo_pick_order 记录 @@ -2617,22 +2629,22 @@ open fun getCompletedJobOrderPickOrders(completedDate: LocalDate?): List - val ill = spl.suggestedLotLine - if (ill == null || ill.deleted == true) return@mapNotNull null + val displayIllId = (spl.actualPickLotLine?.takeIf { it.deleted != true }?.id) + ?: (spl.suggestedLotLine?.takeIf { it.deleted != true }?.id) + ?: return@mapNotNull null + val ill = inventoryLotLineById[displayIllId] ?: return@mapNotNull null val il = ill.inventoryLot if (il == null || il.deleted == true) return@mapNotNull null val warehouse = ill.warehouse - val sol = stockOutLines.firstOrNull { - it.pickOrderLine?.id == pol.id && it.inventoryLotLine?.id == ill.id - } - val jpo = joPickOrders.firstOrNull { it.itemId == item?.id } - val handlerName = jpo?.handledBy?.let { uid -> - userService.find(uid).orElse(null)?.name - } - println("handlerName: $handlerName") + val sol = spl.stockOutLine?.takeIf { it.deleted != true && it.pickOrderLine?.id == pol.id } + ?: stockOutLines.firstOrNull { + it.pickOrderLine?.id == pol.id && it.inventoryLotLine?.id == ill.id + } + val jpoInner = joPickOrders.firstOrNull { it.itemId == item?.id } val availableQty = if (sol?.status == "rejected") { null } else { @@ -2655,7 +2667,6 @@ open fun getCompletedJobOrderPickOrders(completedDate: LocalDate?): List "pending" } - // 获取 stockInLineId val stockInLineId = ill.id?.let { illId -> stockInLinesByInventoryLotLineId[illId] } @@ -2685,11 +2696,11 @@ open fun getCompletedJobOrderPickOrders(completedDate: LocalDate?): List diff --git a/src/main/java/com/ffii/fpsms/modules/jobOrder/service/JoWorkbenchMainService.kt b/src/main/java/com/ffii/fpsms/modules/jobOrder/service/JoWorkbenchMainService.kt index cc63f1d..b0404f5 100644 --- a/src/main/java/com/ffii/fpsms/modules/jobOrder/service/JoWorkbenchMainService.kt +++ b/src/main/java/com/ffii/fpsms/modules/jobOrder/service/JoWorkbenchMainService.kt @@ -32,7 +32,8 @@ import java.util.Locale /** * Job Order Workbench–only flows (assign prime, hierarchical payload for scan-pick UI). - * [getJobOrderLotsHierarchicalByPickOrderIdWorkbench] uses **in − out** for available qty (matches workbench scan-pick). + * [getJobOrderLotsHierarchicalByPickOrderIdWorkbench] uses **in − out** for available qty (matches workbench scan-pick) + * and displays COALESCE(actualPickLotLine, suggestedLotLine). * Non-workbench hierarchical stays in [JoPickOrderService.getJobOrderLotsHierarchicalByPickOrderId] (in − out − hold). */ @Service @@ -190,7 +191,9 @@ open class JoWorkbenchMainService( } /** - * Hierarchical pick UI for JO Workbench: available qty **in − out**; stockouts include **suggestedPickQty** when SPL matches SOL lot line. + * FP-MTMS Version Checklist | Functions Ref. No. 44 | v1.0.0 | 2026-08-03 + * Hierarchical pick UI for JO Workbench: available qty **in − out**; + * lot display uses COALESCE(actualPickLotLine, suggestedLotLine) (same as DO workbench). */ open fun getJobOrderLotsHierarchicalByPickOrderIdWorkbench(pickOrderId: Long): JobOrderLotsHierarchicalWorkbenchResponse { //println("=== JoWorkbenchMainService.getJobOrderLotsHierarchicalByPickOrderIdWorkbench ===") @@ -239,7 +242,10 @@ open class JoWorkbenchMainService( emptyList() } - val inventoryLotLineIds = suggestedPickLots.mapNotNull { it.suggestedLotLine?.id } + // Prefer actualPickLotLine for display (after scan/switch); fallback suggested (old / not yet picked). + val inventoryLotLineIds = suggestedPickLots + .mapNotNull { displayIllForWorkbench(it)?.id } + .distinct() val inventoryLotLines = if (inventoryLotLineIds.isNotEmpty()) { inventoryLotLineRepository.findAllByIdIn(inventoryLotLineIds) @@ -253,16 +259,6 @@ open class JoWorkbenchMainService( inventoryLotRepository.findAllByIdIn(inventoryLotIds).filter { it.deleted == false } } - val stockOutLines = if (pickOrderLineIds.isNotEmpty() && inventoryLotLineIds.isNotEmpty()) { - pickOrderLineIds.flatMap { polId -> - inventoryLotLineIds.flatMap { illId -> - stockOutLineRepository.findByPickOrderLineIdAndInventoryLotLineIdAndDeletedFalse(polId, illId) - } - } - } else { - emptyList() - } - val stockOutLinesByPickOrderLine = pickOrderLineIds.associateWith { polId -> stockOutLineRepository.findAllByPickOrderLineIdAndDeletedFalse(polId) } @@ -322,27 +318,33 @@ open class JoWorkbenchMainService( val uom = pol.uom val lineId = pol.id!! val lineSuggestedLots = suggestedPickLots.filter { it.pickOrderLine?.id == pol.id } + val lineSols = stockOutLinesByPickOrderLine[lineId] ?: emptyList() val jpo = joPickOrders.firstOrNull { it.itemId == item?.id } val handlerName = jpo?.handledBy?.let { uid -> userService.find(uid).orElse(null)?.name } val lots = lineSuggestedLots.mapNotNull { spl -> - val ill = spl.suggestedLotLine - if (ill == null || ill.deleted == true) return@mapNotNull null + val displayIllId = displayIllForWorkbench(spl)?.id ?: return@mapNotNull null + // Prefer freshly loaded ILL (actual or suggested) so lotNo/location match display id. + val ill = inventoryLotLineById[displayIllId] + ?: displayIllForWorkbench(spl) + ?: return@mapNotNull null + if (ill.deleted == true) return@mapNotNull null val il = ill.inventoryLot if (il == null || il.deleted == true) return@mapNotNull null val warehouse = ill.warehouse - val sol = stockOutLines.firstOrNull { - it.pickOrderLine?.id == pol.id && it.inventoryLotLine?.id == ill.id + val linkedSolId = spl.stockOutLine?.takeIf { it.deleted != true }?.id + val sol = when { + linkedSolId != null -> lineSols.firstOrNull { it.id == linkedSolId } + else -> lineSols.firstOrNull { it.inventoryLotLineId == ill.id } } val jpoInner = joPickOrders.firstOrNull { it.itemId == item?.id } val handlerNameInner = jpoInner?.handledBy?.let { uid -> userService.find(uid).orElse(null)?.name } - //println("handlerName: $handlerNameInner") val availableQty = if (sol?.status == "rejected") { null } else { @@ -377,7 +379,7 @@ open class JoWorkbenchMainService( location = warehouse?.code, availableQty = availableQty?.toDouble(), requiredQty = spl.qty?.toDouble() ?: 0.0, - actualPickQty = sol?.qty ?: 0.0, + actualPickQty = sol?.qty?.toDouble() ?: 0.0, processingStatus = processingStatus, lotAvailability = lotAvailability, pickOrderId = pickOrder.id, @@ -387,7 +389,7 @@ open class JoWorkbenchMainService( stockOutLineId = sol?.id, stockInLineId = stockInLineId, suggestedPickLotId = spl.id, - stockOutLineQty = sol?.qty ?: 0.0, + stockOutLineQty = sol?.qty?.toDouble() ?: 0.0, stockOutLineStatus = sol?.status, routerIndex = warehouse?.order?.toString(), routerArea = warehouse?.code, @@ -397,9 +399,9 @@ open class JoWorkbenchMainService( matchBy = jpoInner?.matchBy, matchQty = jpoInner?.matchQty?.toDouble() ) - } + }.distinctBy { it.lotId } - val stockouts = (stockOutLinesByPickOrderLine[lineId] ?: emptyList()).map { sol -> + val stockouts = lineSols.map { sol -> val illId = sol.inventoryLotLineId val ill = if (illId != null) inventoryLotLineById[illId] else null val lot = ill?.inventoryLot @@ -412,7 +414,10 @@ open class JoWorkbenchMainService( illAvailableQtyWorkbench(ill) } val splForSol = lineSuggestedLots.firstOrNull { sp -> - sp.suggestedLotLine?.id != null && sp.suggestedLotLine?.id == illId + sp.stockOutLine?.id != null && sp.stockOutLine?.id == sol.id + } ?: lineSuggestedLots.firstOrNull { sp -> + val displayId = displayIllForWorkbench(sp)?.id + displayId != null && displayId == illId } StockOutLineDetailResponse( @@ -456,6 +461,18 @@ open class JoWorkbenchMainService( } } + /** + * FP-MTMS Version Checklist | Functions Ref. No. 44 | v1.0.0 | 2026-08-03 + * Workbench UI display lot: prefer actual pick (after scan/switch), fallback suggested (pending / old data). + */ + private fun displayIllForWorkbench(spl: com.ffii.fpsms.modules.stock.entity.SuggestedPickLot): InventoryLotLine? { + val actual = spl.actualPickLotLine + if (actual != null && actual.deleted != true) return actual + val suggested = spl.suggestedLotLine + if (suggested != null && suggested.deleted != true) return suggested + return null + } + private fun emptyHierarchical(message: String): JobOrderLotsHierarchicalWorkbenchResponse { println("❌ $message") return JobOrderLotsHierarchicalWorkbenchResponse( diff --git a/src/main/java/com/ffii/fpsms/modules/jobOrder/service/JobOrderPlanStartAutoService.kt b/src/main/java/com/ffii/fpsms/modules/jobOrder/service/JobOrderPlanStartAutoService.kt index b1872c1..cca5992 100644 --- a/src/main/java/com/ffii/fpsms/modules/jobOrder/service/JobOrderPlanStartAutoService.kt +++ b/src/main/java/com/ffii/fpsms/modules/jobOrder/service/JobOrderPlanStartAutoService.kt @@ -21,7 +21,8 @@ import java.util.concurrent.atomic.AtomicBoolean * [JobOrder.planStart] fell on the previous calendar day. * * - Branch A: no pick submitted, product process pending → hide job order. - * - Branch B: pick submitted, product process still pending → reschedule to today 00:00:00 and renumber. + * - Branch B (disabled): pick submitted, process pending — previously rescheduled + renumbered; + * now SKIP so unfinished JOs stay visible via Production Process list lookback instead. */ @Service open class JobOrderPlanStartAutoService( @@ -47,6 +48,7 @@ open class JobOrderPlanStartAutoService( val errors: Int = 0, ) + /** FP-MTMS Version Checklist | Functions Ref. No. 42 | v1.0.0 | 2026-08-03 */ open fun runAutoProcess(runAt: LocalDateTime = LocalDateTime.now()): JobOrderPlanStartAutoReport { if (!inFlight.compareAndSet(false, true)) { logger.warn("Job order plan-start auto process skipped: previous run still in flight") @@ -147,6 +149,7 @@ open class JobOrderPlanStartAutoService( SKIP, } + /** FP-MTMS Version Checklist | Functions Ref. No. 42 | v1.0.0 | 2026-08-03 */ private fun classify( jo: JobOrder, pickOrders: List, @@ -158,7 +161,8 @@ open class JobOrderPlanStartAutoService( val maxSubmittedLines = pickOrders.maxOfOrNull { it.submittedLines ?: 0 } ?: 0 return when { maxSubmittedLines == 0 -> Branch.HIDE - maxSubmittedLines > 0 -> Branch.RESCHEDULE + // Renumber / planStart roll-forward disabled — Production Process list carry-over covers visibility. + maxSubmittedLines > 0 -> Branch.SKIP else -> Branch.SKIP } } diff --git a/src/main/java/com/ffii/fpsms/modules/jobOrder/web/model/CreateJobOrderRequest.kt b/src/main/java/com/ffii/fpsms/modules/jobOrder/web/model/CreateJobOrderRequest.kt index c7e382a..d1c39c0 100644 --- a/src/main/java/com/ffii/fpsms/modules/jobOrder/web/model/CreateJobOrderRequest.kt +++ b/src/main/java/com/ffii/fpsms/modules/jobOrder/web/model/CreateJobOrderRequest.kt @@ -49,7 +49,10 @@ data class AllJoPickOrderResponse( val jobOrderType: String?, val itemId: Long, val itemName: String, + val itemCode: String? = null, val bomDescription: String?, + /** BOM.type (e.g. drink / Powder_Mixture / other). */ + val bomType: String? = null, val reqQty: BigDecimal, val uomId: Long, val uomName: String, diff --git a/src/main/java/com/ffii/fpsms/modules/master/service/ItemsService.kt b/src/main/java/com/ffii/fpsms/modules/master/service/ItemsService.kt index d5cd526..0f383d6 100644 --- a/src/main/java/com/ffii/fpsms/modules/master/service/ItemsService.kt +++ b/src/main/java/com/ffii/fpsms/modules/master/service/ItemsService.kt @@ -266,7 +266,7 @@ open class ItemsService( return jdbcDao.queryForList(sql.toString(), args); } - open fun listBagItemsCombo(): List> { +open fun listBagItemsCombo(): List> { return itemsRepository.findAllByIsBagTrueAndDeletedFalse() .mapNotNull { item -> val code = item.code?.trim().orEmpty() @@ -319,6 +319,7 @@ open class ItemsService( } } + /** FP-MTMS Version Checklist | Functions Ref. No. 35 | v1.0.1 | 2026-07-22 */ open fun getPickOrderItemsByPage(args: Map): List> { try { println("=== Debug: getPickOrderItemsByPage in ItemsService ===") @@ -327,10 +328,12 @@ open class ItemsService( val sql = StringBuilder( """ SELECT - CONCAT(po.id, '-', pol.itemId) as id, + CONCAT(po.id, '-', pol.id) as id, po.id as pickOrderId, po.code as pickOrderCode, + pol.id as pickOrderLineId, pol.itemId, + pol.uomId, i.code as itemCode, i.name as itemName, pol.qty as requiredQty, @@ -349,15 +352,16 @@ open class ItemsService( LEFT JOIN ( SELECT il.itemId, + iu.uomId as uomId, SUM(COALESCE(ill.inQty, 0) - COALESCE(ill.outQty, 0) - COALESCE(ill.holdQty, 0)) as availableQty FROM inventory_lot_line ill JOIN inventory_lot il ON il.id = ill.inventoryLotId - LEFT JOIN item_uom iu_ratio ON iu_ratio.itemId = il.itemId AND iu_ratio.deleted = false AND iu_ratio.salesUnit = true + JOIN item_uom iu ON iu.id = ill.stockItemUomId AND iu.deleted = false WHERE ill.status = 'available' AND (COALESCE(ill.inQty, 0) - COALESCE(ill.outQty, 0) - COALESCE(ill.holdQty, 0)) > 0 AND (il.expiryDate IS NULL OR il.expiryDate >= CURDATE()) - GROUP BY il.itemId - ) inv ON inv.itemId = pol.itemId + GROUP BY il.itemId, iu.uomId + ) inv ON inv.itemId = pol.itemId AND inv.uomId = pol.uomId WHERE po.deleted = false AND po.consoCode IS NULL """ @@ -805,56 +809,66 @@ open class ItemsService( ) } + /** + * FP-MTMS Version Checklist | Functions Ref. No. 35 | v1.0.1 | 2026-07-22 + * Pick-order create search: one row per item + available lot UOM + * (available qty summed per UOM). Items with no available stock are omitted. + */ open fun getItemsWithDetailsByPage(args: Map): List> { val sql = StringBuilder( - "select" - + " i.id, " - + " i.code, " - + " i.name, " - + " i.type, " - + " i.description, " - + " uc.id as uomId, " - + " uc.code as uom, " - + " uc.udfudesc as uomDesc, " - + " COALESCE(inv.availableQty, 0) as currentStockBalance " - + " from items i " - + " left join item_uom iu on iu.itemId = i.id and iu.deleted = false and iu.salesUnit = true " - + " left join uom_conversion uc on uc.id = iu.uomId " - + " left join (" - + " SELECT " - + " il.itemId, " - + " SUM(COALESCE(ill.inQty, 0) - COALESCE(ill.outQty, 0) - COALESCE(ill.holdQty, 0)) as availableQty " - + " FROM inventory_lot_line ill " - + " JOIN inventory_lot il ON il.id = ill.inventoryLotId " - + " LEFT JOIN item_uom iu_ratio ON iu_ratio.itemId = il.itemId AND iu_ratio.deleted = false AND iu_ratio.salesUnit = true " - + " WHERE ill.status = 'available' " - + " AND (COALESCE(ill.inQty, 0) - COALESCE(ill.outQty, 0) - COALESCE(ill.holdQty, 0)) > 0 " - + " AND (il.expiryDate IS NULL OR il.expiryDate >= CURDATE()) " - + " GROUP BY il.itemId " - + " ) inv ON inv.itemId = i.id " - + " where i.deleted = false " + """ + SELECT + i.id, + i.code, + i.name, + i.type, + i.description, + uc.id AS uomId, + uc.code AS uom, + uc.udfudesc AS uomDesc, + SUM( + COALESCE(ill.inQty, 0) - COALESCE(ill.outQty, 0) - COALESCE(ill.holdQty, 0) + ) AS currentStockBalance + FROM items i + JOIN inventory_lot il + ON il.itemId = i.id + AND il.deleted = false + JOIN inventory_lot_line ill + ON ill.inventoryLotId = il.id + AND ill.deleted = false + JOIN item_uom iu + ON iu.id = ill.stockItemUomId + AND iu.deleted = false + JOIN uom_conversion uc + ON uc.id = iu.uomId + WHERE i.deleted = false + AND ill.status = 'available' + AND (COALESCE(ill.inQty, 0) - COALESCE(ill.outQty, 0) - COALESCE(ill.holdQty, 0)) > 0 + AND (il.expiryDate IS NULL OR il.expiryDate >= CURDATE()) + """.trimIndent() ) - // Add search conditions if (args.containsKey("name")) { sql.append(" AND i.name LIKE :name ") } - if (args.containsKey("code")) { sql.append(" AND i.code LIKE :code ") } - if (args.containsKey("description")) { sql.append(" AND i.description LIKE :description ") } - if (args.containsKey("type")) { sql.append(" AND i.type = :type ") } - sql.append(" ORDER BY i.name ASC ") + sql.append( + """ + GROUP BY i.id, i.code, i.name, i.type, i.description, uc.id, uc.code, uc.udfudesc + ORDER BY i.name ASC, uc.udfudesc ASC + """.trimIndent() + ) - return jdbcDao.queryForList(sql.toString(), args); + return jdbcDao.queryForList(sql.toString(), args) } open fun getItemOrderOrderedItems(search: String?, type: String? = null): List> { @@ -1024,4 +1038,78 @@ open class ItemsService( }?.takeIf { it.isNotBlank() } } + /** + * FP-MTMS Version Checklist | Functions Ref. No. 35 | v1.0.1 | 2026-07-22 + * Pick-order create: stock UOM (default) + distinct UOMs from currently available lots. + * Lot UOM = inventory_lot_line.stockItemUom → item_uom.uom (uom_conversion id). + */ + open fun getAvailablePickUoms(itemId: Long): Map { + val args = mapOf("itemId" to itemId) + + val stockRows = jdbcDao.queryForList( + """ + SELECT + uc.id AS uomId, + uc.code AS uomCode, + uc.udfudesc AS uomDesc + FROM item_uom iu + JOIN uom_conversion uc ON uc.id = iu.uomId + WHERE iu.itemId = :itemId + AND iu.deleted = false + AND iu.stockUnit = true + LIMIT 1 + """.trimIndent(), + args, + ) + val stockUom = stockRows.firstOrNull()?.let { row -> + mapOf( + "uomId" to row["uomId"], + "uomCode" to row["uomCode"], + "uomDesc" to row["uomDesc"], + ) + } + + val lotRows = jdbcDao.queryForList( + """ + SELECT DISTINCT + uc.id AS uomId, + uc.code AS uomCode, + uc.udfudesc AS uomDesc + FROM inventory_lot_line ill + JOIN inventory_lot il ON il.id = ill.inventoryLotId + JOIN item_uom iu ON iu.id = ill.stockItemUomId AND iu.deleted = false + JOIN uom_conversion uc ON uc.id = iu.uomId + WHERE il.itemId = :itemId + AND ill.deleted = false + AND ill.status = 'available' + AND (COALESCE(ill.inQty, 0) - COALESCE(ill.outQty, 0)) > 0 + AND (il.expiryDate IS NULL OR il.expiryDate >= CURDATE()) + ORDER BY uc.udfudesc ASC + """.trimIndent(), + args, + ) + + val byId = linkedMapOf>() + fun putUom(row: Map?) { + if (row == null) return + val id = (row["uomId"] as? Number)?.toLong() ?: return + if (id <= 0L) return + if (!byId.containsKey(id)) { + byId[id] = mapOf( + "uomId" to id, + "uomCode" to row["uomCode"], + "uomDesc" to row["uomDesc"], + ) + } + } + putUom(stockUom) + lotRows.forEach { putUom(it) } + + return mapOf( + "itemId" to itemId, + "stockUom" to stockUom, + "options" to byId.values.toList(), + ) + } + } \ No newline at end of file diff --git a/src/main/java/com/ffii/fpsms/modules/master/web/ItemsController.kt b/src/main/java/com/ffii/fpsms/modules/master/web/ItemsController.kt index ba8da36..2be7295 100644 --- a/src/main/java/com/ffii/fpsms/modules/master/web/ItemsController.kt +++ b/src/main/java/com/ffii/fpsms/modules/master/web/ItemsController.kt @@ -60,6 +60,15 @@ class ItemsController( fun itemsExistsByCodes(@RequestBody codes: List): List> { return itemsService.itemsExistsByCodes(codes) } + + /** + * FP-MTMS Version Checklist | Functions Ref. No. 35 | v1.0.1 | 2026-07-22 + * Stock UOM + distinct available-lot UOMs for pick-order create dropdown. + */ + @GetMapping("/{itemId}/available-pick-uoms") + fun getAvailablePickUoms(@PathVariable itemId: Long): Map { + return itemsService.getAvailablePickUoms(itemId) + } // @GetMapping("/getRecordByPage") // fun getAllItemsByPage(@RequestBody filterRequest: HttpServletRequest): RecordsRes> { // val pageSize = filterRequest.getParameter("pageSize").toString().toInt(); // Default to 10 if not provided diff --git a/src/main/java/com/ffii/fpsms/modules/pickOrder/entity/PickOrderRepository.kt b/src/main/java/com/ffii/fpsms/modules/pickOrder/entity/PickOrderRepository.kt index 27b5bdb..9700536 100644 --- a/src/main/java/com/ffii/fpsms/modules/pickOrder/entity/PickOrderRepository.kt +++ b/src/main/java/com/ffii/fpsms/modules/pickOrder/entity/PickOrderRepository.kt @@ -100,6 +100,7 @@ fun findAllByJobOrder_Id(jobOrderId: Long): List fun findAllByJobOrder_IdInAndDeletedFalseOrderByJobOrder_IdAscCreatedDesc(jobOrderIds: Collection): List fun findTopByJobOrder_IdOrderByCreatedDesc(jobOrderId: Long): PickOrder? fun findAllByStatusIn(statuses: List): List +fun findByCodeAndDeletedFalse(@Param("code") code: String): PickOrder? @Query( """ @@ -253,4 +254,54 @@ fun findAllReleasedJoWorkbenchPickOrders( nativeQuery = true, ) fun incrementSubmittedLines(@Param("pickOrderId") pickOrderId: Long): Int + + /** + * FP-MTMS Version Checklist | Functions Ref. No. 41 | v1.0.0 | 2026-08-03 + * JO ids that have at least one stock_out_line counted as picked + * (checked / partially_completed / completed / picking, or rejected with qty > 0). + */ + @Query( + value = """ + SELECT DISTINCT po.joId + FROM pick_order po + INNER JOIN pick_order_line pol ON pol.poId = po.id AND pol.deleted = 0 + INNER JOIN stock_out_line sol ON sol.pickOrderLineId = pol.id AND sol.deleted = 0 + WHERE po.deleted = 0 + AND po.joId IN (:joIds) + AND ( + LOWER(COALESCE(sol.status, '')) IN ('checked', 'partially_completed', 'completed', 'picking') + OR (LOWER(COALESCE(sol.status, '')) = 'rejected' AND COALESCE(sol.qty, 0) > 0) + ) + """, + nativeQuery = true, + ) + fun findPickedJobOrderIds(@Param("joIds") joIds: Collection): List + + /** Unlimited: picked + product process still pending / not started. */ + @Query( + value = """ + SELECT DISTINCT po.joId + FROM pick_order po + INNER JOIN job_order jo + ON jo.id = po.joId + AND IFNULL(jo.deleted, 0) = 0 + AND IFNULL(jo.isHidden, 0) = 0 + AND LOWER(IFNULL(jo.status, '')) NOT IN ('completed', 'planning') + INNER JOIN productprocess pp + ON pp.jobOrderId = jo.id + AND IFNULL(pp.deleted, 0) = 0 + AND LOWER(pp.status) = 'pending' + AND pp.startTime IS NULL + INNER JOIN pick_order_line pol ON pol.poId = po.id AND pol.deleted = 0 + INNER JOIN stock_out_line sol ON sol.pickOrderLineId = pol.id AND sol.deleted = 0 + WHERE po.deleted = 0 + AND po.joId IS NOT NULL + AND ( + LOWER(COALESCE(sol.status, '')) IN ('checked', 'partially_completed', 'completed', 'picking') + OR (LOWER(COALESCE(sol.status, '')) = 'rejected' AND COALESCE(sol.qty, 0) > 0) + ) + """, + nativeQuery = true, + ) + fun findPickedNotStartedJobOrderIds(): List } \ No newline at end of file diff --git a/src/main/java/com/ffii/fpsms/modules/pickOrder/service/HierarchicalFgPayloadAssembler.kt b/src/main/java/com/ffii/fpsms/modules/pickOrder/service/HierarchicalFgPayloadAssembler.kt index 491e973..3b48237 100644 --- a/src/main/java/com/ffii/fpsms/modules/pickOrder/service/HierarchicalFgPayloadAssembler.kt +++ b/src/main/java/com/ffii/fpsms/modules/pickOrder/service/HierarchicalFgPayloadAssembler.kt @@ -5,6 +5,7 @@ import java.util.Locale import kotlin.system.measureTimeMillis /** + * FP-MTMS Version Checklist | Functions Ref. No. 45 | v1.0.0 | 2026-08-03 * Shared hierarchical FG payload (fgInfo + merged pickOrders with lines/lots) for both * legacy [do_pick_order_line] and workbench [delivery_order_pick_order] flows. */ @@ -60,7 +61,8 @@ fun assembleHierarchicalFgPayload( AND pol.deleted = false ), ll AS ( - SELECT spl.pickOrderLineId, spl.suggestedLotLineId AS lotLineId + SELECT spl.pickOrderLineId, + COALESCE(spl.actualPickLotLineId, spl.suggestedLotLineId) AS lotLineId FROM fpsmsdb.suggested_pick_lot spl JOIN target_pol tp ON tp.id = spl.pickOrderLineId WHERE spl.deleted = false @@ -73,11 +75,13 @@ ll AS ( WHERE sol.deleted = false ), sm AS ( - SELECT s.pickOrderLineId, s.suggestedLotLineId, MAX(s.id) AS maxId + SELECT s.pickOrderLineId, + COALESCE(s.actualPickLotLineId, s.suggestedLotLineId) AS displayLotLineId, + MAX(s.id) AS maxId FROM fpsmsdb.suggested_pick_lot s JOIN target_pol tp ON tp.id = s.pickOrderLineId WHERE s.deleted = false - GROUP BY s.pickOrderLineId, s.suggestedLotLineId + GROUP BY s.pickOrderLineId, COALESCE(s.actualPickLotLineId, s.suggestedLotLineId) ) SELECT po.id as pickOrderId, @@ -96,6 +100,7 @@ SELECT i.code as itemCode, i.name as itemName, i.item_Order as itemOrder, + pol.uomId as uomId, uc.code as uomCode, uc.udfudesc as uomDesc, uc.udfShortDesc as uomShortDesc, @@ -149,7 +154,7 @@ LEFT JOIN ll ON ll.pickOrderLineId = pol.id LEFT JOIN sm ON sm.pickOrderLineId = pol.id - AND sm.suggestedLotLineId <=> ll.lotLineId + AND sm.displayLotLineId <=> ll.lotLineId LEFT JOIN fpsmsdb.suggested_pick_lot spl ON spl.id = sm.maxId AND spl.deleted = false @@ -261,6 +266,7 @@ ORDER BY "id" to firstLineRow["itemId"], "code" to firstLineRow["itemCode"], "name" to firstLineRow["itemName"], + "uomId" to firstLineRow["uomId"], "uomCode" to firstLineRow["uomCode"], "uomDesc" to firstLineRow["uomDesc"], "uomShortDesc" to firstLineRow["uomShortDesc"], diff --git a/src/main/java/com/ffii/fpsms/modules/pickOrder/service/PickExecutionIssueService.kt b/src/main/java/com/ffii/fpsms/modules/pickOrder/service/PickExecutionIssueService.kt index ed22cfd..455ae97 100644 --- a/src/main/java/com/ffii/fpsms/modules/pickOrder/service/PickExecutionIssueService.kt +++ b/src/main/java/com/ffii/fpsms/modules/pickOrder/service/PickExecutionIssueService.kt @@ -45,6 +45,7 @@ import com.ffii.fpsms.modules.common.CodeGenerator import com.ffii.fpsms.modules.stock.web.model.BatchStockOutResult import com.ffii.fpsms.modules.stock.entity.StockLedger import com.ffii.fpsms.modules.stock.entity.StockLedgerRepository +import com.ffii.fpsms.modules.stock.service.StockLedgerLotSnapshot import com.ffii.fpsms.modules.deliveryOrder.entity.DeliveryOrderRepository import com.ffii.fpsms.modules.deliveryOrder.enums.DeliveryOrderStatus import com.ffii.fpsms.modules.stock.entity.SuggestPickLotRepository @@ -2371,6 +2372,7 @@ private fun updateInventoryAfterLotLineChange(lotLine: InventoryLotLine) { e.printStackTrace() } } +/** FP-MTMS Version Checklist | Functions Ref. No. 46 | v1.0.1 | 2026-08-05 */ private fun createStockLedgerForStockOut( stockOutLine: StockOutLine, ledgerType: String? = null, @@ -2396,6 +2398,9 @@ private fun createStockLedgerForStockOut( val inventory = itemUomService.findInventoryForItemBaseUom(item.id!!) ?: return + val ill = stockOutLine.inventoryLotLine + val outQtyBd = BigDecimal.valueOf(outQty) + val lotAfter = ill?.let { StockLedgerLotSnapshot.availableQty(it) } val stockLedger = StockLedger().apply { this.stockOutLine = stockOutLine this.inventory = inventory @@ -2405,10 +2410,19 @@ private fun createStockLedgerForStockOut( this.type = ledgerTypeToUse this.itemId = item.id this.itemCode = item.code - this.uomId = inventory.uom?.id - ?: itemUomRespository.findByItemIdAndStockUnitIsTrueAndDeletedIsFalse(item.id!!)?.uom?.id + this.uomId = StockLedgerLotSnapshot.resolveLedgerUomId( + ill, + inventory.uom?.id, + itemUomRespository.findByItemIdAndStockUnitIsTrueAndDeletedIsFalse(item.id!!)?.uom?.id, + ) this.date = LocalDate.now() } + StockLedgerLotSnapshot.apply( + ledger = stockLedger, + inventoryLotLine = ill, + lotAvailableAfterMove = lotAfter, + signedDelta = outQtyBd.negate(), + ) stockLedgerRepository.saveAndFlush(stockLedger) @@ -2419,8 +2433,6 @@ private fun createStockLedgerForStockOut( println("===========================") } - - @Transactional(propagation = Propagation.REQUIRES_NEW, rollbackFor = [Exception::class]) private fun handleBadItemPackageProblem(request: PickExecutionIssueRequest, badItemQty: BigDecimal) { println("=== HANDLING BAD ITEM PACKAGE PROBLEM (DON'T REJECT LOT) ===") @@ -2865,6 +2877,7 @@ open fun submitIssueWithQty(request: SubmitIssueWithQtyRequest): MessageResponse } // New: Create stock ledger with explicit balance (to avoid double subtraction) +/** FP-MTMS Version Checklist | Functions Ref. No. 46 | v1.0.1 | 2026-08-05 */ private fun createStockLedgerForStockOutWithBalance( stockOutLine: StockOutLine, balance: Double, @@ -2882,6 +2895,9 @@ private fun createStockLedgerForStockOutWithBalance( val inventory = itemUomService.findInventoryForItemBaseUom(item.id!!) ?: return + val ill = stockOutLine.inventoryLotLine + val outQtyBd = BigDecimal.valueOf(outQty) + val lotAfter = ill?.let { StockLedgerLotSnapshot.availableQty(it) } val stockLedger = StockLedger().apply { this.stockOutLine = stockOutLine this.inventory = inventory @@ -2891,10 +2907,19 @@ private fun createStockLedgerForStockOutWithBalance( this.type = ledgerTypeToUse this.itemId = item.id this.itemCode = item.code - this.uomId = inventory.uom?.id - ?: itemUomRespository.findByItemIdAndStockUnitIsTrueAndDeletedIsFalse(item.id!!)?.uom?.id + this.uomId = StockLedgerLotSnapshot.resolveLedgerUomId( + ill, + inventory.uom?.id, + itemUomRespository.findByItemIdAndStockUnitIsTrueAndDeletedIsFalse(item.id!!)?.uom?.id, + ) this.date = LocalDate.now() } + StockLedgerLotSnapshot.apply( + ledger = stockLedger, + inventoryLotLine = ill, + lotAvailableAfterMove = lotAfter, + signedDelta = outQtyBd.negate(), + ) stockLedgerRepository.saveAndFlush(stockLedger) diff --git a/src/main/java/com/ffii/fpsms/modules/pickOrder/service/PickOrderLifecycleService.kt b/src/main/java/com/ffii/fpsms/modules/pickOrder/service/PickOrderLifecycleService.kt new file mode 100644 index 0000000..d479814 --- /dev/null +++ b/src/main/java/com/ffii/fpsms/modules/pickOrder/service/PickOrderLifecycleService.kt @@ -0,0 +1,284 @@ +package com.ffii.fpsms.modules.pickOrder.service + +import com.ffii.fpsms.modules.deliveryOrder.entity.DoPickOrderLineRepository +import com.ffii.fpsms.modules.deliveryOrder.entity.DoPickOrderRecordRepository +import com.ffii.fpsms.modules.deliveryOrder.entity.DoPickOrderRepository +import com.ffii.fpsms.modules.deliveryOrder.enums.DoPickOrderStatus +import com.ffii.fpsms.modules.jobOrder.entity.JoPickOrderRecordRepository +import com.ffii.fpsms.modules.jobOrder.entity.JoPickOrderRepository +import com.ffii.fpsms.modules.jobOrder.enums.JoPickOrderStatus +import com.ffii.fpsms.modules.pickOrder.entity.PickOrderGroupRepository +import com.ffii.fpsms.modules.pickOrder.entity.PickOrderRepository +import com.ffii.fpsms.modules.pickOrder.enums.PickOrderStatus +import com.ffii.fpsms.modules.pickOrder.enums.PickOrderType +import com.ffii.fpsms.modules.pickOrder.web.models.* +import org.springframework.stereotype.Service +import org.springframework.transaction.annotation.Transactional +import java.time.format.DateTimeFormatter + +@Service +@Transactional(readOnly = true) +open class PickOrderLifecycleService( + private val pickOrderRepository: PickOrderRepository, + private val pickOrderGroupRepository: PickOrderGroupRepository, + private val doPickOrderRepository: DoPickOrderRepository, + private val doPickOrderRecordRepository: DoPickOrderRecordRepository, + private val doPickOrderLineRepository: DoPickOrderLineRepository, + private val joPickOrderRepository: JoPickOrderRepository, + private val joPickOrderRecordRepository: JoPickOrderRecordRepository, +) { + private val dtf = DateTimeFormatter.ofPattern("yyyy-MM-dd HH:mm:ss") + + /** FP-MTMS Version Checklist | Functions Ref. No. 11 | v1.0.0 | 2026-07-17 */ + open fun getLifecycle(pickOrderId: Long): PickOrderLifecycleResponse? { + val po = pickOrderRepository.findById(pickOrderId).orElse(null) ?: return null + + val groups = po.id?.let { pickOrderGroupRepository.findByPickOrderIdAndDeletedIsFalse(it) } ?: emptyList() + val groupName = groups.firstOrNull()?.name + + val stages = buildPickOrderStages(po) + val doLifecycle = if (po.type == PickOrderType.DELIVERY_ORDER) buildDoLifecycle(pickOrderId) else null + val joLifecycle = if (po.type == PickOrderType.JOB_ORDER) buildJoLifecycle(pickOrderId) else null + + return PickOrderLifecycleResponse( + pickOrderId = po.id!!, + pickOrderCode = po.code ?: "", + pickOrderType = po.type?.value ?: "", + currentStatus = po.status?.value ?: "", + targetDate = po.targetDate?.format(dtf), + completeDate = po.completeDate?.format(dtf), + consoCode = po.consoCode, + groupName = groupName, + totalLines = po.totalLines, + submittedLines = po.submittedLines, + stages = stages, + doLifecycle = doLifecycle, + joLifecycle = joLifecycle, + ) + } + + /** FP-MTMS Version Checklist | Functions Ref. No. 11 | v1.0.0 | 2026-07-17 */ + open fun getLifecycleByCode(code: String): PickOrderLifecycleResponse? { + val po = pickOrderRepository.findByCodeAndDeletedFalse(code) ?: return null + return po.id?.let { getLifecycle(it) } + } + + private fun buildPickOrderStages(po: com.ffii.fpsms.modules.pickOrder.entity.PickOrder): List { + val currentStatus = po.status ?: PickOrderStatus.PENDING + val statusOrder = listOf( + PickOrderStatus.PENDING to "待處理", + PickOrderStatus.CONSOLIDATED to "已合併", + PickOrderStatus.ASSIGNED to "已指派", + PickOrderStatus.RELEASED to "已發放", + PickOrderStatus.PICKING to "提料中", + PickOrderStatus.COMPLETED to "已完成", + ) + + val currentIdx = statusOrder.indexOfFirst { it.first == currentStatus } + + return statusOrder.mapIndexed { idx, (status, label) -> + val reached = idx <= currentIdx + val isCurrent = status == currentStatus + LifecycleStage( + status = status.value, + label = label, + reached = reached, + timestamp = when (status) { + PickOrderStatus.RELEASED -> po.releasedDate?.format(dtf) + PickOrderStatus.COMPLETED -> po.completeDate?.format(dtf) + else -> null + }, + actor = when (status) { + PickOrderStatus.RELEASED -> po.releasedBy?.name + PickOrderStatus.ASSIGNED -> po.assignTo?.name + else -> null + }, + actorId = when (status) { + PickOrderStatus.RELEASED -> po.releasedBy?.id + PickOrderStatus.ASSIGNED -> po.assignTo?.id + else -> null + }, + isCurrent = isCurrent, + details = buildStageDetails(po, status), + ) + } + } + + private fun buildStageDetails( + po: com.ffii.fpsms.modules.pickOrder.entity.PickOrder, + status: PickOrderStatus + ): Map { + val map = mutableMapOf() + when (status) { + PickOrderStatus.CONSOLIDATED -> { + po.consoCode?.let { map["consoCode"] = it } + } + PickOrderStatus.RELEASED -> { + po.consoCode?.let { map["consoCode"] = it } + map["plasticBoxCartonQty2f"] = po.plasticBoxCartonQty2f?.toString() + map["plasticBoxCartonQty3f"] = po.plasticBoxCartonQty3f?.toString() + map["plasticBoxCartonQty4f"] = po.plasticBoxCartonQty4f?.toString() + } + PickOrderStatus.COMPLETED -> { + map["totalLines"] = po.totalLines?.toString() + map["submittedLines"] = po.submittedLines?.toString() + } + else -> {} + } + return map + } + + private fun buildDoLifecycle(pickOrderId: Long): DoLifecycleInfo? { + // Check active do_pick_order first + val doPickOrderLines = doPickOrderLineRepository.findByPickOrderIdAndDeletedFalse(pickOrderId) + val doPickOrderId = doPickOrderLines.firstOrNull()?.doPickOrderId + + var dpo = doPickOrderId?.let { doPickOrderRepository.findById(it).orElse(null) } + var deliveryNoteCode: String? = null + + // If not in active table, check record table + if (dpo == null) { + val records = doPickOrderRecordRepository.findByPickOrderId(pickOrderId) + val record = records.maxByOrNull { it.id ?: 0L } + if (record != null) { + deliveryNoteCode = record.deliveryNoteCode + } + } + + if (dpo == null && deliveryNoteCode == null) { + // Try do_pick_order_record by looking up via delivery_order + val po = pickOrderRepository.findById(pickOrderId).orElse(null) + val doId = po?.deliveryOrder?.id + if (doId != null) { + val doRecords = doPickOrderRecordRepository.findByDoOrderIdAndDeletedFalse(doId) + val doRecord = doRecords.firstOrNull() + if (doRecord != null) { + deliveryNoteCode = doRecord.deliveryNoteCode + } + } + } + + if (dpo == null && deliveryNoteCode == null) return null + + val stages = buildDoStages(dpo) + return DoLifecycleInfo( + doPickOrderId = dpo?.id, + ticketNo = dpo?.ticketNo, + ticketStatus = dpo?.ticketStatus?.value ?: DoPickOrderStatus.completed.value, + storeId = dpo?.storeId, + shopName = dpo?.shopName, + truckLanceCode = dpo?.truckLanceCode, + deliveryNoteCode = deliveryNoteCode, + deliveryOrderCode = dpo?.deliveryOrderCode, + loadingSequence = dpo?.loadingSequence, + stages = stages, + ) + } + + private fun buildDoStages(dpo: com.ffii.fpsms.modules.deliveryOrder.entity.DoPickOrder?): List { + val currentStatus = dpo?.ticketStatus ?: DoPickOrderStatus.completed + val statusOrder = listOf( + DoPickOrderStatus.pending to "DO待處理", + DoPickOrderStatus.released to "DO已發放", + DoPickOrderStatus.completed to "DO已完成(DN產生)", + ) + + val currentIdx = statusOrder.indexOfFirst { it.first == currentStatus } + if (currentIdx < 0 && dpo == null) { + // Already completed and archived + return statusOrder.mapIndexed { idx, (status, label) -> + LifecycleStage( + status = status.value, + label = label, + reached = true, + timestamp = null, + actor = null, + actorId = null, + isCurrent = false, + ) + } + } + + return statusOrder.mapIndexed { idx, (status, label) -> + val reached = idx <= maxOf(currentIdx, 0) + val isCurrent = status == currentStatus + LifecycleStage( + status = status.value, + label = label, + reached = reached, + timestamp = when (status) { + DoPickOrderStatus.released -> dpo?.ticketReleaseTime?.format(dtf) + DoPickOrderStatus.completed -> dpo?.ticketCompleteDateTime?.format(dtf) + else -> null + }, + actor = when (status) { + DoPickOrderStatus.released -> dpo?.handlerName + DoPickOrderStatus.completed -> dpo?.handlerName + else -> null + }, + actorId = dpo?.handledBy, + isCurrent = isCurrent, + ) + } + } + + private fun buildJoLifecycle(pickOrderId: Long): JoLifecycleInfo? { + val jpos = joPickOrderRepository.findByPickOrderId(pickOrderId) + val jpo = jpos.firstOrNull() + + // Check record table if not in active + val jpoRecord = if (jpo == null) { + joPickOrderRecordRepository.findByPickOrderId(pickOrderId).firstOrNull() + } else null + + if (jpo == null && jpoRecord == null) return null + + val stages = buildJoStages(jpo) + return JoLifecycleInfo( + joPickOrderId = jpo?.id ?: jpoRecord?.id, + jobOrderCode = jpo?.jobOrderCode ?: jpoRecord?.jobOrderCode, + matchStatus = jpo?.matchStatus?.value ?: jpoRecord?.matchStatus?.value, + matchQty = jpo?.matchQty ?: jpoRecord?.matchQty, + matchBy = jpo?.matchingBy?.toString() ?: jpoRecord?.matchBy?.toString(), + stages = stages, + ) + } + + private fun buildJoStages(jpo: com.ffii.fpsms.modules.jobOrder.entity.JoPickOrder?): List { + val currentStatus = jpo?.matchStatus ?: JoPickOrderStatus.completed + val statusOrder = listOf( + JoPickOrderStatus.pending to "等待二次掃描", + JoPickOrderStatus.scanned to "已掃描", + JoPickOrderStatus.completed to "配對完成", + ) + + val currentIdx = statusOrder.indexOfFirst { it.first == currentStatus } + if (currentIdx < 0 && jpo == null) { + return statusOrder.mapIndexed { idx, (status, label) -> + LifecycleStage( + status = status.value, + label = label, + reached = true, + timestamp = null, + actor = null, + actorId = null, + isCurrent = false, + ) + } + } + + return statusOrder.mapIndexed { idx, (status, label) -> + val reached = idx <= maxOf(currentIdx, 0) + val isCurrent = status == currentStatus + LifecycleStage( + status = status.value, + label = label, + reached = reached, + timestamp = null, + actor = null, + actorId = jpo?.matchingBy, + isCurrent = isCurrent, + ) + } + } +} diff --git a/src/main/java/com/ffii/fpsms/modules/pickOrder/service/PickOrderService.kt b/src/main/java/com/ffii/fpsms/modules/pickOrder/service/PickOrderService.kt index a8eb5b2..1856056 100644 --- a/src/main/java/com/ffii/fpsms/modules/pickOrder/service/PickOrderService.kt +++ b/src/main/java/com/ffii/fpsms/modules/pickOrder/service/PickOrderService.kt @@ -3804,8 +3804,10 @@ open fun getAllPickOrderLotsWithDetailsHierarchical(userId: Long): Map ll.lotLineId -- lots 用(保留原逻辑) LEFT JOIN fpsmsdb.stock_out_line sol @@ -4018,6 +4022,7 @@ println("DEBUG sol polIds in linesResults: " + linesResults.mapNotNull { it["sto ) } */ +/** FP-MTMS Version Checklist | Functions Ref. No. 45 | v1.0.0 | 2026-08-03 */ open fun getAllPickOrderLotsWithDetailsHierarchical(userId: Long): Map { val user = userService.find(userId).orElse(null) if (user == null) { @@ -4162,15 +4167,19 @@ open fun getAllPickOrderLotsWithDetailsHierarchical(userId: Long): Map ll.lotLineId LEFT JOIN fpsmsdb.stock_out_line sol ON sol.pickOrderLineId = pol.id AND ( (sol.inventoryLotLineId = ll.lotLineId) diff --git a/src/main/java/com/ffii/fpsms/modules/pickOrder/service/PickOrderWorkbenchService.kt b/src/main/java/com/ffii/fpsms/modules/pickOrder/service/PickOrderWorkbenchService.kt index e35f250..6cc90c6 100644 --- a/src/main/java/com/ffii/fpsms/modules/pickOrder/service/PickOrderWorkbenchService.kt +++ b/src/main/java/com/ffii/fpsms/modules/pickOrder/service/PickOrderWorkbenchService.kt @@ -145,6 +145,7 @@ open class PickOrderWorkbenchService( } /** + * FP-MTMS Version Checklist | Functions Ref. No. 45 | v1.0.0 | 2026-08-03 * Consumable workbench hierarchical payload for Tab3. * Uses no-hold available qty semantics (inQty - outQty). */ @@ -251,6 +252,7 @@ open class PickOrderWorkbenchService( "id" to pol.item?.id, "code" to pol.item?.code, "name" to pol.item?.name, + "uomId" to pol.uom?.id, "uomCode" to pol.uom?.code, "uomDesc" to pol.uom?.udfudesc, ), @@ -274,6 +276,7 @@ open class PickOrderWorkbenchService( /** * Workbench line detail V2 (consumable): + * FP-MTMS Version Checklist | Functions Ref. No. 45 | v1.0.0 | 2026-08-03 * - Returns only lot rows already linked by suggestion/stock_out_line for this pick order line. * - Uses no-hold availability semantics: available = inQty - outQty. * - Includes no-lot stock_out_line rows. @@ -305,14 +308,14 @@ open class PickOrderWorkbenchService( LEFT JOIN fpsmsdb.warehouse w ON w.id = ill.warehouseId LEFT JOIN ( SELECT - s.suggestedLotLineId AS lotLineId, + COALESCE(s.actualPickLotLineId, s.suggestedLotLineId) AS lotLineId, MAX(s.id) AS suggestedPickLotId, SUM(COALESCE(s.qty,0)) AS requiredQty FROM fpsmsdb.suggested_pick_lot s WHERE s.pickOrderLineId = :pickOrderLineId AND s.deleted = false - AND s.suggestedLotLineId IS NOT NULL - GROUP BY s.suggestedLotLineId + AND COALESCE(s.actualPickLotLineId, s.suggestedLotLineId) IS NOT NULL + GROUP BY COALESCE(s.actualPickLotLineId, s.suggestedLotLineId) ) spl ON spl.lotLineId = ill.id LEFT JOIN ( SELECT @@ -327,11 +330,11 @@ open class PickOrderWorkbenchService( GROUP BY so.inventoryLotLineId ) sol ON sol.lotLineId = ill.id WHERE ill.id IN ( - SELECT DISTINCT s.suggestedLotLineId + SELECT DISTINCT COALESCE(s.actualPickLotLineId, s.suggestedLotLineId) FROM fpsmsdb.suggested_pick_lot s WHERE s.pickOrderLineId = :pickOrderLineId AND s.deleted = false - AND s.suggestedLotLineId IS NOT NULL + AND COALESCE(s.actualPickLotLineId, s.suggestedLotLineId) IS NOT NULL UNION SELECT DISTINCT so.inventoryLotLineId FROM fpsmsdb.stock_out_line so diff --git a/src/main/java/com/ffii/fpsms/modules/pickOrder/web/PickOrderLifecycleController.kt b/src/main/java/com/ffii/fpsms/modules/pickOrder/web/PickOrderLifecycleController.kt new file mode 100644 index 0000000..5e32037 --- /dev/null +++ b/src/main/java/com/ffii/fpsms/modules/pickOrder/web/PickOrderLifecycleController.kt @@ -0,0 +1,28 @@ +package com.ffii.fpsms.modules.pickOrder.web + +import com.ffii.fpsms.modules.pickOrder.service.PickOrderLifecycleService +import com.ffii.fpsms.modules.pickOrder.web.models.PickOrderLifecycleResponse +import org.springframework.http.ResponseEntity +import org.springframework.web.bind.annotation.* + +@RestController +@RequestMapping("/pickOrder") +class PickOrderLifecycleController( + private val lifecycleService: PickOrderLifecycleService, +) { + /** FP-MTMS Version Checklist | Functions Ref. No. 11 | v1.0.0 | 2026-07-17 */ + @GetMapping("/{id}/lifecycle") + fun getLifecycle(@PathVariable id: Long): ResponseEntity { + val result = lifecycleService.getLifecycle(id) + return if (result != null) ResponseEntity.ok(result) + else ResponseEntity.notFound().build() + } + + /** FP-MTMS Version Checklist | Functions Ref. No. 11 | v1.0.0 | 2026-07-17 */ + @GetMapping("/lifecycle/by-code") + fun getLifecycleByCode(@RequestParam code: String): ResponseEntity { + val result = lifecycleService.getLifecycleByCode(code) + return if (result != null) ResponseEntity.ok(result) + else ResponseEntity.notFound().build() + } +} diff --git a/src/main/java/com/ffii/fpsms/modules/pickOrder/web/models/PickOrderLifecycleResponse.kt b/src/main/java/com/ffii/fpsms/modules/pickOrder/web/models/PickOrderLifecycleResponse.kt new file mode 100644 index 0000000..858e284 --- /dev/null +++ b/src/main/java/com/ffii/fpsms/modules/pickOrder/web/models/PickOrderLifecycleResponse.kt @@ -0,0 +1,50 @@ +package com.ffii.fpsms.modules.pickOrder.web.models + +data class LifecycleStage( + val status: String, + val label: String, + val reached: Boolean, + val timestamp: String?, + val actor: String?, + val actorId: Long?, + val isCurrent: Boolean, + val details: Map = emptyMap(), +) + +data class DoLifecycleInfo( + val doPickOrderId: Long?, + val ticketNo: String?, + val ticketStatus: String?, + val storeId: String?, + val shopName: String?, + val truckLanceCode: String?, + val deliveryNoteCode: String?, + val deliveryOrderCode: String?, + val loadingSequence: Int?, + val stages: List, +) + +data class JoLifecycleInfo( + val joPickOrderId: Long?, + val jobOrderCode: String?, + val matchStatus: String?, + val matchQty: Int?, + val matchBy: String?, + val stages: List, +) + +data class PickOrderLifecycleResponse( + val pickOrderId: Long, + val pickOrderCode: String, + val pickOrderType: String, + val currentStatus: String, + val targetDate: String?, + val completeDate: String?, + val consoCode: String?, + val groupName: String?, + val totalLines: Int?, + val submittedLines: Int?, + val stages: List, + val doLifecycle: DoLifecycleInfo?, + val joLifecycle: JoLifecycleInfo?, +) diff --git a/src/main/java/com/ffii/fpsms/modules/productProcess/entity/ProductProcessRepository.kt b/src/main/java/com/ffii/fpsms/modules/productProcess/entity/ProductProcessRepository.kt index 46845ce..ce34319 100644 --- a/src/main/java/com/ffii/fpsms/modules/productProcess/entity/ProductProcessRepository.kt +++ b/src/main/java/com/ffii/fpsms/modules/productProcess/entity/ProductProcessRepository.kt @@ -55,4 +55,72 @@ interface ProductProcessRepository : JpaRepository, JpaSpe @Param("useBomIds") useBomIds: Boolean, @Param("bomIds") bomIds: Collection, ): List + + /** + * FP-MTMS Version Checklist | Functions Ref. No. 41 | v1.0.0 | 2026-08-03 + * Production list lookback: inclusive date range on ProductProcess.date. + */ + @Query( + """ + SELECT + p.jobOrder.id AS jobOrderId, + MAX(p.date) AS maxDate, + MIN(COALESCE(p.productionPriority, 2147483647)) AS minPriority + FROM ProductProcess p + WHERE p.deleted = false + AND p.jobOrder IS NOT NULL + AND COALESCE(p.jobOrder.isHidden, false) = false + AND p.jobOrder.status <> com.ffii.fpsms.modules.jobOrder.enums.JobOrderStatus.PLANNING + AND p.jobOrder.status <> com.ffii.fpsms.modules.jobOrder.enums.JobOrderStatus.COMPLETED + AND (:dateFrom IS NULL OR p.date >= :dateFrom) + AND (:dateTo IS NULL OR p.date <= :dateTo) + AND (:itemCode IS NULL OR LOWER(COALESCE(p.item.code, '')) LIKE LOWER(CONCAT('%', :itemCode, '%'))) + AND (:jobOrderCode IS NULL OR LOWER(COALESCE(p.jobOrder.code, '')) LIKE LOWER(CONCAT('%', :jobOrderCode, '%'))) + AND (:bomType IS NULL OR LOWER(COALESCE(p.bom.type, '')) = LOWER(:bomType)) + AND (:useBomIds = false OR p.bom.id IN :bomIds) + AND (:excludeCompletedProcess = false OR p.status <> com.ffii.fpsms.modules.productProcess.enums.ProductProcessStatus.COMPLETED) + GROUP BY p.jobOrder.id + ORDER BY MAX(p.date) DESC, MIN(COALESCE(p.productionPriority, 2147483647)) ASC + """ + ) + fun findCandidateJobOrderAggregatesByDateRange( + @Param("dateFrom") dateFrom: LocalDate?, + @Param("dateTo") dateTo: LocalDate?, + @Param("itemCode") itemCode: String?, + @Param("jobOrderCode") jobOrderCode: String?, + @Param("bomType") bomType: String?, + @Param("useBomIds") useBomIds: Boolean, + @Param("bomIds") bomIds: Collection, + @Param("excludeCompletedProcess") excludeCompletedProcess: Boolean, + ): List + + @Query( + """ + SELECT + p.jobOrder.id AS jobOrderId, + MAX(p.date) AS maxDate, + MIN(COALESCE(p.productionPriority, 2147483647)) AS minPriority + FROM ProductProcess p + WHERE p.deleted = false + AND p.jobOrder IS NOT NULL + AND p.jobOrder.id IN :jobOrderIds + AND COALESCE(p.jobOrder.isHidden, false) = false + AND p.jobOrder.status <> com.ffii.fpsms.modules.jobOrder.enums.JobOrderStatus.PLANNING + AND p.jobOrder.status <> com.ffii.fpsms.modules.jobOrder.enums.JobOrderStatus.COMPLETED + AND (:itemCode IS NULL OR LOWER(COALESCE(p.item.code, '')) LIKE LOWER(CONCAT('%', :itemCode, '%'))) + AND (:jobOrderCode IS NULL OR LOWER(COALESCE(p.jobOrder.code, '')) LIKE LOWER(CONCAT('%', :jobOrderCode, '%'))) + AND (:bomType IS NULL OR LOWER(COALESCE(p.bom.type, '')) = LOWER(:bomType)) + AND (:useBomIds = false OR p.bom.id IN :bomIds) + GROUP BY p.jobOrder.id + """ + ) + /** FP-MTMS Version Checklist | Functions Ref. No. 41 | v1.0.0 | 2026-08-03 */ + fun findCandidateJobOrderAggregatesByJobOrderIds( + @Param("jobOrderIds") jobOrderIds: Collection, + @Param("itemCode") itemCode: String?, + @Param("jobOrderCode") jobOrderCode: String?, + @Param("bomType") bomType: String?, + @Param("useBomIds") useBomIds: Boolean, + @Param("bomIds") bomIds: Collection, + ): List } \ No newline at end of file diff --git a/src/main/java/com/ffii/fpsms/modules/productProcess/service/ProductProcessService.kt b/src/main/java/com/ffii/fpsms/modules/productProcess/service/ProductProcessService.kt index eafd8da..e8cb18b 100644 --- a/src/main/java/com/ffii/fpsms/modules/productProcess/service/ProductProcessService.kt +++ b/src/main/java/com/ffii/fpsms/modules/productProcess/service/ProductProcessService.kt @@ -1516,7 +1516,8 @@ open class ProductProcessService( assignedTo = pickOrder?.assignTo?.id, itemName = productProcesses.item?.name, itemCode = productProcesses.item?.code, - bomDescription = productProcesses.bom?.bomKind, +bomDescription = productProcesses.bom?.bomKind, + bomType = productProcesses.bom?.type, pickOrderId = pickOrder?.id, pickOrderStatus = pickOrder?.status?.value, jobOrderId = productProcesses.jobOrder?.id, @@ -1554,6 +1555,7 @@ open class ProductProcessService( } /** + * FP-MTMS Version Checklist | Functions Ref. No. 41 | v1.0.0 | 2026-08-03 * QC 列表:按 jobOrder 粒度分页,qcReady 由该 jobOrder 下所有 productProcessLine 是否全部 Completed/Pass 决定。 * * 注意:date/itemCode/jobOrderCode/bomIds 用来筛选“候选 jobOrder”,但 qcReady 判断会用该 jobOrder 下全部 productProcessLine。 @@ -1568,7 +1570,9 @@ open class ProductProcessService( includePutaway: Boolean?, putawayStatus: String?, page: Int, - size: Int + size: Int, + lookbackDays: Int? = null, + bucket: String? = null, ): JobOrderProductProcessPageResponse { val safeSize = if (size <= 0) 50 else size val safePage = if (page < 0) 0 else page @@ -1579,16 +1583,82 @@ open class ProductProcessService( val normalizedBomIds = bomIds?.distinct().orEmpty() val useBomIds = normalizedBomIds.isNotEmpty() val bomIdsForQuery = if (useBomIds) normalizedBomIds else listOf(-1L) + val normalizedBucket = bucket?.trim()?.lowercase()?.takeIf { it.isNotBlank() && it != "all" } + val useLookback = lookbackDays != null && lookbackDays >= 0 && date != null + /** Production list: unfinished process tabs + picked-not-started (capped at search date). */ + val useProductionLookback = useLookback && qcReady != true + /** Waiting QC putaway: date lookback only (completed process OK), no pick buckets. */ + val usePutawayLookback = useLookback && qcReady == true // 1) DB 端先完成候選 jobOrder 篩選 + 分組排序,避免全表拉回 JVM - val candidateAggregates = productProcessRepository.findCandidateJobOrderAggregates( - date = date, - itemCode = trimmedItemCode, - jobOrderCode = trimmedJobOrderCode, - bomType = normalizedType, - useBomIds = useBomIds, - bomIds = bomIdsForQuery, - ) + val candidateAggregates = if (useProductionLookback) { + val dateFrom = date!!.minusDays(lookbackDays!!.toLong()) + val rangeAggs = productProcessRepository.findCandidateJobOrderAggregatesByDateRange( + dateFrom = dateFrom, + dateTo = date, + itemCode = trimmedItemCode, + jobOrderCode = trimmedJobOrderCode, + bomType = normalizedType, + useBomIds = useBomIds, + bomIds = bomIdsForQuery, + excludeCompletedProcess = true, + ) + // Picked-not-started: still merge outside lookback window, but never after search date + // (avoids future plan dates appearing when searching a past day). + val unlimitedPickedNotStartedIds = pickOrderRepository.findPickedNotStartedJobOrderIds() + val extraAggs = if (unlimitedPickedNotStartedIds.isEmpty()) { + emptyList() + } else { + productProcessRepository.findCandidateJobOrderAggregatesByJobOrderIds( + jobOrderIds = unlimitedPickedNotStartedIds, + itemCode = trimmedItemCode, + jobOrderCode = trimmedJobOrderCode, + bomType = normalizedType, + useBomIds = useBomIds, + bomIds = bomIdsForQuery, + ).filter { agg -> + val maxDate = agg.maxDate + maxDate == null || !maxDate.isAfter(date) + } + } + val byId = LinkedHashMap() + (rangeAggs + extraAggs).forEach { agg -> + val existing = byId[agg.jobOrderId] + if (existing == null) { + byId[agg.jobOrderId] = agg + } else { + val maxDate = listOfNotNull(existing.maxDate, agg.maxDate).maxOrNull() + val minPriority = listOfNotNull(existing.minPriority, agg.minPriority).minOrNull() + byId[agg.jobOrderId] = object : JobOrderProcessAggregate { + override val jobOrderId = agg.jobOrderId + override val maxDate = maxDate + override val minPriority = minPriority + } + } + } + byId.values.toList() + } else if (usePutawayLookback) { + val dateFrom = date!!.minusDays(lookbackDays!!.toLong()) + productProcessRepository.findCandidateJobOrderAggregatesByDateRange( + dateFrom = dateFrom, + dateTo = date, + itemCode = trimmedItemCode, + jobOrderCode = trimmedJobOrderCode, + bomType = normalizedType, + useBomIds = useBomIds, + bomIds = bomIdsForQuery, + excludeCompletedProcess = false, + ) + } else { + productProcessRepository.findCandidateJobOrderAggregates( + date = date, + itemCode = trimmedItemCode, + jobOrderCode = trimmedJobOrderCode, + bomType = normalizedType, + useBomIds = useBomIds, + bomIds = bomIdsForQuery, + ) + } val candidateJobOrderIds = candidateAggregates.map { it.jobOrderId } if (candidateJobOrderIds.isEmpty()) { @@ -1596,7 +1666,10 @@ open class ProductProcessService( content = emptyList(), totalJobOrders = 0L, page = safePage, - size = safeSize + size = safeSize, + bucketCounts = if (useProductionLookback) JobOrderProductProcessBucketCounts() else null, + searchDate = date?.toString(), + carriedOverCount = if (useLookback) 0L else null, ) } @@ -1682,19 +1755,104 @@ open class ProductProcessService( } val filteredQcKeys = qcKeys.filter { key -> qcReady == null || key.ready == qcReady } - val totalJobOrders = filteredQcKeys.size.toLong() - val sortedQcKeys = filteredQcKeys.sortedWith( - compareByDescending { it.maxDate }.thenBy { it.minPriority } + val processByJobOrderIdForBucket = if (useProductionLookback) { + productProcessRepository + .findByJobOrder_IdInAndDeletedIsFalse(filteredQcKeys.map { it.jobOrderId }) + .groupBy { it.jobOrder?.id } + .mapNotNull { (joId, list) -> + if (joId == null) null + else joId to (list.firstOrNull { it.status != ProductProcessStatus.COMPLETED } ?: list.first()) + } + .toMap() + } else emptyMap() + + val pickedJoIds = if (useProductionLookback && filteredQcKeys.isNotEmpty()) { + pickOrderRepository.findPickedJobOrderIds(filteredQcKeys.map { it.jobOrderId }).toSet() + } else emptySet() + + fun classifyBucket(jobOrderId: Long): String? { + val pp = processByJobOrderIdForBucket[jobOrderId] ?: return null + val status = pp.status + if (status == ProductProcessStatus.COMPLETED) return null + val started = + status == ProductProcessStatus.IN_PROGRESS || + status == ProductProcessStatus.STOPPED || + pp.startTime != null + val notStarted = status == ProductProcessStatus.PENDING && pp.startTime == null + val picked = pickedJoIds.contains(jobOrderId) + return when { + notStarted && !picked -> "not_picked_not_started" + notStarted && picked -> "picked_not_started" + started && picked -> "picked_started" + started && !picked -> "not_picked_started" + else -> null + } + } + + val keysWithBucket = if (useProductionLookback) { + filteredQcKeys.mapNotNull { key -> + val b = classifyBucket(key.jobOrderId) ?: return@mapNotNull null + key to b + } + } else { + filteredQcKeys.map { it to null as String? } + } + + val bucketCounts = if (useProductionLookback) { + JobOrderProductProcessBucketCounts( + notPickedNotStarted = keysWithBucket.count { it.second == "not_picked_not_started" }.toLong(), + pickedNotStarted = keysWithBucket.count { it.second == "picked_not_started" }.toLong(), + pickedStarted = keysWithBucket.count { it.second == "picked_started" }.toLong(), + notPickedStarted = keysWithBucket.count { it.second == "not_picked_started" }.toLong(), + ) + } else null + + fun matchesMergedBucket(fine: String?, merged: String): Boolean { + return when (merged) { + "pending" -> + fine == "not_picked_not_started" || fine == "picked_not_started" + "processing" -> + fine == "picked_started" || fine == "not_picked_started" + else -> fine == merged + } + } + + val afterBucketFilter = if (normalizedBucket != null && useProductionLookback) { + keysWithBucket.filter { matchesMergedBucket(it.second, normalizedBucket) } + } else { + keysWithBucket + } + + val totalJobOrders = afterBucketFilter.size.toLong() + val carriedOverCount = if (useLookback && date != null) { + afterBucketFilter.count { (key, _) -> + val d = key.maxDate + d != null && d.isBefore(date) + }.toLong() + } else null + + val sortedPairs = afterBucketFilter.sortedWith( + compareByDescending> { it.first.maxDate }.thenBy { it.first.minPriority } ) val from = safePage * safeSize - if (from >= sortedQcKeys.size) { - return JobOrderProductProcessPageResponse(emptyList(), totalJobOrders, safePage, safeSize) + if (from >= sortedPairs.size) { + return JobOrderProductProcessPageResponse( + content = emptyList(), + totalJobOrders = totalJobOrders, + page = safePage, + size = safeSize, + bucketCounts = bucketCounts, + searchDate = date?.toString(), + carriedOverCount = carriedOverCount, + ) } - val to = minOf(from + safeSize, sortedQcKeys.size) - val pagedJobOrderIds = sortedQcKeys.subList(from, to).map { it.jobOrderId } + val to = minOf(from + safeSize, sortedPairs.size) + val pagedPairs = sortedPairs.subList(from, to) + val pagedJobOrderIds = pagedPairs.map { it.first.jobOrderId } + val bucketByJobOrderId = pagedPairs.associate { it.first.jobOrderId to it.second } val pagedJobOrderIdSet = pagedJobOrderIds.toSet() // 4) 只對當前頁資料做批量關聯查詢,並且排序/資料縮小盡量下推 DB @@ -1812,7 +1970,8 @@ open class ProductProcessService( assignedTo = pickOrder?.assignTo?.id, itemName = productProcess.item?.name, itemCode = productProcess.item?.code, - bomDescription = productProcess.bom?.bomKind, +bomDescription = productProcess.bom?.bomKind, + bomType = productProcess.bom?.type, pickOrderId = pickOrder?.id, pickOrderStatus = pickOrder?.status?.value, jobOrderId = productProcess.jobOrder?.id, @@ -1832,7 +1991,9 @@ open class ProductProcessService( endTime = line.endTime, status = line.status ?: "" ) - } + }, + isPicked = if (useProductionLookback) pickedJoIds.contains(jobOrderId) else null, + pickProcessBucket = if (useProductionLookback) bucketByJobOrderId[jobOrderId] else null, ) } @@ -1840,7 +2001,10 @@ open class ProductProcessService( content = content, totalJobOrders = totalJobOrders, page = safePage, - size = safeSize + size = safeSize, + bucketCounts = bucketCounts, + searchDate = date?.toString(), + carriedOverCount = carriedOverCount, ) } @@ -2521,6 +2685,173 @@ open class ProductProcessService( } + // ===== Drink Production Qty Dashboard ===== + /** FP-MTMS Version Checklist | Functions Ref. No. 27 | v1.0.0 | 2026-07-20 */ + open fun getDrinkProductionQty(date: LocalDate?): List { + val targetDate = date ?: LocalDate.now() + val startOfDay = targetDate.atStartOfDay() + val endOfDayExclusive = targetDate.plusDays(1).atStartOfDay() + + // 依「生產日」彙總:與既有 dashboard 的用法一致,採半開區間 [startOfDay, endExclusive) + val jobOrders = jobOrderRepository.findByDeletedFalseAndPlanStartFromBeforeExclusiveOrderByIdAsc( + planStartFrom = startOfDay, + planStartToExclusive = endOfDayExclusive, + ) + + if (jobOrders.isEmpty()) { + return emptyList() + } + + data class GroupKey( + val itemCode: String?, + val itemName: String?, + ) + + data class DrinkOrderRow( + val itemCode: String?, + val itemName: String?, + val uom: String?, + val reqQty: BigDecimal, + val productionQty: BigDecimal, + val jobOrderId: Long?, + val jobOrderCode: String?, + val productionDate: LocalDate?, + ) + + val drinkOrders = jobOrders + .filter { it.bom?.isDrink == true } + .filter { it.status != JobOrderStatus.PLANNING } + + if (drinkOrders.isEmpty()) { + return emptyList() + } + + val jobOrderIds = drinkOrders.mapNotNull { it.id } + val itemIds = drinkOrders.mapNotNull { it.bom?.item?.id }.distinct() + + val processesByJobOrderId = productProcessRepository + .findByJobOrder_IdInAndDeletedIsFalse(jobOrderIds) + .groupBy { it.jobOrder?.id } + .mapValues { (_, processes) -> processes.firstOrNull() } + + val processIds = processesByJobOrderId.values.mapNotNull { it?.id }.distinct() + val linesByProcessId = if (processIds.isNotEmpty()) { + productProcessLineRepository.findByProductProcess_IdIn(processIds) + .groupBy { it.productProcess?.id ?: 0L } + } else { + emptyMap() + } + + val stockInLineByJobOrderId = if (jobOrderIds.isNotEmpty()) { + stockInLineRepository.findAllByJobOrder_IdInAndDeletedFalse(jobOrderIds) + .associateBy { it.jobOrder?.id } + } else { + emptyMap() + } + + val stockItemUoms = if (itemIds.isNotEmpty()) { + itemUomRepository.findAllByItemIdInAndStockUnitIsTrueAndDeletedIsFalse(itemIds) + } else { + emptyList() + } + val itemUomByItemId = mutableMapOf() + stockItemUoms.forEach { iu -> + val itemId = iu.item?.id ?: return@forEach + itemUomByItemId.putIfAbsent(itemId, iu) + } + val uomIds = itemUomByItemId.values.mapNotNull { it.uom?.id }.distinct() + val uomById = if (uomIds.isNotEmpty()) { + uomConversionRepository.findAllByIdInAndDeletedFalse(uomIds).associateBy { it.id } + } else { + emptyMap() + } + val uomDescByItemId = itemUomByItemId.mapValues { (_, iu) -> + uomById[iu.uom?.id]?.udfudesc + } + + val drinkRows = drinkOrders.map { jo -> + val bom = jo.bom + val itemId = bom?.item?.id + val process = processesByJobOrderId[jo.id] + val lines = process?.id?.let { linesByProcessId[it].orEmpty() }.orEmpty() + val stockInLine = stockInLineByJobOrderId[jo.id] + + DrinkOrderRow( + itemCode = bom?.item?.code, + itemName = bom?.item?.name, + uom = resolveDrinkProductionUom(bom, itemId, uomDescByItemId), + reqQty = jo.reqQty ?: BigDecimal.ZERO, + productionQty = resolveDrinkProductionQty(jo, lines, stockInLine), + jobOrderId = jo.id, + jobOrderCode = jo.code, + productionDate = jo.planStart?.toLocalDate(), + ) + } + + return drinkRows + .groupBy { GroupKey(it.itemCode, it.itemName) } + .map { (key, rows) -> + DrinkProductionQtyResponse( + itemCode = key.itemCode, + itemName = key.itemName, + uom = rows.firstNotNullOfOrNull { it.uom?.takeIf { u -> u.isNotBlank() } }, + totalReqQty = rows.fold(BigDecimal.ZERO) { acc, row -> + acc + row.reqQty + }, + totalQty = rows.fold(BigDecimal.ZERO) { acc, row -> + acc + row.productionQty + }, + jobOrders = rows.map { row -> + DrinkProductionQtyJobOrderDetail( + jobOrderId = row.jobOrderId ?: 0L, + jobOrderCode = row.jobOrderCode, + productionDate = row.productionDate, + reqQty = row.reqQty, + productionQty = row.productionQty, + ) + }.sortedBy { it.jobOrderCode ?: "" }, + ) + } + .sortedWith( + compareBy { it.itemCode ?: "" } + .thenBy { it.itemName ?: "" } + ) + } + + private fun resolveDrinkProductionUom( + bom: Bom?, + itemId: Long?, + uomDescByItemId: Map, + ): String? { + bom?.outputQtyUom?.trim()?.takeIf { it.isNotEmpty() }?.let { return it } + bom?.excelUom?.trim()?.takeIf { it.isNotEmpty() }?.let { return it } + itemId?.let { uomDescByItemId[it] }?.trim()?.takeIf { it.isNotEmpty() }?.let { return it } + return null + } + + /** 實際生產數量:優先工序產出 → QC 入庫接受量 → 工單 reqQty(當日計劃生產量) */ + private fun resolveDrinkProductionQty( + jobOrder: com.ffii.fpsms.modules.jobOrder.entity.JobOrder, + lines: List, + stockInLine: com.ffii.fpsms.modules.stock.entity.StockInLine?, + ): BigDecimal { + val lineOutputQty = lines + .sortedByDescending { it.seqNo ?: 0L } + .firstNotNullOfOrNull { line -> + line.outputFromProcessQty?.takeIf { it > 0 }?.toBigDecimal() + } + if (lineOutputQty != null) { + return lineOutputQty + } + + val acceptedQty = stockInLine?.acceptedQty + if (acceptedQty != null && acceptedQty > BigDecimal.ZERO) { + return acceptedQty + } + + return jobOrder.reqQty ?: BigDecimal.ZERO + } + // ===== Operator KPI Dashboard ===== open fun getOperatorKpi(date: LocalDate?): List { val targetDate = date ?: LocalDate.now() diff --git a/src/main/java/com/ffii/fpsms/modules/productProcess/web/ProductProcessController.kt b/src/main/java/com/ffii/fpsms/modules/productProcess/web/ProductProcessController.kt index 071be15..350ce48 100644 --- a/src/main/java/com/ffii/fpsms/modules/productProcess/web/ProductProcessController.kt +++ b/src/main/java/com/ffii/fpsms/modules/productProcess/web/ProductProcessController.kt @@ -200,6 +200,7 @@ class ProductProcessController( return productProcessService.getAllJoborderProductProcessInfo(bomType) } + /** FP-MTMS Version Checklist | Functions Ref. No. 41 | v1.0.0 | 2026-08-03 */ @GetMapping("/Demo/Process/search") fun demoprocesssearch( @RequestParam(required = false) date: String?, @@ -211,6 +212,14 @@ class ProductProcessController( /** all | completed | notCompleted */ @RequestParam(required = false) putawayStatus: String?, @RequestParam(name = "type", required = false) bomType: String?, + /** Production list: unfinished from (date - lookbackDays) .. date; picked_not_started merged but capped at date. */ + @RequestParam(required = false) lookbackDays: Int?, + /** + * all | pending | processing + * (pending = not started; processing = started; also accepts fine-grained pick buckets) + * Only applied when lookbackDays is set. + */ + @RequestParam(required = false) bucket: String?, @RequestParam(defaultValue = "0") page: Int, @RequestParam(defaultValue = "50") size: Int ): JobOrderProductProcessPageResponse { @@ -233,7 +242,9 @@ class ProductProcessController( putawayStatus = putawayStatus, bomType = bomType, page = page, - size = size + size = size, + lookbackDays = lookbackDays, + bucket = bucket, ) } @@ -306,4 +317,13 @@ class ProductProcessController( fun getEquipmentStatus(): List { return productProcessService.getEquipmentStatusByType() } + + /** FP-MTMS Version Checklist | Functions Ref. No. 27 | v1.0.0 | 2026-07-20 */ + @GetMapping("/Demo/DrinkProductionQty") + fun getDrinkProductionQty(@RequestParam(required = false) date: String?): List { + val parsedDate = date?.takeIf { it.isNotBlank() }?.let { + LocalDate.parse(it, DateTimeFormatter.ISO_DATE) + } + return productProcessService.getDrinkProductionQty(parsedDate) + } } \ No newline at end of file diff --git a/src/main/java/com/ffii/fpsms/modules/productProcess/web/model/SaveProductProcessRequest.kt b/src/main/java/com/ffii/fpsms/modules/productProcess/web/model/SaveProductProcessRequest.kt index 7f52f8d..ff80dcd 100644 --- a/src/main/java/com/ffii/fpsms/modules/productProcess/web/model/SaveProductProcessRequest.kt +++ b/src/main/java/com/ffii/fpsms/modules/productProcess/web/model/SaveProductProcessRequest.kt @@ -2,6 +2,7 @@ package com.ffii.fpsms.modules.productProcess.web.model import java.time.LocalDate import java.time.LocalDateTime +import java.math.BigDecimal import com.fasterxml.jackson.annotation.JsonProperty import com.fasterxml.jackson.annotation.JsonFormat data class SaveProductProcessRequest( @@ -169,6 +170,8 @@ data class AllJoborderProductProcessInfoResponse( val itemName: String?, val itemCode: String?, val bomDescription: String?, + /** BOM.type (e.g. drink / Powder_Mixture / other). */ + val bomType: String? = null, val matchStatus: String?, val RequiredQty: Int?, val Uom: String?, @@ -186,14 +189,32 @@ data class AllJoborderProductProcessInfoResponse( val stockInLineStatus: String?, val TimeNeedToComplete: Int?, val isDrink: Boolean?, - val lines: List + val lines: List, + /** True when JO pick has at least one counted stock_out_line progress. */ + val isPicked: Boolean? = null, + /** + * Fine-grained pick/process bucket: + * not_picked_not_started | picked_not_started | picked_started | not_picked_started + */ + val pickProcessBucket: String? = null, +) + +data class JobOrderProductProcessBucketCounts( + val notPickedNotStarted: Long = 0, + val pickedNotStarted: Long = 0, + val pickedStarted: Long = 0, + val notPickedStarted: Long = 0, ) data class JobOrderProductProcessPageResponse( val content: List, val totalJobOrders: Long, val page: Int, - val size: Int + val size: Int, + val bucketCounts: JobOrderProductProcessBucketCounts? = null, + /** Search date echoed for carry-over UI (YYYY-MM-dd). */ + val searchDate: String? = null, + val carriedOverCount: Long? = null, ) /** @@ -335,4 +356,23 @@ data class EquipmentStatusByTypeResponse( val equipmentTypeId: Long, val equipmentTypeName: String?, val details: List, +) + +// ===== Drink Production Qty Dashboard ===== +data class DrinkProductionQtyJobOrderDetail( + val jobOrderId: Long, + val jobOrderCode: String?, + @JsonFormat(shape = JsonFormat.Shape.STRING, pattern = "yyyy-MM-dd") + val productionDate: LocalDate?, + val reqQty: BigDecimal, + val productionQty: BigDecimal, +) + +data class DrinkProductionQtyResponse( + val itemCode: String?, + val itemName: String?, + val uom: String?, + val totalReqQty: BigDecimal, + val totalQty: BigDecimal, + val jobOrders: List = emptyList(), ) \ No newline at end of file diff --git a/src/main/java/com/ffii/fpsms/modules/report/service/DoUserPickAuditReportService.kt b/src/main/java/com/ffii/fpsms/modules/report/service/DoUserPickAuditReportService.kt new file mode 100644 index 0000000..d469358 --- /dev/null +++ b/src/main/java/com/ffii/fpsms/modules/report/service/DoUserPickAuditReportService.kt @@ -0,0 +1,204 @@ +package com.ffii.fpsms.modules.report.service + +import com.ffii.core.support.JdbcDao +import org.springframework.stereotype.Service + +@Service +class DoUserPickAuditReportService( + private val jdbcDao: JdbcDao, +) { + /** + * FP-MTMS Version Checklist | Functions Ref. No. 39 | v1.0.2 | 2026-08-05 + * DO workbench user-audit rows (Just issue only). + * issueTypes may combine: 跳過提料(使用「已完成」)+多提+少提+擅自換批/揀錯批. + * 少提/多提 use POL-level SUM(stock_out_line.qty) vs pol.qty (multi-lot split OK if total matches). + * Only completed tickets (`dop.ticketStatus = completed`) are included. + * + * Perf: filter day/completed SOL first (`day_sol`) by pickTime only (idx_sol_pickTime_deleted), + * then SUM qty only for those POL ids (avoids full-table GROUP BY / OR-date full scan). + */ + fun searchDoUserPickAudit( + dateStart: String?, + dateEnd: String?, + userId: Long?, + handler: String?, + ticketNo: String?, + itemCode: String?, + storeId: String?, + ): List> { + val args = mutableMapOf() + + // Date filter on pickTime only (uses idx_sol_pickTime_deleted). + // ~1% completed SOL with null pickTime are excluded by design (OR fallback caused full scan ~20s+/day). + val dateStartSql = if (!dateStart.isNullOrBlank()) { + args["dateStart"] = dateStart.replace("/", "-") + "AND sol.pickTime >= CONCAT(:dateStart, ' 00:00:00')" + } else "" + + val dateEndSql = if (!dateEnd.isNullOrBlank()) { + args["dateEnd"] = dateEnd.replace("/", "-") + "AND sol.pickTime < DATE_ADD(CONCAT(:dateEnd, ' 00:00:00'), INTERVAL 1 DAY)" + } else "" + + val userSql = if (userId != null && userId > 0) { + args["userId"] = userId + "AND sol.handled_by = :userId" + } else "" + + val handlerSql = if (!handler.isNullOrBlank()) { + args["handler"] = handler.trim() + "AND TRIM(COALESCE(u.name, d.handlerName, '')) = :handler" + } else "" + + val ticketSql = if (!ticketNo.isNullOrBlank()) { + args["ticketNo"] = ticketNo.trim() + "AND dop.ticketNo LIKE CONCAT('%', :ticketNo, '%')" + } else "" + + val itemSql = if (!itemCode.isNullOrBlank()) { + args["itemCode"] = itemCode.trim() + "AND i.code LIKE CONCAT('%', :itemCode, '%')" + } else "" + + val storeSql = if (!storeId.isNullOrBlank()) { + args["storeId"] = storeId.trim().uppercase().replace("/", "").replace(" ", "") + """AND REPLACE(REPLACE(UPPER(IFNULL(dop.storeId,'')), '/', ''), ' ', '') = :storeId""" + } else "" + + val sql = """ + WITH day_sol AS ( + SELECT + sol.id AS solId, + sol.pickOrderLineId AS pickOrderLineId, + sol.itemId AS itemId, + sol.qty AS actualQty, + sol.inventoryLotLineId AS inventoryLotLineId, + sol.handled_by AS handledBy, + sol.isIssueJustComplete AS isIssueJustComplete, + sol.pickTime AS pickTime, + sol.endTime AS endTime, + sol.created AS created, + pol.qty AS polQty, + dop.ticketNo AS ticketNo, + dop.handlerName AS handlerName + FROM stock_out_line sol + INNER JOIN pick_order_line pol + ON pol.id = sol.pickOrderLineId AND IFNULL(pol.deleted, 0) = 0 + INNER JOIN pick_order po + ON po.id = pol.poId AND IFNULL(po.deleted, 0) = 0 + AND po.deliveryOrderPickOrderId IS NOT NULL + INNER JOIN delivery_order_pick_order dop + ON dop.id = po.deliveryOrderPickOrderId AND IFNULL(dop.deleted, 0) = 0 + WHERE IFNULL(sol.deleted, 0) = 0 + AND dop.ticketStatus = 'completed' + $dateStartSql + $dateEndSql + $userSql + $ticketSql + $storeSql + ), + pol_pick AS ( + SELECT s2.pickOrderLineId AS pickOrderLineId, + COALESCE(SUM(s2.qty), 0) AS pickedTotal + FROM stock_out_line s2 + WHERE IFNULL(s2.deleted, 0) = 0 + AND s2.pickOrderLineId IN (SELECT DISTINCT pickOrderLineId FROM day_sol) + GROUP BY s2.pickOrderLineId + ) + SELECT + DATE_FORMAT(COALESCE(d.pickTime, d.endTime, d.created), '%Y-%m-%d %H:%i:%s') AS datetime, + IFNULL(d.ticketNo, '') AS ticketNo, + IFNULL(i.code, '') AS itemCode, + IFNULL(i.name, '') AS itemName, + CASE WHEN IFNULL(i.isEgg, 0) = 1 THEN '是雞蛋類貨品' ELSE '不是雞蛋類貨品' END AS isEgg, + IFNULL(sug_lot.lotNo, '') AS suggestLot, + IFNULL(act_lot.lotNo, '') AS actualLot, + d.polQty AS qty, + d.actualQty AS actualQty, + sl.lotQtyBefore AS lotBeforeQty, + sl.lotQtyAfter AS lotAfterQty, + IFNULL(d.isIssueJustComplete, 0) AS issueJustComplete, + COALESCE(u.name, d.handlerName, '') AS user, + TRIM(BOTH '+' FROM CONCAT_WS('+', + CASE WHEN IFNULL(d.isIssueJustComplete, 0) = 1 THEN '跳過提料(使用「已完成」)' ELSE NULL END, + CASE WHEN d.polQty IS NOT NULL + AND COALESCE(pp.pickedTotal, 0) > d.polQty THEN '多提' ELSE NULL END, + CASE WHEN d.polQty IS NOT NULL + AND COALESCE(pp.pickedTotal, 0) < d.polQty + AND d.inventoryLotLineId IS NOT NULL THEN '少提' ELSE NULL END, + CASE WHEN sug_lot.lotNo IS NOT NULL AND act_lot.lotNo IS NOT NULL + AND sug_lot.lotNo <> act_lot.lotNo THEN '擅自換批/揀錯批' ELSE NULL END + )) AS issueTypes, + d.solId AS stockOutLineId + FROM day_sol d + INNER JOIN items i + ON i.id = d.itemId AND IFNULL(i.deleted, 0) = 0 + LEFT JOIN pol_pick pp + ON pp.pickOrderLineId = d.pickOrderLineId + LEFT JOIN user u + ON u.id = d.handledBy AND IFNULL(u.deleted, 0) = 0 + LEFT JOIN suggested_pick_lot spl + ON spl.id = ( + SELECT MAX(s.id) + FROM suggested_pick_lot s + WHERE s.stockOutLineId = d.solId + AND IFNULL(s.deleted, 0) = 0 + ) + LEFT JOIN inventory_lot_line ill_sug + ON ill_sug.id = spl.suggestedLotLineId + AND IFNULL(ill_sug.deleted, 0) = 0 + LEFT JOIN inventory_lot sug_lot + ON sug_lot.id = ill_sug.inventoryLotId AND IFNULL(sug_lot.deleted, 0) = 0 + LEFT JOIN inventory_lot_line ill_act + ON ill_act.id = COALESCE(d.inventoryLotLineId, spl.actualPickLotLineId) + AND IFNULL(ill_act.deleted, 0) = 0 + LEFT JOIN inventory_lot act_lot + ON act_lot.id = ill_act.inventoryLotId AND IFNULL(act_lot.deleted, 0) = 0 + LEFT JOIN stock_ledger sl + ON sl.id = ( + SELECT MAX(sl2.id) + FROM stock_ledger sl2 + WHERE sl2.stockOutLineId = d.solId + AND IFNULL(sl2.deleted, 0) = 0 + AND sl2.outQty IS NOT NULL + AND sl2.outQty > 0 + ) + WHERE ( + IFNULL(d.isIssueJustComplete, 0) = 1 + OR (d.polQty IS NOT NULL AND COALESCE(pp.pickedTotal, 0) > d.polQty) + OR (d.polQty IS NOT NULL AND COALESCE(pp.pickedTotal, 0) < d.polQty + AND d.inventoryLotLineId IS NOT NULL) + OR ( + sug_lot.lotNo IS NOT NULL AND act_lot.lotNo IS NOT NULL + AND sug_lot.lotNo <> act_lot.lotNo + ) + ) + $handlerSql + $itemSql + ORDER BY COALESCE(d.pickTime, d.endTime, d.created) DESC, d.solId DESC + LIMIT 20000 + """.trimIndent() + + @Suppress("UNCHECKED_CAST") + return jdbcDao.queryForList(sql, args) as List> + } + + /** FP-MTMS Version Checklist | Functions Ref. No. 39 | v1.0.2 | 2026-08-05 */ + fun getDistinctHandlers(): List { + val sql = """ + SELECT DISTINCT TRIM(COALESCE(u.name, dop.handlerName, '')) AS handler + FROM stock_out_line sol + INNER JOIN pick_order_line pol ON pol.id = sol.pickOrderLineId AND IFNULL(pol.deleted, 0) = 0 + INNER JOIN pick_order po ON po.id = pol.poId AND IFNULL(po.deleted, 0) = 0 + AND po.deliveryOrderPickOrderId IS NOT NULL + INNER JOIN delivery_order_pick_order dop ON dop.id = po.deliveryOrderPickOrderId AND IFNULL(dop.deleted, 0) = 0 + LEFT JOIN user u ON u.id = sol.handled_by AND IFNULL(u.deleted, 0) = 0 + WHERE IFNULL(sol.deleted, 0) = 0 + AND TRIM(COALESCE(u.name, dop.handlerName, '')) <> '' + ORDER BY handler + """.trimIndent() + return jdbcDao.queryForList(sql, emptyMap()) + .map { (it["handler"]?.toString() ?: "").trim() } + .filter { it.isNotBlank() } + } +} diff --git a/src/main/java/com/ffii/fpsms/modules/report/service/ShopOrderReplenishmentReportService.kt b/src/main/java/com/ffii/fpsms/modules/report/service/ShopOrderReplenishmentReportService.kt new file mode 100644 index 0000000..7319118 --- /dev/null +++ b/src/main/java/com/ffii/fpsms/modules/report/service/ShopOrderReplenishmentReportService.kt @@ -0,0 +1,281 @@ +package com.ffii.fpsms.modules.report.service + +import com.ffii.core.support.JdbcDao +import org.springframework.stereotype.Service +import java.math.BigDecimal + +/** + * Shop Orders Replenishment Records (店鋪訂單補貨記錄). + * + * Only completed replenishments with a linked ticket are included. + * Qty columns are loaded in a second pass for the filtered row ids only + * (avoids full-table stock_out_line / pick_order GROUP BY). + */ +@Service +class ShopOrderReplenishmentReportService( + private val jdbcDao: JdbcDao, +) { + fun searchShopOrderReplenishmentReport( + reorderDateStart: String?, + reorderDateEnd: String?, + shopOrderDateStart: String?, + shopOrderDateEnd: String?, + deliveredDateStart: String?, + deliveredDateEnd: String?, + shopCode: String?, + ): Map { + val args = mutableMapOf() + + val reorderDateStartSql = dateStartClause( + raw = reorderDateStart, + argName = "reorderDateStart", + columnExpr = "dr.created", + args = args, + ) + val reorderDateEndSql = dateEndClause( + raw = reorderDateEnd, + argName = "reorderDateEnd", + columnExpr = "dr.created", + args = args, + ) + + val shopOrderDateStartSql = dateStartClause( + raw = shopOrderDateStart, + argName = "shopOrderDateStart", + columnExpr = "sourceDo.estimatedArrivalDate", + args = args, + ) + val shopOrderDateEndSql = dateEndClause( + raw = shopOrderDateEnd, + argName = "shopOrderDateEnd", + columnExpr = "sourceDo.estimatedArrivalDate", + args = args, + ) + + val deliveredDateStartSql = if (!deliveredDateStart.isNullOrBlank()) { + args["deliveredDateStart"] = normalizeDate(deliveredDateStart) + "AND dop.requiredDeliveryDate >= :deliveredDateStart" + } else { + "" + } + val deliveredDateEndSql = if (!deliveredDateEnd.isNullOrBlank()) { + args["deliveredDateEnd"] = normalizeDate(deliveredDateEnd) + "AND dop.requiredDeliveryDate <= :deliveredDateEnd" + } else { + "" + } + + val shopCodeSql = if (!shopCode.isNullOrBlank()) { + args["shopCode"] = shopCode.trim() + "AND dr.shopCode LIKE CONCAT('%', :shopCode, '%')" + } else { + "" + } + + // Pass 1: filter do_replenishment only (no full-table SOL aggregation). + val baseSql = """ + SELECT + dr.id AS replenishmentId, + dr.shopCode AS shopNo, + dr.shopName AS shopName, + DATE_FORMAT(sourceDo.estimatedArrivalDate, '%Y-%m-%d') AS shopOrderDate, + sourceDo.code AS shopOrderNo, + dr.itemNo AS itemNo, + dr.itemName AS itemName, + dol.qty AS firstOrderQty, + dr.replenishQty AS reorderQty, + DATE_FORMAT(dr.created, '%Y-%m-%d') AS reorderDate, + dr.reason AS reason, + DATE_FORMAT(dop.requiredDeliveryDate, '%Y-%m-%d') AS deliveredDate, + dr.sourceDoId AS sourceDoId, + dr.itemId AS itemId, + dr.pickOrderLineId AS pickOrderLineId + FROM do_replenishment dr + INNER JOIN delivery_order sourceDo + ON sourceDo.id = dr.sourceDoId + AND IFNULL(sourceDo.deleted, 0) = 0 + LEFT JOIN delivery_order_line dol + ON dol.id = dr.sourceDoLineId + AND IFNULL(dol.deleted, 0) = 0 + INNER JOIN delivery_order_pick_order dop + ON dop.id = dr.deliveryOrderPickOrderId + AND IFNULL(dop.deleted, 0) = 0 + WHERE IFNULL(dr.deleted, 0) = 0 + AND dr.status = 'completed' + AND dr.deliveryOrderPickOrderId IS NOT NULL + AND dr.pickOrderLineId IS NOT NULL + $reorderDateStartSql + $reorderDateEndSql + $shopOrderDateStartSql + $shopOrderDateEndSql + $deliveredDateStartSql + $deliveredDateEndSql + $shopCodeSql + ORDER BY + dr.shopCode ASC, + DATE(sourceDo.estimatedArrivalDate) ASC, + sourceDo.code ASC, + dr.itemNo ASC, + dr.id ASC + """.trimIndent() + + @Suppress("UNCHECKED_CAST") + val baseRows = jdbcDao.queryForList(baseSql, args) as List> + if (baseRows.isEmpty()) { + return mapOf("rows" to emptyList>()) + } + + val pickOrderLineIds = baseRows.mapNotNull { longVal(it["pickOrderLineId"]) }.distinct() + val sourceDoIds = baseRows.mapNotNull { longVal(it["sourceDoId"]) }.distinct() + val itemIds = baseRows.mapNotNull { longVal(it["itemId"]) }.distinct() + + val actualDeliveredByPolId = loadActualDeliveredQtyByPickOrderLineId(pickOrderLineIds) + val firstOrderPickBySourceKey = loadFirstOrderActualPickQty(sourceDoIds, itemIds, pickOrderLineIds) + + val rows = baseRows.map { row -> + val polId = longVal(row["pickOrderLineId"]) + val sourceDoId = longVal(row["sourceDoId"]) + val itemId = longVal(row["itemId"]) + val sourceKey = + if (sourceDoId != null && itemId != null) sourceDoId to itemId else null + + linkedMapOf( + "shopNo" to row["shopNo"], + "shopName" to row["shopName"], + "shopOrderDate" to row["shopOrderDate"], + "shopOrderNo" to row["shopOrderNo"], + "itemNo" to row["itemNo"], + "itemName" to row["itemName"], + "firstOrderQty" to row["firstOrderQty"], + "firstOrderActualPickQty" to (sourceKey?.let { firstOrderPickBySourceKey[it] } ?: BigDecimal.ZERO), + "reorderQty" to row["reorderQty"], + "reorderDate" to row["reorderDate"], + "reason" to row["reason"], + "actualDeliveredQty" to (polId?.let { actualDeliveredByPolId[it] } ?: BigDecimal.ZERO), + "deliveredDate" to row["deliveredDate"], + ) + } + + return mapOf("rows" to rows) + } + + /** Sum stock_out_line.qty only for the given replenishment pickOrderLineIds. */ + private fun loadActualDeliveredQtyByPickOrderLineId( + pickOrderLineIds: List, + ): Map { + if (pickOrderLineIds.isEmpty()) return emptyMap() + + val args = mapOf("polIds" to pickOrderLineIds) + val sql = """ + SELECT + sol.pickOrderLineId AS pickOrderLineId, + SUM(sol.qty) AS actualDeliveredQty + FROM stock_out_line sol + WHERE IFNULL(sol.deleted, 0) = 0 + AND sol.pickOrderLineId IN (:polIds) + GROUP BY sol.pickOrderLineId + """.trimIndent() + + @Suppress("UNCHECKED_CAST") + val rows = jdbcDao.queryForList(sql, args) as List> + return rows.mapNotNull { row -> + val polId = longVal(row["pickOrderLineId"]) ?: return@mapNotNull null + polId to decimalVal(row["actualDeliveredQty"]) + }.toMap() + } + + /** + * Sum stock_out_line.qty for source DO pick order lines of the same item, + * excluding any POL that is itself a replenishment line (incl. current report POLs). + */ + private fun loadFirstOrderActualPickQty( + sourceDoIds: List, + itemIds: List, + excludePickOrderLineIds: List, + ): Map, BigDecimal> { + if (sourceDoIds.isEmpty() || itemIds.isEmpty()) return emptyMap() + + val args = mutableMapOf( + "sourceDoIds" to sourceDoIds, + "itemIds" to itemIds, + ) + val excludeSql = if (excludePickOrderLineIds.isNotEmpty()) { + args["excludePolIds"] = excludePickOrderLineIds + "AND pol.id NOT IN (:excludePolIds)" + } else { + "" + } + + val sql = """ + SELECT + po.doId AS sourceDoId, + pol.itemId AS itemId, + SUM(IFNULL(sol.qty, 0)) AS firstOrderActualPickQty + FROM pick_order po + INNER JOIN pick_order_line pol + ON pol.poId = po.id + AND IFNULL(pol.deleted, 0) = 0 + LEFT JOIN stock_out_line sol + ON sol.pickOrderLineId = pol.id + AND IFNULL(sol.deleted, 0) = 0 + WHERE IFNULL(po.deleted, 0) = 0 + AND po.doId IN (:sourceDoIds) + AND pol.itemId IN (:itemIds) + $excludeSql + AND NOT EXISTS ( + SELECT 1 + FROM do_replenishment r2 + WHERE r2.pickOrderLineId = pol.id + AND IFNULL(r2.deleted, 0) = 0 + ) + GROUP BY po.doId, pol.itemId + """.trimIndent() + + @Suppress("UNCHECKED_CAST") + val rows = jdbcDao.queryForList(sql, args) as List> + return rows.mapNotNull { row -> + val sourceDoId = longVal(row["sourceDoId"]) ?: return@mapNotNull null + val itemId = longVal(row["itemId"]) ?: return@mapNotNull null + (sourceDoId to itemId) to decimalVal(row["firstOrderActualPickQty"]) + }.toMap() + } + + private fun normalizeDate(raw: String): String = raw.trim().replace("/", "-") + + private fun dateStartClause( + raw: String?, + argName: String, + columnExpr: String, + args: MutableMap, + ): String { + if (raw.isNullOrBlank()) return "" + args[argName] = normalizeDate(raw) + return "AND $columnExpr >= CONCAT(:$argName, ' 00:00:00')" + } + + private fun dateEndClause( + raw: String?, + argName: String, + columnExpr: String, + args: MutableMap, + ): String { + if (raw.isNullOrBlank()) return "" + args[argName] = normalizeDate(raw) + return "AND $columnExpr < DATE_ADD(CONCAT(:$argName, ' 00:00:00'), INTERVAL 1 DAY)" + } + + private fun longVal(value: Any?): Long? = + when (value) { + null -> null + is Number -> value.toLong() + else -> value.toString().toLongOrNull() + } + + private fun decimalVal(value: Any?): BigDecimal = + when (value) { + null -> BigDecimal.ZERO + is BigDecimal -> value + is Number -> BigDecimal(value.toString()) + else -> value.toString().toBigDecimalOrNull() ?: BigDecimal.ZERO + } +} diff --git a/src/main/java/com/ffii/fpsms/modules/report/web/DoUserPickAuditReportController.kt b/src/main/java/com/ffii/fpsms/modules/report/web/DoUserPickAuditReportController.kt new file mode 100644 index 0000000..01ca7f0 --- /dev/null +++ b/src/main/java/com/ffii/fpsms/modules/report/web/DoUserPickAuditReportController.kt @@ -0,0 +1,140 @@ +package com.ffii.fpsms.modules.report.web + +import com.ffii.fpsms.modules.report.service.DoUserPickAuditReportService +import org.apache.poi.ss.usermodel.BorderStyle +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.VerticalAlignment +import org.apache.poi.ss.util.WorkbookUtil +import org.apache.poi.xssf.usermodel.XSSFWorkbook +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 + +@RestController +@RequestMapping("/report") +class DoUserPickAuditReportController( + private val doUserPickAuditReportService: DoUserPickAuditReportService, +) { + /** FP-MTMS Version Checklist | Functions Ref. No. 39 | v1.0.2 | 2026-08-05 */ + @GetMapping("/do-user-pick-audit-handlers") + fun handlers(): List = doUserPickAuditReportService.getDistinctHandlers() + + /** FP-MTMS Version Checklist | Functions Ref. No. 39 | v1.0.2 | 2026-08-05 */ + @GetMapping("/print-do-user-pick-audit-excel") + fun exportExcel( + @RequestParam(required = false) dateStart: String?, + @RequestParam(required = false) dateEnd: String?, + @RequestParam(required = false) userId: Long?, + @RequestParam(required = false) handler: String?, + @RequestParam(required = false) ticketNo: String?, + @RequestParam(required = false) itemCode: String?, + @RequestParam(required = false) storeId: String?, + ): ResponseEntity { + val rows = doUserPickAuditReportService.searchDoUserPickAudit( + dateStart = dateStart, + dateEnd = dateEnd, + userId = userId, + handler = handler, + ticketNo = ticketNo, + itemCode = itemCode, + storeId = storeId, + ) + val bytes = buildExcel(rows) + val headers = HttpHeaders().apply { + contentType = MediaType.parseMediaType( + "application/vnd.openxmlformats-officedocument.spreadsheetml.sheet", + ) + setContentDispositionFormData("attachment", "成品出倉揀貨合規報告.xlsx") + set("filename", "成品出倉揀貨合規報告.xlsx") + } + return ResponseEntity(bytes, headers, HttpStatus.OK) + } + + private fun buildExcel(rows: List>): ByteArray { + val workbook = XSSFWorkbook() + val sheet = workbook.createSheet(WorkbookUtil.createSafeSheetName("DO揀貨人員稽核")) + 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 { + borderTop = BorderStyle.THIN + borderBottom = BorderStyle.THIN + borderLeft = BorderStyle.THIN + borderRight = BorderStyle.THIN + } + + // key = SQL alias; label = Chinese Excel header + val columns = listOf( + "datetime" to "完成時間", + "ticketNo" to "提票號碼", + "itemCode" to "貨品編號", + "itemName" to "貨品名稱", + "isEgg" to "是否雞蛋類", + "suggestLot" to "建議批號", + "actualLot" to "實際批號", + "qty" to "所需數量", + "actualQty" to "實際提料數量", + "lotBeforeQty" to "提料前庫存", + "lotAfterQty" to "提料後庫存", + "issueJustComplete" to "跳過提料(使用「已完成」)", + "user" to "提料人", + "issueTypes" to "偏離類型", + ) + + var r = 0 + val headerRow = sheet.createRow(r++) + columns.forEachIndexed { i, (_, label) -> + headerRow.createCell(i).apply { + setCellValue(label) + cellStyle = headerStyle + } + } + + for (row in rows) { + val excelRow = sheet.createRow(r++) + columns.forEachIndexed { i, (key, _) -> + val v = row[key] + val cell = excelRow.createCell(i) + cell.cellStyle = textStyle + when (v) { + null -> cell.setCellValue("") + is Number -> { + if (key == "issueJustComplete") { + cell.setCellValue(if (v.toDouble() != 0.0) "是" else "否") + } else { + cell.setCellValue(v.toDouble()) + } + } + is Boolean -> cell.setCellValue(if (v) "是" else "否") + else -> cell.setCellValue(v.toString()) + } + } + } + + // Fixed widths (avoid slow autoSize on large sheets) + val widths = intArrayOf(18, 14, 14, 28, 16, 16, 16, 12, 12, 12, 12, 14, 12, 20) + widths.forEachIndexed { i, w -> sheet.setColumnWidth(i, w * 256) } + + val out = ByteArrayOutputStream() + workbook.write(out) + workbook.close() + return out.toByteArray() + } +} diff --git a/src/main/java/com/ffii/fpsms/modules/report/web/ReportController.kt b/src/main/java/com/ffii/fpsms/modules/report/web/ReportController.kt index 9fbd9bc..fc24362 100644 --- a/src/main/java/com/ffii/fpsms/modules/report/web/ReportController.kt +++ b/src/main/java/com/ffii/fpsms/modules/report/web/ReportController.kt @@ -24,12 +24,14 @@ import java.time.format.DateTimeFormatter import com.ffii.fpsms.modules.common.SecurityUtils import com.ffii.fpsms.modules.report.service.M18BomShopSyncReportService import com.ffii.fpsms.modules.report.service.ReportService +import com.ffii.fpsms.modules.report.service.ShopOrderReplenishmentReportService @RestController @RequestMapping("/report") class ReportController( private val reportService: ReportService, private val m18BomShopSyncReportService: M18BomShopSyncReportService, + private val shopOrderReplenishmentReportService: ShopOrderReplenishmentReportService, ) { private data class ExcelStyles( val title: XSSFCellStyle, @@ -1001,6 +1003,32 @@ class ReportController( syncStatus = syncStatus, ) + /** + * Shop Orders Replenishment Records (店鋪訂單補貨記錄). + * JSON for Excel export: completed replenishments with linked ticket only. + * + * Example: `/report/shop-order-replenishment?reorderDateStart=2026-08-01&shopCode=S001` + */ + @GetMapping("/shop-order-replenishment") + fun getShopOrderReplenishmentReport( + @RequestParam(required = false) reorderDateStart: String?, + @RequestParam(required = false) reorderDateEnd: String?, + @RequestParam(required = false) shopOrderDateStart: String?, + @RequestParam(required = false) shopOrderDateEnd: String?, + @RequestParam(required = false) deliveredDateStart: String?, + @RequestParam(required = false) deliveredDateEnd: String?, + @RequestParam(required = false) shopCode: String?, + ): Map = + shopOrderReplenishmentReportService.searchShopOrderReplenishmentReport( + reorderDateStart = reorderDateStart, + reorderDateEnd = reorderDateEnd, + shopOrderDateStart = shopOrderDateStart, + shopOrderDateEnd = shopOrderDateEnd, + deliveredDateStart = deliveredDateStart, + deliveredDateEnd = deliveredDateEnd, + shopCode = shopCode, + ) + companion object { /** GRN report fields only users with ADMIN authority may see */ private val GRN_FINANCIAL_KEYS = setOf("unitPrice", "lineAmount", "currencyCode") diff --git a/src/main/java/com/ffii/fpsms/modules/stock/entity/InventoryLotLineRepository.kt b/src/main/java/com/ffii/fpsms/modules/stock/entity/InventoryLotLineRepository.kt index a7bb2a4..b2b190f 100644 --- a/src/main/java/com/ffii/fpsms/modules/stock/entity/InventoryLotLineRepository.kt +++ b/src/main/java/com/ffii/fpsms/modules/stock/entity/InventoryLotLineRepository.kt @@ -196,19 +196,23 @@ WHERE ill.id = :id fun findAllByWarehouseCodeAndDeletedIsFalse(@Param("warehouseCode") warehouseCode: String): List + /** FP-MTMS Version Checklist | Functions Ref. No. 53 | v1.0.0 | 2026-08-05 — 卡片「貨品種類數量」:僅 available,與明細 fallback / 預建來源一致 */ @Query(""" SELECT COUNT(DISTINCT ill.inventoryLot.item.id) FROM InventoryLotLine ill WHERE ill.warehouse.id IN :warehouseIds AND ill.deleted = false + AND ill.status = 'available' """) fun countDistinctItemsByWarehouseIds(@Param("warehouseIds") warehouseIds: List): Long + /** FP-MTMS Version Checklist | Functions Ref. No. 53 | v1.0.0 | 2026-08-05 — 卡片 lot 筆數:僅 available */ @Query(""" SELECT COUNT(ill.id) FROM InventoryLotLine ill WHERE ill.warehouse.id IN :warehouseIds AND ill.deleted = false + AND ill.status = 'available' """) fun countAllByWarehouseIds(@Param("warehouseIds") warehouseIds: List): Long diff --git a/src/main/java/com/ffii/fpsms/modules/stock/entity/InventoryLotRepository.kt b/src/main/java/com/ffii/fpsms/modules/stock/entity/InventoryLotRepository.kt index f4dbaf6..5635bc3 100644 --- a/src/main/java/com/ffii/fpsms/modules/stock/entity/InventoryLotRepository.kt +++ b/src/main/java/com/ffii/fpsms/modules/stock/entity/InventoryLotRepository.kt @@ -21,7 +21,10 @@ interface InventoryLotRepository: AbstractRepository { WHERE il.lotNo = :lotNo AND il.item.id = :itemId AND il.deleted = false + ORDER BY il.id ASC """) - fun findByLotNoAndItemId(lotNo: String, itemId: Long): InventoryLot? + fun findAllByLotNoAndItemIdAndDeletedFalseOrderByIdAsc(lotNo: String, itemId: Long): List + + fun findFirstByStockInLineIdAndDeletedFalseOrderByIdAsc(stockInLineId: Serializable): InventoryLot? fun findByIdAndDeletedFalse(id: Serializable): InventoryLot? } \ No newline at end of file diff --git a/src/main/java/com/ffii/fpsms/modules/stock/entity/InventoryRepository.kt b/src/main/java/com/ffii/fpsms/modules/stock/entity/InventoryRepository.kt index a439b31..89d7754 100644 --- a/src/main/java/com/ffii/fpsms/modules/stock/entity/InventoryRepository.kt +++ b/src/main/java/com/ffii/fpsms/modules/stock/entity/InventoryRepository.kt @@ -46,6 +46,58 @@ interface InventoryRepository: AbstractRepository { pageable: Pageable ): Page + /** + * FP-MTMS Version Checklist | Functions Ref. No. 19 | v1.0.0 | 2026-07-17 + * Inventory search page only: no baseUnit/uomId match required; + * one row per item = latest inventory id (no larger id for same item). + */ + @Query( + """ + SELECT i FROM Inventory i + WHERE (:code IS NULL OR :code = '' OR LOWER(i.item.code) LIKE LOWER(CONCAT('%', :code, '%'))) + AND (:name IS NULL OR :name = '' OR LOWER(i.item.name) LIKE LOWER(CONCAT('%', :name, '%'))) + AND (:type IS NULL OR :type = '' OR i.item.type = :type) + AND i.deleted = false + AND NOT EXISTS ( + SELECT 1 FROM Inventory i2 + WHERE i2.item.id = i.item.id + AND i2.deleted = false + AND i2.id > i.id + ) + """ + ) + fun findLatestInventoryInfoByItemCodeContainsAndItemNameContainsAndItemTypeAndDeletedIsFalse( + code: String, + name: String, + type: String, + pageable: Pageable, + ): Page + + /** FP-MTMS Version Checklist | Functions Ref. No. 19 | v1.0.0 | 2026-07-17 */ + @Query( + """ + SELECT i FROM Inventory i + WHERE (:code IS NULL OR :code = '' OR LOWER(i.item.code) LIKE LOWER(CONCAT('%', :code, '%'))) + AND (:name IS NULL OR :name = '' OR LOWER(i.item.name) LIKE LOWER(CONCAT('%', :name, '%'))) + AND (:type IS NULL OR :type = '' OR i.item.type = :type) + AND i.item.id IN :itemIds + AND i.deleted = false + AND NOT EXISTS ( + SELECT 1 FROM Inventory i2 + WHERE i2.item.id = i.item.id + AND i2.deleted = false + AND i2.id > i.id + ) + """ + ) + fun findLatestInventoryInfoByItemCodeContainsAndItemNameContainsAndItemTypeAndItemIdInAndDeletedIsFalse( + code: String, + name: String, + type: String, + itemIds: List, + pageable: Pageable, + ): Page + fun findInventoryInfoByItemIdInAndDeletedIsFalse(itemIds: List): List fun findInventoryInfoByItemInAndDeletedIsFalse(items: List): List diff --git a/src/main/java/com/ffii/fpsms/modules/stock/entity/ItemLotTraceLog.kt b/src/main/java/com/ffii/fpsms/modules/stock/entity/ItemLotTraceLog.kt new file mode 100644 index 0000000..ff82cf7 --- /dev/null +++ b/src/main/java/com/ffii/fpsms/modules/stock/entity/ItemLotTraceLog.kt @@ -0,0 +1,36 @@ +package com.ffii.fpsms.modules.stock.entity + +import com.ffii.core.entity.BaseEntity +import jakarta.persistence.Column +import jakarta.persistence.Entity +import jakarta.persistence.Table +import jakarta.validation.constraints.NotNull +import jakarta.validation.constraints.Size + +@Entity +@Table(name = "item_lot_trace_log") +open class ItemLotTraceLog : BaseEntity() { + + @NotNull + @Column(name = "userId", nullable = false) + open var userId: Long? = null + + @Size(max = 64) + @NotNull + @Column(name = "username", nullable = false, length = 64) + open var username: String? = null + + @Column(name = "inventoryLotId") + open var inventoryLotId: Long? = null + + @Size(max = 64) + @Column(name = "lotNo", length = 64) + open var lotNo: String? = null + + @Column(name = "itemId") + open var itemId: Long? = null + + @Size(max = 64) + @Column(name = "itemCode", length = 64) + open var itemCode: String? = null +} diff --git a/src/main/java/com/ffii/fpsms/modules/stock/entity/ItemLotTraceLogRepository.kt b/src/main/java/com/ffii/fpsms/modules/stock/entity/ItemLotTraceLogRepository.kt new file mode 100644 index 0000000..3dddb2d --- /dev/null +++ b/src/main/java/com/ffii/fpsms/modules/stock/entity/ItemLotTraceLogRepository.kt @@ -0,0 +1,7 @@ +package com.ffii.fpsms.modules.stock.entity + +import com.ffii.core.support.AbstractRepository +import org.springframework.stereotype.Repository + +@Repository +interface ItemLotTraceLogRepository : AbstractRepository diff --git a/src/main/java/com/ffii/fpsms/modules/stock/entity/StockLedger.kt b/src/main/java/com/ffii/fpsms/modules/stock/entity/StockLedger.kt index 5f868a8..16099af 100644 --- a/src/main/java/com/ffii/fpsms/modules/stock/entity/StockLedger.kt +++ b/src/main/java/com/ffii/fpsms/modules/stock/entity/StockLedger.kt @@ -4,9 +4,11 @@ import com.ffii.core.entity.BaseEntity import jakarta.persistence.Column import jakarta.persistence.Entity import jakarta.persistence.JoinColumn +import jakarta.persistence.ManyToOne import jakarta.persistence.OneToOne import jakarta.persistence.Table import jakarta.validation.constraints.NotNull +import java.math.BigDecimal import java.time.LocalDate @Entity @Table(name = "stock_ledger") @@ -19,6 +21,24 @@ open class StockLedger: BaseEntity() { @JoinColumn(name = "stockOutLineId") open var stockOutLine: StockOutLine? = null + /** + * FP-MTMS Version Checklist | Functions Ref. No. 46 | v1.0.1 | 2026-08-05 + * + * stock_ledger.inventoryLotLineId = the **actual** inventory_lot_line.id this + * in/out movement posted against (the lot whose qty changed). + * + * Not a suggested lot. For pick-order flows (FG / JO / PO), this must align with + * suggested_pick_lot.actualPickLotLineId (and stock_out_line.inventoryLotLineId), + * NOT suggested_pick_lot.suggestedLotLineId. + * If the picker switches lot (suggested A, scan B), ledger points to B. + * + * Prefer reading this column directly; do not infer lot only via stockInLine / + * stockOutLine when this is populated. + */ + @ManyToOne + @JoinColumn(name = "inventoryLotLineId") + open var inventoryLotLine: InventoryLotLine? = null + @NotNull @OneToOne @JoinColumn(name = "inventoryId") @@ -33,7 +53,21 @@ open class StockLedger: BaseEntity() { @Column(name = "balance") open var balance: Double? = null - /** 物料庫存單位對應之 uom_conversion.id(與 item_uom / inventory 之 stock UOM 一致) */ + /** + * FP-MTMS Version Checklist | Functions Ref. No. 46 | v1.0.1 | 2026-08-05 + * Available qty of that same actual lot (in−out) **before** this movement; null for pre-V2 rows. + */ + @Column(name = "lotQtyBefore", precision = 14, scale = 2) + open var lotQtyBefore: BigDecimal? = null + + /** + * FP-MTMS Version Checklist | Functions Ref. No. 46 | v1.0.1 | 2026-08-05 + * Available qty of that same actual lot (in−out) **after** this movement; null for pre-V2 rows. + */ + @Column(name = "lotQtyAfter", precision = 14, scale = 2) + open var lotQtyAfter: BigDecimal? = null + + /** Lot line stock UOM → uom.id(優先);無 lot 時才 fallback item/inventory stock UOM */ @Column(name = "uomId") open var uomId: Long? = null diff --git a/src/main/java/com/ffii/fpsms/modules/stock/entity/StockOutLine.kt b/src/main/java/com/ffii/fpsms/modules/stock/entity/StockOutLine.kt index 8eda10d..25ec293 100644 --- a/src/main/java/com/ffii/fpsms/modules/stock/entity/StockOutLine.kt +++ b/src/main/java/com/ffii/fpsms/modules/stock/entity/StockOutLine.kt @@ -41,6 +41,13 @@ open class StockOutLine: BaseEntity() { @Column(name = "handled_by") open var handledBy: Long? = null + /** + * DO user-audit: Just Complete that should be reported because pickable stock still existed + * for the item at complete time. Null/false = not an issue JC (e.g. lot exhausted remainder). + */ + @Column(name = "isIssueJustComplete") + open var isIssueJustComplete: Boolean? = null + @JsonBackReference @ManyToOne @JoinColumn(name = "pickOrderLineId") diff --git a/src/main/java/com/ffii/fpsms/modules/stock/entity/SuggestedPickLot.kt b/src/main/java/com/ffii/fpsms/modules/stock/entity/SuggestedPickLot.kt index dbaac21..989d58a 100644 --- a/src/main/java/com/ffii/fpsms/modules/stock/entity/SuggestedPickLot.kt +++ b/src/main/java/com/ffii/fpsms/modules/stock/entity/SuggestedPickLot.kt @@ -29,6 +29,18 @@ open class SuggestedPickLot: BaseEntity() { @JoinColumn(name = "suggestedLotLineId") open var suggestedLotLine: InventoryLotLine? = null + /** + * FP-MTMS Version Checklist | Functions Ref. No. 33 | v1.0.2 | 2026-08-03 + * + * Actual picked inventory_lot_line.id. + * On create: init = suggestedLotLineId; lot switch/scan updates this only (suggested stays). + * Downstream (display, COALESCE reads, stock_ledger.inventoryLotLineId / SOL.inventoryLotLineId) + * must use this, not [suggestedLotLine]. + */ + @ManyToOne + @JoinColumn(name = "actualPickLotLineId") + open var actualPickLotLine: InventoryLotLine? = null + @JsonBackReference @ManyToOne @JoinColumn(name = "pickOrderLineId") diff --git a/src/main/java/com/ffii/fpsms/modules/stock/entity/projection/InventoryLotLineInfo.kt b/src/main/java/com/ffii/fpsms/modules/stock/entity/projection/InventoryLotLineInfo.kt index 109251c..9039574 100644 --- a/src/main/java/com/ffii/fpsms/modules/stock/entity/projection/InventoryLotLineInfo.kt +++ b/src/main/java/com/ffii/fpsms/modules/stock/entity/projection/InventoryLotLineInfo.kt @@ -50,6 +50,9 @@ interface InventoryLotLineInfo { @get:Value("#{target.stockUom?.uom?.udfudesc}") val uom: String? + @get:Value("#{target.stockUom?.uom?.id}") + val uomId: Long? + @get:Value("#{target.inventoryLot.expiryDate}") val expiryDate: LocalDate } diff --git a/src/main/java/com/ffii/fpsms/modules/stock/service/InventoryLotLineService.kt b/src/main/java/com/ffii/fpsms/modules/stock/service/InventoryLotLineService.kt index c1be17a..bf046b4 100644 --- a/src/main/java/com/ffii/fpsms/modules/stock/service/InventoryLotLineService.kt +++ b/src/main/java/com/ffii/fpsms/modules/stock/service/InventoryLotLineService.kt @@ -456,6 +456,7 @@ open fun updateInventoryLotLineQuantities(request: UpdateInventoryLotLineQuantit } /** + * FP-MTMS Version Checklist | Functions Ref. No. 34 | v1.0.1 | 2026-07-22 * DO workbench label modal: pickable qty = in - out (aligned with workbench scan-pick). */ open fun analyzeQrCodeWorkbench(request: QrCodeAnalysisRequest): QrCodeAnalysisResponse { @@ -463,16 +464,22 @@ open fun updateInventoryLotLineQuantities(request: UpdateInventoryLotLineQuantit } /** + * FP-MTMS Version Checklist | Functions Ref. No. 34 | v1.0.1 | 2026-07-22 * DO workbench label modal fallback (no stockInLineId/no-lot row): * list available lots by item only, using pickable qty = in - out. + * When [uomId] is set, only lots whose stockUom.uom.id matches are returned. */ - open fun listWorkbenchAvailableLotsByItem(itemId: Long): WorkbenchItemLotsResponse { + open fun listWorkbenchAvailableLotsByItem(itemId: Long, uomId: Long? = null): WorkbenchItemLotsResponse { val source = inventoryLotLineRepository .findAllByInventoryLotItemIdAndStatus(itemId, InventoryLotLineStatus.AVAILABLE) .asSequence() .filter { !it.deleted && it.inventoryLot?.item != null } + .filter { uomId == null || it.stockUom?.uom?.id == uomId } .toList() val item = source.firstOrNull()?.inventoryLot?.item + ?: inventoryLotLineRepository + .findAllByInventoryLotItemIdAndStatus(itemId, InventoryLotLineStatus.AVAILABLE) + .firstOrNull { !it.deleted }?.inventoryLot?.item if (item == null) { return WorkbenchItemLotsResponse( itemId = itemId, @@ -486,6 +493,7 @@ open fun updateInventoryLotLineQuantities(request: UpdateInventoryLotLineQuantit val lot = lotLine.inventoryLot ?: return@mapNotNull null val lotNo = lot.stockInLine?.lotNo ?: return@mapNotNull null val uomDesc = lotLine.stockUom?.uom?.udfudesc ?: return@mapNotNull null + val lotUomId = lotLine.stockUom?.uom?.id val inQty = lotLine.inQty ?: BigDecimal.ZERO val outQty = lotLine.outQty ?: BigDecimal.ZERO val remainingQty = inQty.minus(outQty) @@ -496,6 +504,7 @@ open fun updateInventoryLotLineQuantities(request: UpdateInventoryLotLineQuantit stockInLineId = lot.stockInLine?.id, availableQty = remainingQty, uom = uomDesc, + uomId = lotUomId, warehouseCode = lotLine.warehouse?.code, warehouseName = lotLine.warehouse?.name, ) @@ -523,14 +532,17 @@ open fun updateInventoryLotLineQuantities(request: UpdateInventoryLotLineQuantit val item = scannedInventoryLotLine.inventoryLot?.item ?: throw IllegalStateException("Item not found for lot line id=${scannedInventoryLotLine.id}") + val filterUomId = request.uomId val sameItemLots = inventoryLotLineRepository .findAllByInventoryLotItemIdAndStatus(request.itemId, InventoryLotLineStatus.AVAILABLE) .asSequence() .filter { it.id != scannedInventoryLotLine.id } + .filter { filterUomId == null || it.stockUom?.uom?.id == filterUomId } .mapNotNull { lotLine -> val lot = lotLine.inventoryLot ?: return@mapNotNull null val lotNo = lot.stockInLine?.lotNo ?: return@mapNotNull null val uomDesc = lotLine.stockUom?.uom?.udfudesc ?: return@mapNotNull null + val lotUomId = lotLine.stockUom?.uom?.id val whCode = lotLine.warehouse?.code val whName = lotLine.warehouse?.name @@ -547,6 +559,7 @@ open fun updateInventoryLotLineQuantities(request: UpdateInventoryLotLineQuantit stockInLineId = lot.stockInLine?.id, availableQty = remainingQty, uom = uomDesc, + uomId = lotUomId, warehouseCode = whCode, warehouseName = whName, ) @@ -571,6 +584,8 @@ open fun updateInventoryLotLineQuantities(request: UpdateInventoryLotLineQuantit ?: throw IllegalStateException("inventoryLotLineId missing on scanned lot line"), warehouseCode = scannedInventoryLotLine.warehouse?.code, warehouseName = scannedInventoryLotLine.warehouse?.name, + uom = scannedInventoryLotLine.stockUom?.uom?.udfudesc, + uomId = scannedInventoryLotLine.stockUom?.uom?.id, ), sameItemLots = sameItemLots, ) diff --git a/src/main/java/com/ffii/fpsms/modules/stock/service/InventoryService.kt b/src/main/java/com/ffii/fpsms/modules/stock/service/InventoryService.kt index 407c6a5..d909d74 100644 --- a/src/main/java/com/ffii/fpsms/modules/stock/service/InventoryService.kt +++ b/src/main/java/com/ffii/fpsms/modules/stock/service/InventoryService.kt @@ -88,6 +88,44 @@ open class InventoryService( return RecordsRes(records, total.toInt()); } + /** + * FP-MTMS Version Checklist | Functions Ref. No. 19 | v1.0.0 | 2026-07-17 + * Inventory search page only: no baseUnit/uomId filter; + * one inventory row per item (latest id). Shared getRecordByPage is unchanged. + */ + open fun searchInventoriesByLatestInventory( + request: SearchInventoryRequest, + ): RecordsRes { + val pageable = PageRequest.of(request.pageNum ?: 0, request.pageSize ?: 10) + val code = request.code?.trim().orEmpty() + val name = request.name?.trim().orEmpty() + val type = request.type?.trim().orEmpty() + val lotNo = request.lotNo?.trim()?.takeIf { it.isNotEmpty() } + + val response = if (lotNo != null) { + val itemIds = inventoryLotLineRepository.findDistinctItemIdsByLotNo(lotNo) + if (itemIds.isEmpty()) { + return RecordsRes(emptyList(), 0) + } + inventoryRepository.findLatestInventoryInfoByItemCodeContainsAndItemNameContainsAndItemTypeAndItemIdInAndDeletedIsFalse( + code = code, + name = name, + type = type, + itemIds = itemIds, + pageable = pageable, + ) + } else { + inventoryRepository.findLatestInventoryInfoByItemCodeContainsAndItemNameContainsAndItemTypeAndDeletedIsFalse( + code = code, + name = name, + type = type, + pageable = pageable, + ) + } + + return RecordsRes(response.content, response.totalElements.toInt()) + } + open fun allInventoriesByItems(items: List): List{ return inventoryRepository.findInventoryInfoByItemInAndDeletedIsFalse(items); } diff --git a/src/main/java/com/ffii/fpsms/modules/stock/service/ItemLotTraceLogService.kt b/src/main/java/com/ffii/fpsms/modules/stock/service/ItemLotTraceLogService.kt new file mode 100644 index 0000000..3f98b6e --- /dev/null +++ b/src/main/java/com/ffii/fpsms/modules/stock/service/ItemLotTraceLogService.kt @@ -0,0 +1,60 @@ +package com.ffii.fpsms.modules.stock.service + +import com.ffii.fpsms.modules.stock.entity.ItemLotTraceLog +import com.ffii.fpsms.modules.stock.entity.ItemLotTraceLogRepository +import com.ffii.fpsms.modules.user.entity.User +import org.slf4j.LoggerFactory +import org.springframework.stereotype.Service +import org.springframework.transaction.annotation.Isolation +import org.springframework.transaction.annotation.Transactional + +@Service +open class ItemLotTraceLogService( + private val itemLotTraceLogRepository: ItemLotTraceLogRepository, +) { + private val log = LoggerFactory.getLogger(javaClass) + + @Transactional(isolation = Isolation.READ_COMMITTED, rollbackFor = [Exception::class], readOnly = false) + open fun insert( + user: User, + inventoryLotId: Long?, + lotNo: String?, + itemId: Long?, + itemCode: String?, + ) { + val entity = ItemLotTraceLog().apply { + this.userId = user.id + this.username = user.username + this.inventoryLotId = inventoryLotId + this.lotNo = lotNo?.takeIf { it.isNotBlank() } + this.itemId = itemId + this.itemCode = itemCode?.takeIf { it.isNotBlank() } + } + itemLotTraceLogRepository.save(entity) + } + + /** Best-effort audit write; never fails the main trace. */ + open fun insertQuietly( + user: User?, + inventoryLotId: Long?, + lotNo: String?, + itemId: Long?, + itemCode: String?, + ) { + if (user?.id == null) { + log.warn("Skip item_lot_trace_log insert: no authenticated user") + return + } + try { + insert( + user = user, + inventoryLotId = inventoryLotId, + lotNo = lotNo, + itemId = itemId, + itemCode = itemCode, + ) + } catch (e: Exception) { + log.warn("Failed to insert item_lot_trace_log: {}", e.message, e) + } + } +} diff --git a/src/main/java/com/ffii/fpsms/modules/stock/service/ItemLotTraceService.kt b/src/main/java/com/ffii/fpsms/modules/stock/service/ItemLotTraceService.kt new file mode 100644 index 0000000..1969bbc --- /dev/null +++ b/src/main/java/com/ffii/fpsms/modules/stock/service/ItemLotTraceService.kt @@ -0,0 +1,48 @@ +package com.ffii.fpsms.modules.stock.service + +import com.ffii.fpsms.modules.common.SecurityUtils +import com.ffii.fpsms.modules.stock.trace.ItemLotTraceOrchestrator +import com.ffii.fpsms.modules.stock.web.model.ItemLotTraceLocationBlock +import com.ffii.fpsms.modules.stock.web.model.ItemLotTraceResponse +import org.springframework.stereotype.Service + +@Service +open class ItemLotTraceService( + private val orchestrator: ItemLotTraceOrchestrator, + private val itemLotTraceLogService: ItemLotTraceLogService, +) { + /** FP-MTMS Version Checklist | Functions Ref. No. 10 | v1.0.0 | 2026-07-17 */ + open fun trace( + stockInLineId: Long?, + inventoryLotLineId: Long?, + lotNo: String?, + itemId: Long?, + itemCode: String?, + ): ItemLotTraceResponse { + val response = orchestrator.trace( + stockInLineId, + inventoryLotLineId, + lotNo, + itemId, + itemCode, + ) + val lot = response.lot + itemLotTraceLogService.insertQuietly( + user = SecurityUtils.getUser().orElse(null), + inventoryLotId = lot.inventoryLotId, + lotNo = lot.lotNo, + itemId = lot.itemId, + itemCode = lot.itemCode, + ) + return response + } + + /** + * FP-MTMS Version Checklist | Functions Ref. No. 10 | v1.0.0 | 2026-07-17 + * Lazy-load endpoint: returns the full location-scoped trace block for a single inventory lot. + * Lot-level dimensions (BOM, JO prelude, production, byproducts) are excluded — those are only + * available via the main /trace endpoint. + */ + open fun traceLocation(inventoryLotId: Long): ItemLotTraceLocationBlock = + orchestrator.traceLocation(inventoryLotId) +} diff --git a/src/main/java/com/ffii/fpsms/modules/stock/service/StockInLineService.kt b/src/main/java/com/ffii/fpsms/modules/stock/service/StockInLineService.kt index db6172f..d0739c0 100644 --- a/src/main/java/com/ffii/fpsms/modules/stock/service/StockInLineService.kt +++ b/src/main/java/com/ffii/fpsms/modules/stock/service/StockInLineService.kt @@ -581,7 +581,11 @@ open class StockInLineService( this.stockInLine = stockInLine this.failQty = it.failQty this.qcPassed = it.qcPassed - this.type = "qc" + this.type = when { + stockInLine.jobOrder != null -> "EPQC" + stockInLine.purchaseOrderLine != null -> "IQC" + else -> "IQC" + } this.remarks = it.remarks this.escalationLog = escalationLogService.find(escLogId).getOrNull() } @@ -1030,9 +1034,6 @@ open class StockInLineService( if (request.status == StockInLineStatus.RECEIVED.status) { val _tPutAwayTotal = System.nanoTime() // Putaway - var savedInventoryLotLine: InventoryLotLine? = null - -// savedInventoryLotLine = saveInventoryLotLineWhenStockIn(request = request, stockInLine = stockInLine) val _tSaveLotLines = System.nanoTime() val savedInventoryLotLines = saveInventoryLotLineWhenStockIn(request = request, stockInLine = stockInLine) _logStep("saveInventoryLotLineWhenStockIn", _tSaveLotLines) @@ -1041,6 +1042,11 @@ open class StockInLineService( val inventoryLotLines = stockInLine.inventoryLot?.let { it.id?.let { _id -> inventoryLotLineRepository.findAllByInventoryLotId(_id) } } ?: listOf() _logStep("inventoryLotLineRepository.findAllByInventoryLotId", _tFindAllLotLines) + // NOR putaway can create multiple ILL (per warehouse); SIL only has one FK — keep latest from this putaway. + (savedInventoryLotLines.lastOrNull() ?: inventoryLotLines.lastOrNull())?.let { primaryLotLine -> + stockInLine.inventoryLotLine = primaryLotLine + } + // ✅ 每次上架都寫一筆 stock_ledger(inQty = 本次上架的庫存數量) val _tDeltaCompute = System.nanoTime() val putAwayDeltaStockQty = (request.inventoryLotLines ?: listOf()).sumOf { line -> @@ -1098,7 +1104,6 @@ open class StockInLineService( else StockInLineStatus.PARTIALLY_COMPLETE.status } - // this.inventoryLotLine = savedInventoryLotLine } // Update JO Status if (stockInLine.jobOrder != null) { //TODO Improve @@ -1373,6 +1378,7 @@ open class StockInLineService( } @Transactional + /** FP-MTMS Version Checklist | Functions Ref. No. 46 | v1.0.0 | 2026-08-03 */ private fun createStockLedgerForStockIn(stockInLine: StockInLine, inQty: Double) { val _t0 = System.nanoTime() fun _msSince(t: Long): Double = (System.nanoTime() - t) / 1_000_000.0 @@ -1398,7 +1404,11 @@ open class StockInLineService( val newBalance = previousBalance + inQty val _tUom = System.nanoTime() - val stockUomId = itemUomService.findStockUnitByItemId(item.id!!)?.uom?.id ?: inventory.uom?.id + val stockUomId = StockLedgerLotSnapshot.resolveLedgerUomId( + stockInLine.inventoryLotLine, + itemUomService.findStockUnitByItemId(item.id!!)?.uom?.id, + inventory.uom?.id, + ) _logStep("itemUomService.findStockUnitByItemId", _tUom) val stockLedger = StockLedger().apply { @@ -1413,6 +1423,14 @@ open class StockInLineService( this.uomId = stockUomId this.date = LocalDate.now() } + val ill = stockInLine.inventoryLotLine + val lotAfter = ill?.let { StockLedgerLotSnapshot.availableQty(it) } + StockLedgerLotSnapshot.apply( + ledger = stockLedger, + inventoryLotLine = ill, + lotAvailableAfterMove = lotAfter, + signedDelta = BigDecimal.valueOf(inQty), + ) val _tSave = System.nanoTime() stockLedgerRepository.saveAndFlush(stockLedger) diff --git a/src/main/java/com/ffii/fpsms/modules/stock/service/StockLedgerLotSnapshot.kt b/src/main/java/com/ffii/fpsms/modules/stock/service/StockLedgerLotSnapshot.kt new file mode 100644 index 0000000..54820c8 --- /dev/null +++ b/src/main/java/com/ffii/fpsms/modules/stock/service/StockLedgerLotSnapshot.kt @@ -0,0 +1,63 @@ +package com.ffii.fpsms.modules.stock.service + +import com.ffii.fpsms.modules.stock.entity.InventoryLotLine +import com.ffii.fpsms.modules.stock.entity.StockLedger +import java.math.BigDecimal + +/** + * FP-MTMS Version Checklist | Functions Ref. No. 46 | v1.0.1 | 2026-08-05 + * + * Fills [StockLedger.inventoryLotLine], [lotQtyBefore], [lotQtyAfter] for each movement. + * + * [StockLedger.inventoryLotLine] / inventoryLotLineId = **actual** moved lot line id + * (the lot whose qty changed). Prefer explicit [inventoryLotLine]; else fall back to + * stockOutLine / stockInLine related lot. + * + * On pick/suggestion flows: pass/resolve the lot that was actually picked/posted + * (= suggested_pick_lot.actualPickLotLineId / stock_out_line.inventoryLotLineId), + * never suggested_pick_lot.suggestedLotLineId alone. Lot switch (suggested A, scan B) + * → ledger must point to B. + * + * [lotQtyBefore]/[lotQtyAfter] are snapshots of that same actual lot's available qty. + * [lotAvailableAfterMove] = lot (in−out) **after** this movement has already been applied. + * [signedDelta] = +inQty or −outQty for this ledger row (stock-in positive, stock-out negative). + */ +object StockLedgerLotSnapshot { + /** + * FP-MTMS Version Checklist | Functions Ref. No. 46 | v1.0.1 | 2026-08-05 + * Sets ledger.inventoryLotLine to the actual lot, then lotQtyBefore/After from that lot. + */ + fun apply( + ledger: StockLedger, + inventoryLotLine: InventoryLotLine? = null, + lotAvailableAfterMove: BigDecimal? = null, + signedDelta: BigDecimal? = null, + ) { + val ill = inventoryLotLine + ?: ledger.inventoryLotLine + ?: ledger.stockOutLine?.inventoryLotLine + ?: ledger.stockInLine?.inventoryLotLine + ledger.inventoryLotLine = ill + + if (ill == null || lotAvailableAfterMove == null || signedDelta == null) { + // Leave before/after null when we cannot snapshot (caller may set later). + return + } + ledger.lotQtyAfter = lotAvailableAfterMove + ledger.lotQtyBefore = lotAvailableAfterMove.subtract(signedDelta) + } + + /** FP-MTMS Version Checklist | Functions Ref. No. 46 | v1.0.1 | 2026-08-05 */ + fun availableQty(ill: InventoryLotLine): BigDecimal = + (ill.inQty ?: BigDecimal.ZERO).subtract(ill.outQty ?: BigDecimal.ZERO) + + /** + * FP-MTMS Version Checklist | Functions Ref. No. 46 | v1.0.1 | 2026-08-05 + * Ledger UOM must follow the lot line's stock ItemUom → uom.id + * (same item may have lots with different UOMs; item stockUnit flag can change later). + */ + fun resolveLedgerUomId(ill: InventoryLotLine?, vararg fallbacks: Long?): Long? { + ill?.stockUom?.uom?.id?.let { return it } + return fallbacks.firstOrNull { it != null } + } +} diff --git a/src/main/java/com/ffii/fpsms/modules/stock/service/StockOutLineService.kt b/src/main/java/com/ffii/fpsms/modules/stock/service/StockOutLineService.kt index 0e046f8..e4f114a 100644 --- a/src/main/java/com/ffii/fpsms/modules/stock/service/StockOutLineService.kt +++ b/src/main/java/com/ffii/fpsms/modules/stock/service/StockOutLineService.kt @@ -1511,6 +1511,7 @@ open fun newBatchSubmit(request: QrPickBatchSubmitRequest): MessageResponse { } } @Transactional + /** FP-MTMS Version Checklist | Functions Ref. No. 46 | v1.0.1 | 2026-08-05 */ private fun createStockLedgerForStockOut(stockOutLine: StockOutLine) { val item = stockOutLine.item ?: return val inventory = itemUomService.findInventoryForItemBaseUom(item.id!!) ?: return @@ -1521,6 +1522,9 @@ open fun newBatchSubmit(request: QrPickBatchSubmitRequest): MessageResponse { val previousBalance = latestLedger?.balance ?: (inventory.onHandQty ?: BigDecimal.ZERO).toDouble() val newBalance = previousBalance - outQty + val ill = stockOutLine.inventoryLotLine + val outQtyBd = BigDecimal.valueOf(outQty) + val lotAfter = ill?.let { StockLedgerLotSnapshot.availableQty(it) } val stockLedger = StockLedger().apply { this.stockOutLine = stockOutLine this.inventory = inventory @@ -1530,10 +1534,19 @@ open fun newBatchSubmit(request: QrPickBatchSubmitRequest): MessageResponse { this.type = stockOutLine.type this.itemId = item.id this.itemCode = item.code - this.uomId = itemUomRespository.findByItemIdAndStockUnitIsTrueAndDeletedIsFalse(item.id!!)?.uom?.id - ?: inventory.uom?.id + this.uomId = StockLedgerLotSnapshot.resolveLedgerUomId( + ill, + itemUomRespository.findByItemIdAndStockUnitIsTrueAndDeletedIsFalse(item.id!!)?.uom?.id, + inventory.uom?.id, + ) this.date = LocalDate.now() } + StockLedgerLotSnapshot.apply( + ledger = stockLedger, + inventoryLotLine = ill, + lotAvailableAfterMove = lotAfter, + signedDelta = outQtyBd.negate(), + ) stockLedgerRepository.saveAndFlush(stockLedger) } @@ -1746,6 +1759,7 @@ open fun newBatchSubmit(request: QrPickBatchSubmitRequest): MessageResponse { } + /** FP-MTMS Version Checklist | Functions Ref. No. 46 | v1.0.1 | 2026-08-05 */ private fun createStockLedgerForPickDelta( stockOutLine: StockOutLine, deltaQty: BigDecimal, @@ -1793,6 +1807,8 @@ open fun newBatchSubmit(request: QrPickBatchSubmitRequest): MessageResponse { } } + val ill = stockOutLine.inventoryLotLine + val lotAfter = ill?.let { StockLedgerLotSnapshot.availableQty(it) } val ledger = StockLedger().apply { this.stockOutLine = stockOutLine this.inventory = inventory @@ -1802,10 +1818,19 @@ open fun newBatchSubmit(request: QrPickBatchSubmitRequest): MessageResponse { this.type = "NOR" this.itemId = item.id this.itemCode = item.code - this.uomId = itemUomRespository.findByItemIdAndStockUnitIsTrueAndDeletedIsFalse(item.id!!)?.uom?.id - ?: inventory.uom?.id + this.uomId = StockLedgerLotSnapshot.resolveLedgerUomId( + ill, + itemUomRespository.findByItemIdAndStockUnitIsTrueAndDeletedIsFalse(item.id!!)?.uom?.id, + inventory.uom?.id, + ) this.date = LocalDate.now() } + StockLedgerLotSnapshot.apply( + ledger = ledger, + inventoryLotLine = ill, + lotAvailableAfterMove = lotAfter, + signedDelta = deltaQty.negate(), + ) if (flushAfterSave) { stockLedgerRepository.saveAndFlush(ledger) @@ -2199,6 +2224,9 @@ fun applyStockOutLineDelta( val outQty = deltaQty.toDouble() val newBalance = previousBalance - outQty + val ill = savedSol.inventoryLotLine + val outQtyBd = BigDecimal.valueOf(outQty) + val lotAfter = ill?.let { StockLedgerLotSnapshot.availableQty(it) } val ledger = StockLedger().apply { this.stockOutLine = savedSol this.inventory = inventory @@ -2209,14 +2237,23 @@ fun applyStockOutLineDelta( this.itemId = item.id this.itemCode = item.code this.uomId = - itemUomRespository.findByItemIdAndStockUnitIsTrueAndDeletedIsFalse(item.id!!)?.uom?.id - ?: inventory.uom?.id + StockLedgerLotSnapshot.resolveLedgerUomId( + ill, + itemUomRespository.findByItemIdAndStockUnitIsTrueAndDeletedIsFalse(item.id!!)?.uom?.id, + inventory.uom?.id, + ) this.date = eventTime.toLocalDate() if (!operator.isNullOrBlank()) { this.createdBy = operator this.modifiedBy = operator } } + StockLedgerLotSnapshot.apply( + ledger = ledger, + inventoryLotLine = ill, + lotAvailableAfterMove = lotAfter, + signedDelta = outQtyBd.negate(), + ) if (deferPersistenceFlush) stockLedgerRepository.save(ledger) else stockLedgerRepository.saveAndFlush(ledger) } @@ -2332,7 +2369,9 @@ open fun createStockOutBatch(request: BatchStockOutRequest): BatchStockOutResult } val newBalance = prevBalance - delta.toDouble() - ledgersToInsert += StockLedger().apply { + val ill = sol.inventoryLotLine + val lotAfter = ill?.let { StockLedgerLotSnapshot.availableQty(it) } + val ledger = StockLedger().apply { this.stockOutLine = sol this.inventory = inv this.inQty = null @@ -2341,10 +2380,20 @@ open fun createStockOutBatch(request: BatchStockOutRequest): BatchStockOutResult this.type = request.type this.itemId = itemId this.itemCode = item.code - this.uomId = itemUomRespository.findByItemIdAndStockUnitIsTrueAndDeletedIsFalse(itemId)?.uom?.id - ?: inv.uom?.id + this.uomId = StockLedgerLotSnapshot.resolveLedgerUomId( + ill, + itemUomRespository.findByItemIdAndStockUnitIsTrueAndDeletedIsFalse(itemId)?.uom?.id, + inv.uom?.id, + ) this.date = today } + StockLedgerLotSnapshot.apply( + ledger = ledger, + inventoryLotLine = ill, + lotAvailableAfterMove = lotAfter, + signedDelta = delta.negate(), + ) + ledgersToInsert += ledger runningLedgerBalanceByItemId[itemId] = newBalance } stockLedgerRepository.saveAll(ledgersToInsert) diff --git a/src/main/java/com/ffii/fpsms/modules/stock/service/StockOutLineWorkbenchService.kt b/src/main/java/com/ffii/fpsms/modules/stock/service/StockOutLineWorkbenchService.kt index 013fd73..efcf4c3 100644 --- a/src/main/java/com/ffii/fpsms/modules/stock/service/StockOutLineWorkbenchService.kt +++ b/src/main/java/com/ffii/fpsms/modules/stock/service/StockOutLineWorkbenchService.kt @@ -197,6 +197,9 @@ open class StockOutLineWorkbenchService( ) } + /** + * FP-MTMS Version Checklist | Functions Ref. No. 33 | v1.0.2 | 2026-08-03 + */ @Transactional(rollbackFor = [Exception::class]) open fun scanPick(request: WorkbenchScanPickRequest): MessageResponse { val lotNo = request.lotNo.trim() @@ -315,6 +318,15 @@ open class StockOutLineWorkbenchService( ) } + val polUomId = pol.uom?.id + val lotUomId = scannedIll.stockUom?.uom?.id + if (polUomId != null && lotUomId != null && polUomId != lotUomId) { + return MessageResponse( + id = sol.id, name = "scan_pick", code = "INVALID", type = "workbench_scan_pick", + message = "Scanned lot UOM does not match pick line UOM", errorPosition = null, entity = null + ) + } + sol.inventoryLotLine = scannedIll val required = pol.qty ?: BigDecimal.ZERO @@ -505,6 +517,9 @@ if (updated == 0) { val onHandAfter = (inventory.onHandQty ?: BigDecimal.ZERO).toDouble() val previousBalance = onHandAfter + deltaQty.toDouble() val newBalance = previousBalance - deltaQty.toDouble() + val ill = sol.inventoryLotLine?.id?.let { inventoryLotLineRepository.findById(it).orElse(sol.inventoryLotLine) } + ?: sol.inventoryLotLine + val lotAfter = ill?.let { StockLedgerLotSnapshot.availableQty(it) } val ledger = StockLedger().apply { this.stockOutLine = sol this.inventory = inventory @@ -514,10 +529,19 @@ if (updated == 0) { this.type = "NOR" this.itemId = solItem.id this.itemCode = solItem.code - this.uomId = itemUomRespository.findByItemIdAndStockUnitIsTrueAndDeletedIsFalse(solItem.id!!)?.uom?.id - ?: inventory.uom?.id + this.uomId = StockLedgerLotSnapshot.resolveLedgerUomId( + ill, + itemUomRespository.findByItemIdAndStockUnitIsTrueAndDeletedIsFalse(solItem.id!!)?.uom?.id, + inventory.uom?.id, + ) this.date = LocalDate.now() } + StockLedgerLotSnapshot.apply( + ledger = ledger, + inventoryLotLine = ill, + lotAvailableAfterMove = lotAfter, + signedDelta = deltaQty.negate(), + ) stockLedgerRepository.save(ledger) } diff --git a/src/main/java/com/ffii/fpsms/modules/stock/service/StockTakeRecordService.kt b/src/main/java/com/ffii/fpsms/modules/stock/service/StockTakeRecordService.kt index 635c8cc..4597177 100644 --- a/src/main/java/com/ffii/fpsms/modules/stock/service/StockTakeRecordService.kt +++ b/src/main/java/com/ffii/fpsms/modules/stock/service/StockTakeRecordService.kt @@ -2488,6 +2488,10 @@ private fun applyVarianceAdjustment( } val newBalance = previousBalance - qtyToRemove.toDouble() + // FP-MTMS Version Checklist | Functions Ref. No. 46 | v1.0.1 | 2026-08-05 + // Snapshot from live lot line (in−out) at approve time — not stock_take_record.bookQty. + val lotAfterAvail = StockLedgerLotSnapshot.availableQty(latestLine).subtract(qtyToRemove) + val stockLedger = StockLedger().apply { this.inventory = inventory this.itemId = inventoryLot.item?.id @@ -2497,9 +2501,18 @@ private fun applyVarianceAdjustment( this.stockOutLine = stockOutLine this.balance = newBalance this.type = "TKE" - this.uomId = latestLine.stockUom?.uom?.id ?: inventory.uom?.id + this.uomId = StockLedgerLotSnapshot.resolveLedgerUomId( + latestLine, + inventory.uom?.id, + ) this.date = LocalDate.now() } + StockLedgerLotSnapshot.apply( + ledger = stockLedger, + inventoryLotLine = latestLine, + lotAvailableAfterMove = lotAfterAvail, + signedDelta = qtyToRemove.negate(), + ) if (context != null) { context.stockLedgers.add(stockLedger) @@ -2613,6 +2626,9 @@ private fun applyVarianceAdjustment( } val newBalance = previousBalance + plusQty.toDouble() + // latestLine.inQty already includes plusQty above — snapshot live lot line after move. + val lotAfterAvail = StockLedgerLotSnapshot.availableQty(latestLine) + val stockLedger = StockLedger().apply { this.inventory = inventory this.itemId = inventoryLot.item?.id @@ -2622,9 +2638,18 @@ private fun applyVarianceAdjustment( this.stockInLine = stockInLine this.balance = newBalance this.type = "TKE" - this.uomId = latestLine.stockUom?.uom?.id ?: inventory.uom?.id + this.uomId = StockLedgerLotSnapshot.resolveLedgerUomId( + latestLine, + inventory.uom?.id, + ) this.date = LocalDate.now() } + StockLedgerLotSnapshot.apply( + ledger = stockLedger, + inventoryLotLine = latestLine, + lotAvailableAfterMove = lotAfterAvail, + signedDelta = plusQty, + ) if (context != null) { context.stockLedgers.add(stockLedger) diff --git a/src/main/java/com/ffii/fpsms/modules/stock/service/SuggestedPickLotService.kt b/src/main/java/com/ffii/fpsms/modules/stock/service/SuggestedPickLotService.kt index fde90b9..765a569 100644 --- a/src/main/java/com/ffii/fpsms/modules/stock/service/SuggestedPickLotService.kt +++ b/src/main/java/com/ffii/fpsms/modules/stock/service/SuggestedPickLotService.kt @@ -96,6 +96,7 @@ open class SuggestedPickLotService( return SuggestedPickLotResponse(holdQtyMap = holdQtyMap, suggestedList = suggestedList) } + /** FP-MTMS Version Checklist | Functions Ref. No. 32 | v1.0.1 | 2026-07-22 */ open fun suggestionForPickOrderLines(request: SuggestedPickLotForPolRequest): SuggestedPickLotResponse { val pols = request.pickOrderLines val itemIds = pols.mapNotNull { it.item?.id } @@ -123,7 +124,9 @@ open class SuggestedPickLotService( pols.forEach { line -> val salesUnit = line.item?.id?.let { itemUomService.findSalesUnitByItemId(it) } + val polUomId = line.uom?.id val lotLines = availableInventoryLotLines[line.item?.id].orEmpty() + .filter { polUomId == null || it.uomId == polUomId } val ratio = one // (salesUnit?.ratioN ?: one).divide(salesUnit?.ratioD ?: one, 10, RoundingMode.HALF_UP) val pickOrder = line.pickOrder val isDoPickOrder = pickOrder?.type?.value == "do" || pickOrder?.type?.value == "delivery_order" @@ -275,6 +278,7 @@ open class SuggestedPickLotService( } return SuggestedPickLotResponse(holdQtyMap = holdQtyMap, suggestedList = suggestedList) } + /** FP-MTMS Version Checklist | Functions Ref. No. 32 | v1.0.1 | 2026-07-22 */ open fun suggestionForPickOrderLinesForJobOrder(request: SuggestedPickLotForPolRequest): SuggestedPickLotResponse { val pols = request.pickOrderLines val itemIds = pols.mapNotNull { it.item?.id } @@ -302,8 +306,10 @@ open class SuggestedPickLotService( pols.forEach { line -> val itemId = line.item?.id val isWipItem = itemId != null && wipItemIdSet.contains(itemId) - + val polUomId = line.uom?.id + val lotInfos = availableInventoryLotLines[itemId].orEmpty() + .filter { polUomId == null || it.uomId == polUomId } val lotIds = lotInfos.mapNotNull { it.id } // 仅一次查询,避免后续重复 findById val lotEntities = if (lotIds.isNotEmpty()) inventoryLotLineRepository.findAllById(lotIds) else emptyList() @@ -580,6 +586,7 @@ open class SuggestedPickLotService( return null } } +/** FP-MTMS Version Checklist | Functions Ref. No. 32 | v1.0.1 | 2026-07-22 */ @Transactional(rollbackFor = [Exception::class]) open fun resuggestPickOrder(pickOrderId: Long): MessageResponse { try { @@ -1290,11 +1297,15 @@ private fun generateOptimalSuggestionsForAllPickOrders( var lotRemainingQty = availableQty - // Allocate this lot to pick order lines in order + // Allocate this lot to pick order lines in order (same UOM only) remainingPickOrderLines.removeAll { pickOrderLine -> val lineId = pickOrderLine.id val lineRemainingQty = remainingQtyPerLine[lineId] ?: zero - + val polUomId = pickOrderLine.uom?.id + if (polUomId != null && lot.stockUom?.uom?.id != polUomId) { + return@removeAll false + } + if (lineRemainingQty <= zero || lotRemainingQty <= zero) return@removeAll false val assignQty = minOf(lotRemainingQty, lineRemainingQty) @@ -1394,10 +1405,12 @@ private fun updateInventoryTableAfterResuggest(pickOrder: PickOrder) { if (excessQtyInSalesUnits <= zero) return@forEach - // Get available inventory lots for this item (FEFO order) + val polUomId = orderLine.uom?.id + // Get available inventory lots for this item (FEFO order, same UOM) val availableLots = inventoryLotLineService .allInventoryLotLinesByItemIdIn(listOf(itemId)) .filter { it.status == InventoryLotLineStatus.AVAILABLE.value } + .filter { polUomId == null || it.uomId == polUomId } .sortedBy { it.expiryDate } var remainingQtyInBaseUnits = excessQtyInBaseUnits @@ -1550,9 +1563,11 @@ private fun generateCorrectSuggestionsWithExistingHolds(pickOrder: PickOrder): L println("Available lots: ${availableLots.size}, Unavailable lots: ${unavailableLots.size}") println("Total unavailable hold qty to redistribute: $totalUnavailableHoldQty") - // Generate suggestions from available lots ONLY + val polUomId = orderLine.uom?.id + // Generate suggestions from available lots ONLY (same UOM as POL) availableLots.forEach { lotInfo -> if (remainingQtyInSalesUnits <= zero) return@forEach + if (polUomId != null && lotInfo.uomId != polUomId) return@forEach val lot = lotInfo.id?.let { inventoryLotLineRepository.findById(it).orElse(null) } ?: return@forEach @@ -1620,12 +1635,14 @@ private fun generateCorrectSuggestionsWithOriginalHolds( val salesUnit = itemUomService.findSalesUnitByItemId(itemId) val ratio = one + val polUomId = orderLine.uom?.id - // Get available inventory lots for this item (FEFO order) + // Get available inventory lots for this item (FEFO order, same UOM) val availableLots = inventoryLotLineService .allInventoryLotLinesByItemIdIn(listOf(itemId)) .filter { it.status == InventoryLotLineStatus.AVAILABLE.value } .filter { it.expiryDate.isAfter(today) || it.expiryDate.isEqual(today) } + .filter { polUomId == null || it.uomId == polUomId } .sortedBy { it.expiryDate } // Calculate total quantity that needs to be redistributed diff --git a/src/main/java/com/ffii/fpsms/modules/stock/service/SuggestedPickLotWorkbenchService.kt b/src/main/java/com/ffii/fpsms/modules/stock/service/SuggestedPickLotWorkbenchService.kt index 92f0754..50f02a6 100644 --- a/src/main/java/com/ffii/fpsms/modules/stock/service/SuggestedPickLotWorkbenchService.kt +++ b/src/main/java/com/ffii/fpsms/modules/stock/service/SuggestedPickLotWorkbenchService.kt @@ -70,6 +70,7 @@ open class SuggestedPickLotWorkbenchService( } /** + * FP-MTMS Version Checklist | Functions Ref. No. 32 | v1.0.2 | 2026-08-03 * After workbench scan-pick updates [StockOutLine] qty/status, bind SPL and set **qty** to match SOL (已揀 + 進行中). */ @Transactional(rollbackFor = [Exception::class]) @@ -97,7 +98,14 @@ open class SuggestedPickLotWorkbenchService( } if (spl != null) { spl.stockOutLine = sol - spl.suggestedLotLine = ill + // Keep suggestedLotLine as the system suggestion; only record what was actually picked. + if (spl.suggestedLotLine == null && ill != null) { + spl.suggestedLotLine = ill + } + spl.actualPickLotLine = ill + if (spl.actualPickLotLine == null && spl.suggestedLotLine != null) { + spl.actualPickLotLine = spl.suggestedLotLine + } spl.pickOrderLine = pol spl.qty = qtyBd spl.type = SuggestedPickLotType.PICK_ORDER @@ -109,6 +117,7 @@ open class SuggestedPickLotWorkbenchService( type = SuggestedPickLotType.PICK_ORDER stockOutLine = sol suggestedLotLine = ill + actualPickLotLine = ill pickOrderLine = pol qty = qtyBd }, @@ -150,6 +159,16 @@ open class SuggestedPickLotWorkbenchService( } /** + * FP-MTMS Version Checklist | Functions Ref. No. 32 | v1.0.2 | 2026-08-03 + * Lot stock UOM must match pick_order_line.uom (UomConversion id). + */ + private fun matchesPolUom(lot: InventoryLotLine, line: PickOrderLine): Boolean { + val polUomId = line.uom?.id ?: return false + return lot.stockUom?.uom?.id == polUomId + } + + /** + * FP-MTMS Version Checklist | Functions Ref. No. 32 | v1.0.2 | 2026-08-03 * Build suggestions with no-hold rule: * available = inQty - outQty (ignore holdQty). * When [desired] is a single segment, reuses one SPL row (max id) and repoints lot + qty (switch-lot without new id). @@ -207,7 +226,7 @@ open class SuggestedPickLotWorkbenchService( return@forEach } - val lots = lotsByItem[line.item?.id].orEmpty() + val lots = lotsByItem[line.item?.id].orEmpty().filter { matchesPolUom(it, line) } val desired = buildDesiredAllocations(lots, remaining, lineId) created += syncSuggestedPickLotsForLine(line, desired) } @@ -282,6 +301,7 @@ open class SuggestedPickLotWorkbenchService( } /** + * FP-MTMS Version Checklist | Functions Ref. No. 32 | v1.0.2 | 2026-08-03 * Same no-hold allocation as [rebuildNoHoldSuggestionsForPickOrder], but only for one [pickOrderLineId]. * Used by workbench scan-pick to avoid rebuilding suggestions for every line on the pick order. */ @@ -340,6 +360,7 @@ open class SuggestedPickLotWorkbenchService( .filter { it.status == InventoryLotLineStatus.AVAILABLE } .filter { it.inventoryLot?.expiryDate?.isBefore(today) != true } .filter { !isWarehouseExcluded(it, excludeSet) } + .filter { matchesPolUom(it, lineFresh) } .filter { lot -> if (storeKey == null) true else normalizeStoreId(lot.warehouse?.store_id) == storeKey @@ -372,6 +393,7 @@ open class SuggestedPickLotWorkbenchService( } /** + * FP-MTMS Version Checklist | Functions Ref. No. 32 | v1.0.2 | 2026-08-03 * Workbench explicit-qty remainder support (single-next-lot): * Create exactly ONE suggested lot row for this POL with [targetQty] pointing to the next available lot. * When [storeId] is provided, the next lot must be within the same store (warehouse.store_id). @@ -411,6 +433,7 @@ open class SuggestedPickLotWorkbenchService( .filter { it.status == InventoryLotLineStatus.AVAILABLE } .filter { it.inventoryLot?.expiryDate?.isBefore(today) != true } .filter { !isWarehouseExcluded(it, excludeSet) } + .filter { matchesPolUom(it, line) } .filter { lot -> if (excludeInventoryLotLineId == null) true else lot.id != excludeInventoryLotLineId } @@ -431,6 +454,7 @@ open class SuggestedPickLotWorkbenchService( } /** + * FP-MTMS Version Checklist | Functions Ref. No. 32 | v1.0.2 | 2026-08-03 * Workbench explicit-qty split support: * Set suggestions for this POL to an exact [targetQty] (independent of POL.qty). * - If [targetQty] <= 0: soft-delete all active suggestions for the line. @@ -466,6 +490,7 @@ open class SuggestedPickLotWorkbenchService( .filter { it.status == InventoryLotLineStatus.AVAILABLE } .filter { it.inventoryLot?.expiryDate?.isBefore(today) != true } .filter { !isWarehouseExcluded(it, excludeSet) } + .filter { matchesPolUom(it, line) } .sortedBy { it.inventoryLot?.expiryDate } val desired = buildDesiredAllocations(lots, targetQty, pickOrderLineId) @@ -545,6 +570,7 @@ open class SuggestedPickLotWorkbenchService( } /** + * FP-MTMS Version Checklist | Functions Ref. No. 32 | v1.0.2 | 2026-08-03 * Align DB rows with [desired]. Returns count of newly inserted SPL rows. */ private fun syncSuggestedPickLotsForLine( @@ -563,6 +589,14 @@ open class SuggestedPickLotWorkbenchService( val reuse = keepers.maxByOrNull { it.id ?: 0L } if (reuse != null) { reuse.suggestedLotLine = ill + // Pending suggestion: keep actual in sync with suggested until user picks/switches. + if (reuse.actualPickLotLine == null || + reuse.actualPickLotLine?.id == null || + reuse.stockOutLine == null || + !isSplFrozen(reuse) + ) { + reuse.actualPickLotLine = ill + } reuse.pickOrderLine = line reuse.type = SuggestedPickLotType.PICK_ORDER reuse.qty = qty @@ -579,6 +613,7 @@ open class SuggestedPickLotWorkbenchService( SuggestedPickLot().apply { type = SuggestedPickLotType.PICK_ORDER suggestedLotLine = ill + actualPickLotLine = ill pickOrderLine = line this.qty = qty }, @@ -600,6 +635,12 @@ open class SuggestedPickLotWorkbenchService( val spl = byIllKey[k] if (spl != null) { spl.suggestedLotLine = ill + if (spl.actualPickLotLine == null || + spl.stockOutLine == null || + !isSplFrozen(spl) + ) { + spl.actualPickLotLine = ill + } spl.pickOrderLine = line spl.type = SuggestedPickLotType.PICK_ORDER spl.qty = qty @@ -613,6 +654,7 @@ open class SuggestedPickLotWorkbenchService( SuggestedPickLot().apply { type = SuggestedPickLotType.PICK_ORDER suggestedLotLine = ill + actualPickLotLine = ill pickOrderLine = line this.qty = qty }, diff --git a/src/main/java/com/ffii/fpsms/modules/stock/trace/ItemLotTraceOrchestrator.kt b/src/main/java/com/ffii/fpsms/modules/stock/trace/ItemLotTraceOrchestrator.kt new file mode 100644 index 0000000..32186f2 --- /dev/null +++ b/src/main/java/com/ffii/fpsms/modules/stock/trace/ItemLotTraceOrchestrator.kt @@ -0,0 +1,319 @@ +package com.ffii.fpsms.modules.stock.trace + +import com.ffii.fpsms.modules.master.entity.ItemsRepository +import com.ffii.fpsms.modules.stock.web.model.ItemLotTraceResponse +import org.springframework.http.HttpStatus +import org.springframework.stereotype.Component +import org.springframework.web.server.ResponseStatusException + +@Component +open class ItemLotTraceOrchestrator( + private val lotResolver: TraceLotResolver, + private val inboundLoader: TraceInboundLoader, + private val movementLoader: TraceMovementLoader, + private val outboundLoader: TraceOutboundLoader, + private val bomLoader: TraceBomLoader, + private val productionLoader: TraceProductionLoader, + private val joPreludeLoader: TraceJoPreludeLoader, + private val extendedLoader: TraceExtendedLoader, + private val upstreamLoader: TraceUpstreamLoader, + private val itemsRepository: ItemsRepository, + private val traceGraphSemanticsBuilder: TraceGraphSemanticsBuilder, +) { + open fun trace( + stockInLineId: Long?, + inventoryLotLineId: Long?, + lotNo: String?, + itemId: Long?, + itemCode: String?, + ): ItemLotTraceResponse { + val resolvedItemId = itemId ?: itemCode?.trim()?.takeIf { it.isNotEmpty() }?.let { code -> + itemsRepository.findByCodeAndDeletedFalse(code)?.id + ?: throw ResponseStatusException(HttpStatus.NOT_FOUND, "Item code not found: $code") + } + + val inventoryLotId = lotResolver.resolveInventoryLotId( + stockInLineId, + inventoryLotLineId, + lotNo, + resolvedItemId, + ) + val lot = lotResolver.loadLotInfo(inventoryLotId) + val lotLineIds = lotResolver.loadLotLineIds(inventoryLotId) + val stockInLineIds = lotResolver.loadStockInLineIds(inventoryLotId) + + val joProvenance = upstreamLoader.resolveJoProvenance( + inventoryLotId, + itemId = lot.itemId, + lotNo = lot.lotNo, + ) + + val effectiveJoSilId = lot.primaryStockInLineId?.takeIf { bomLoader.hasJoStockIn(it) } + ?: joProvenance?.joStockInLineId + val joId = lotResolver.resolveJobOrderIdForLot(inventoryLotId, lot.primaryStockInLineId) + ?: joProvenance?.jobOrderId + + val warehouseLines = lotResolver.loadWarehouseLines(inventoryLotId) + val alternateLocations = lotResolver.loadAlternateLocations(lot.itemId, lot.lotNo, inventoryLotId) + val altLotIdSet = alternateLocations.map { it.inventoryLotId }.toSet() + + // Transfer-chain / JO upstream lots whose PO/QC/putaway should surface on the + // destination lot when its own stock-in is only TRANSFER (helpers were unused). + // Sibling same-lotNo lots already arrive via locationBlocks — exclude them here + // to avoid duplicating rows in merged tabs. + val upstreamMergeLotIds = ( + upstreamLoader.resolveUpstreamLotIds(inventoryLotId, lot.itemId, lot.lotNo) + + (joProvenance?.upstreamLotIds.orEmpty()) + ) + .distinct() + .filter { it != inventoryLotId && it !in altLotIdSet } + + val origins = inboundLoader.loadOriginsForLots(listOf(inventoryLotId) + upstreamMergeLotIds) + .distinctBy { it.stockInLineId } + val upstreamStockInLineIds = if (upstreamMergeLotIds.isNotEmpty()) { + lotResolver.loadStockInLineIdsForLots(upstreamMergeLotIds).values.flatten().distinct() + } else { + emptyList() + } + val mergedStockInLineIds = (stockInLineIds + upstreamStockInLineIds).distinct() + val purchaseEvents = inboundLoader.loadPurchaseEvents(mergedStockInLineIds) + .distinctBy { it.purchaseOrderLineId } + val qcResults = inboundLoader.loadQcResults(mergedStockInLineIds) + .distinctBy { "${it.stockInLineId}|${it.qcItemCode}|${it.qcType}|${it.created}" } + val putawayEvents = ( + inboundLoader.loadPutawayEvents(inventoryLotId) + + upstreamMergeLotIds.flatMap { inboundLoader.loadPutawayEvents(it) } + ) + .distinctBy { "${it.inventoryLotLineId}|${it.timestamp}|${it.refCode}|${it.qty}" } + val movements = movementLoader.loadMovements(inventoryLotId, lotLineIds) + val outboundUsage = outboundLoader.loadOutboundUsage(inventoryLotId) + val stockTakeEvents = movementLoader.loadStockTakeEvents(lotLineIds) + val adjustments = movementLoader.loadAdjustments(inventoryLotId, lotLineIds) + val transfers = movementLoader.loadTransfers(inventoryLotId) + val bomTrace = bomLoader.loadBomTrace( + inventoryLotId, + effectiveJoSilId ?: lot.primaryStockInLineId, + lot.itemId, + lotLineIds, + ) + val joPrelude = effectiveJoSilId?.let { silId -> + if (bomLoader.hasJoStockIn(silId)) joPreludeLoader.loadJoPrelude(silId) else null + } + val productionSteps = joId?.let { productionLoader.loadProductionSteps(it) } ?: emptyList() + val byproductLots = joId?.let { productionLoader.loadByproductLots(it, lot.itemId) } ?: emptyList() + val openMovements = movementLoader.loadOpenMovements(inventoryLotId) + val failEvents = extendedLoader.loadFailEvents(inventoryLotId) + val rawMovements = movements.sortedByDescending { it.timestamp ?: "" } + val doDeliveries = resolveDoDeliveries(listOf(inventoryLotId), rawMovements) + val movementsWithDoMeta = enrichDoMovements(rawMovements, doDeliveries) + val returnEvents = outboundLoader.loadReturnEvents(inventoryLotId, lotLineIds) + val replenishmentEvents = outboundLoader.loadReplenishmentEvents(inventoryLotId) + val lotRelations = extendedLoader.loadLotRelations(inventoryLotId, lot.itemId, lot.lotNo) + + // Batch pre-load alternate location data to avoid N+1 queries + val altLotIds = alternateLocations.map { it.inventoryLotId } + val altLotInfoByLotId = if (altLotIds.isNotEmpty()) lotResolver.loadLotInfoBatch(altLotIds) else emptyMap() + val altStockInLineIdsByLotId = if (altLotIds.isNotEmpty()) lotResolver.loadStockInLineIdsForLots(altLotIds) else emptyMap() + val altLotLineIdsByLotId = if (altLotIds.isNotEmpty()) lotResolver.loadLotLineIdsForLots(altLotIds) else emptyMap() + val altPutawayByLotId = if (altLotIds.isNotEmpty()) inboundLoader.loadPutawayEventsByLotIds(altLotIds) else emptyMap() + + // Batch pre-load origins, purchase events, and QC results across ALL alternate locations + val altOriginsByLotId: Map> = + if (altLotIds.isNotEmpty()) { + inboundLoader.loadOriginsForLots(altLotIds) + .groupBy { origin -> + // Map back to lot via stockInLineId lookup + altStockInLineIdsByLotId.entries + .firstOrNull { (_, silIds) -> silIds.contains(origin.stockInLineId) }?.key + ?: altLotIds.first() + } + } else emptyMap() + + val allAltStockInLineIds = altStockInLineIdsByLotId.values.flatten().distinct() + val altPurchaseEventsBySilId: Map> = + if (allAltStockInLineIds.isNotEmpty()) { + inboundLoader.loadPurchaseEvents(allAltStockInLineIds) + .groupBy { it.purchaseOrderLineId } + } else emptyMap() + + val altQcResultsBySilId: Map> = + if (allAltStockInLineIds.isNotEmpty()) { + inboundLoader.loadQcResults(allAltStockInLineIds) + .groupBy { it.stockInLineId } + } else emptyMap() + + val locationBlocks = alternateLocations.map { alt -> + val altLot = altLotInfoByLotId[alt.inventoryLotId] + val altSilIds = altStockInLineIdsByLotId[alt.inventoryLotId].orEmpty() + traceLocationBlock( + inventoryLotId = alt.inventoryLotId, + itemCode = altLot?.itemCode ?: lot.itemCode, + lotNo = altLot?.lotNo ?: lot.lotNo, + itemName = altLot?.itemName ?: "", + uom = altLot?.uom ?: "", + expiryDate = altLot?.expiryDate, + productionDate = altLot?.productionDate, + stockInDate = altLot?.stockInDate, + primaryStockInLineId = altLot?.primaryStockInLineId, + preloadedStockInLineIds = altSilIds, + preloadedLotLineIds = altLotLineIdsByLotId[alt.inventoryLotId], + preloadedPutawayEvents = altPutawayByLotId[alt.inventoryLotId], + preloadedOrigins = altOriginsByLotId[alt.inventoryLotId], + preloadedPurchaseEvents = altSilIds.flatMap { sid -> altPurchaseEventsBySilId[sid].orEmpty() }.distinctBy { it.purchaseOrderLineId }, + preloadedQcResults = altSilIds.flatMap { sid -> altQcResultsBySilId[sid].orEmpty() }, + ) + } + + return ItemLotTraceResponse( + lot = lot, + warehouseLines = warehouseLines, + origins = origins, + purchaseEvents = purchaseEvents, + qcResults = qcResults, + putawayEvents = putawayEvents.sortedBy { it.timestamp ?: "" }, + movements = movementsWithDoMeta, + outboundUsage = outboundUsage.sortedByDescending { it.timestamp ?: "" }, + stockTakeEvents = stockTakeEvents.sortedByDescending { it.timestamp ?: "" }, + adjustments = adjustments.sortedByDescending { it.timestamp ?: "" }, + transfers = transfers.sortedByDescending { it.timestamp ?: "" }, + bomTrace = bomTrace, + joPrelude = joPrelude, + productionSteps = productionSteps, + byproductLots = byproductLots, + openMovements = openMovements, + failEvents = failEvents, + doDeliveries = doDeliveries, + replenishmentEvents = replenishmentEvents, + returnEvents = returnEvents, + lotRelations = lotRelations, + alternateLocations = alternateLocations, + locationBlocks = locationBlocks, + ).let { response -> + response.copy(traceGraph = traceGraphSemanticsBuilder.build(response)) + } + } + + private fun resolveDoDeliveries( + mergedLotIds: List, + movements: List, + ): List { + val fromLots = mergedLotIds.flatMap { outboundLoader.loadDoDeliveries(it) } + val covered = fromLots.map { it.pickOrderCode.trim() }.filter { it.isNotEmpty() }.toSet() + val missingCodes = movements + .filter { it.refType.equals("DO", ignoreCase = true) } + .map { it.refCode.trim() } + .filter { it.isNotEmpty() && it !in covered } + .distinct() + val supplemental = outboundLoader.loadDoDeliveriesByPickOrderCodes(missingCodes) + return (fromLots + supplemental).distinctBy { it.stockOutLineId } + } + + /** FP-MTMS Version Checklist | Functions Ref. No. 24 | v1.0.0 | 2026-07-20 */ + private fun enrichDoMovements( + movements: List, + doDeliveries: List, + ): List { + val metaByPickCode = doDeliveries.associateBy { it.pickOrderCode.trim() } + return movements.map { movement -> + if (!movement.refType.equals("DO", ignoreCase = true)) return@map movement + val meta = metaByPickCode[movement.refCode.trim()] ?: return@map movement + movement.copy( + pickConsoCode = meta.consoCode, + outboundTicketNo = meta.ticketNo, + deliveryOrderPickOrderId = meta.deliveryOrderPickOrderId, + relationshipId = meta.relationshipId, + doOutboundIsExtra = meta.isExtra, + doOutboundIsReplenish = meta.isReplenish, + doOutboundQtyChanged = meta.qtyChanged, + ) + } + } + + /** + * Builds a location-scoped trace block for a single alternate [inventoryLotId]. + * + * Only includes dimensions that are location-scoped (warehouse lines, movements, origins, + * outbound usage, stock take, etc.). Lot-level dimensions (BOM, JO prelude, production steps, + * byproduct lots, lot relations, trace graph) are NOT included — they're already at the top + * level of [ItemLotTraceResponse] and are shared across all locations of the same lot. + */ + open fun traceLocationBlock( + inventoryLotId: Long, + itemCode: String, + lotNo: String, + itemName: String = "", + uom: String = "", + expiryDate: String? = null, + productionDate: String? = null, + stockInDate: String? = null, + primaryStockInLineId: Long? = null, + /** Pre-loaded from batch query — avoids per-location stockInLineIds query. */ + preloadedStockInLineIds: List? = null, + /** Pre-loaded from batch query — avoids per-location lotLineIds query. */ + preloadedLotLineIds: List? = null, + /** Pre-loaded from batch query — avoids per-location putaway query. */ + preloadedPutawayEvents: List? = null, + /** Pre-loaded from batch query — avoids per-location origins query. */ + preloadedOrigins: List? = null, + /** Pre-loaded from batch query — avoids per-location purchaseEvents query. */ + preloadedPurchaseEvents: List? = null, + /** Pre-loaded from batch query — avoids per-location qcResults query. */ + preloadedQcResults: List? = null, + ): com.ffii.fpsms.modules.stock.web.model.ItemLotTraceLocationBlock { + val lotLineIds = preloadedLotLineIds ?: lotResolver.loadLotLineIds(inventoryLotId) + val stockInLineIds = preloadedStockInLineIds ?: lotResolver.loadStockInLineIds(inventoryLotId) + val effectiveSilId = primaryStockInLineId ?: stockInLineIds.firstOrNull() + val rawMovements = movementLoader.loadMovements(inventoryLotId, lotLineIds) + .sortedByDescending { it.timestamp ?: "" } + val doDeliveries = resolveDoDeliveries(listOf(inventoryLotId), rawMovements) + val movementsWithDoMeta = enrichDoMovements(rawMovements, doDeliveries) + + return com.ffii.fpsms.modules.stock.web.model.ItemLotTraceLocationBlock( + inventoryLotId = inventoryLotId, + lotNo = lotNo, + itemCode = itemCode, + itemName = itemName, + uom = uom, + expiryDate = expiryDate, + productionDate = productionDate, + stockInDate = stockInDate, + stockInLineId = effectiveSilId, + warehouseLines = lotResolver.loadWarehouseLines(inventoryLotId), + origins = preloadedOrigins ?: inboundLoader.loadOrigins(inventoryLotId), + purchaseEvents = preloadedPurchaseEvents ?: inboundLoader.loadPurchaseEvents(stockInLineIds), + qcResults = preloadedQcResults ?: inboundLoader.loadQcResults(stockInLineIds), + putawayEvents = preloadedPutawayEvents ?: inboundLoader.loadPutawayEvents(inventoryLotId), + movements = movementsWithDoMeta, + outboundUsage = outboundLoader.loadOutboundUsage(inventoryLotId), + stockTakeEvents = movementLoader.loadStockTakeEvents(lotLineIds), + adjustments = movementLoader.loadAdjustments(inventoryLotId, lotLineIds), + transfers = movementLoader.loadTransfers(inventoryLotId), + openMovements = movementLoader.loadOpenMovements(inventoryLotId), + failEvents = extendedLoader.loadFailEvents(inventoryLotId), + doDeliveries = doDeliveries, + replenishmentEvents = outboundLoader.loadReplenishmentEvents(inventoryLotId), + returnEvents = outboundLoader.loadReturnEvents(inventoryLotId, lotLineIds), + ) + } + + /** + * Lazy-load entry point: resolves lot info from [inventoryLotId] then delegates to + * [traceLocationBlock]. Used by the controller's `GET /trace/location/{inventoryLotId}` endpoint. + */ + open fun traceLocation( + inventoryLotId: Long, + ): com.ffii.fpsms.modules.stock.web.model.ItemLotTraceLocationBlock { + val lot = lotResolver.loadLotInfo(inventoryLotId) + return traceLocationBlock( + inventoryLotId = inventoryLotId, + itemCode = lot.itemCode, + lotNo = lot.lotNo, + itemName = lot.itemName, + uom = lot.uom, + expiryDate = lot.expiryDate, + productionDate = lot.productionDate, + stockInDate = lot.stockInDate, + primaryStockInLineId = lot.primaryStockInLineId, + ) + } +} diff --git a/src/main/java/com/ffii/fpsms/modules/stock/trace/TraceBomLoader.kt b/src/main/java/com/ffii/fpsms/modules/stock/trace/TraceBomLoader.kt new file mode 100644 index 0000000..24c1d1d --- /dev/null +++ b/src/main/java/com/ffii/fpsms/modules/stock/trace/TraceBomLoader.kt @@ -0,0 +1,308 @@ +package com.ffii.fpsms.modules.stock.trace + +import com.ffii.core.support.JdbcDao +import com.ffii.fpsms.modules.stock.web.model.* +import org.springframework.stereotype.Component + +data class BomStepAssignment( + val bomProcessId: Long, + val bomProcessSeqNo: Long, + val stepName: String, +) + +@Component +open class TraceBomLoader( + private val jdbcDao: JdbcDao, + private val sql: TraceSqlSupport, + private val lotResolver: TraceLotResolver, +) { + open fun itemCodeKey(code: String): String = code.trim().uppercase() + + open fun loadBomTrace( + inventoryLotId: Long, + primaryStockInLineId: Long?, + itemId: Long, + lotLineIds: List, + ): ItemLotTraceBomTrace { + val isFinishedGood = primaryStockInLineId?.let { hasJoStockIn(it) } == true + val isMaterial = lotLineIds.isNotEmpty() && hasJoStockOut(lotLineIds) + + val direction = when { + isFinishedGood -> "FINISHED_GOOD" + isMaterial -> "MATERIAL" + else -> "UNKNOWN" + } + + val downstream = if (isMaterial) loadBomDownstream(lotLineIds) else emptyList() + val upstream = if (isFinishedGood && primaryStockInLineId != null) { + loadBomUpstream(primaryStockInLineId) + } else { + emptyList() + } + + val bomRecipe = when { + isFinishedGood && primaryStockInLineId != null -> loadBomRecipeForFgStockIn(primaryStockInLineId) + isMaterial && downstream.isNotEmpty() -> { + val joId = downstream.first().jobOrderId + if (joId != null) loadBomRecipeForJobOrder(joId) else emptyList() + } + else -> emptyList() + } + + return ItemLotTraceBomTrace( + direction = direction, + downstream = downstream, + upstream = upstream, + bomRecipe = bomRecipe, + ) + } + + open fun hasJoStockIn(stockInLineId: Long): Boolean { + val query = """ + SELECT COUNT(*) AS cnt FROM stock_in_line + WHERE id = :stockInLineId AND deleted = 0 AND jobOrderId IS NOT NULL + """.trimIndent() + val cnt = jdbcDao.queryForList(query, mapOf("stockInLineId" to stockInLineId)) + .firstOrNull()?.let { (it["cnt"] as? Number)?.toLong() ?: 0L } ?: 0L + return cnt > 0 + } + + /** Resolve the JO finished-goods stock-in line for a material lot (semi-finished from another JO). */ + open fun resolveJoProducedStockInLineId( + materialStockInLineId: Long?, + materialInventoryLotId: Long?, + silIdsByLot: Map>, + ): Long? { + materialStockInLineId?.takeIf { hasJoStockIn(it) }?.let { return it } + materialInventoryLotId?.let { lotId -> + silIdsByLot[lotId] + ?.sortedDescending() + ?.firstOrNull { hasJoStockIn(it) } + }?.let { return it } + if (materialInventoryLotId == null) return null + val query = """ + SELECT id FROM stock_in_line + WHERE inventoryLotId = :inventoryLotId AND deleted = 0 AND jobOrderId IS NOT NULL + ORDER BY created DESC + LIMIT 1 + """.trimIndent() + return jdbcDao.queryForList(query, mapOf("inventoryLotId" to materialInventoryLotId)) + .firstOrNull()?.let { sql.longVal(it["id"]) } + } + + open fun isJoProducedMaterial( + resolvedSilId: Long?, + materialInventoryLotId: Long?, + stockInOrigin: ItemLotTraceOrigin?, + ): Boolean { + if (resolvedSilId != null && hasJoStockIn(resolvedSilId)) return true + if (stockInOrigin?.type.equals("JO", ignoreCase = true)) return true + return materialInventoryLotId?.let { lotResolver.resolveJobOrderIdForLot(it, null) != null } == true + } + + open fun hasJoStockOut(lotLineIds: List): Boolean { + val query = """ + SELECT COUNT(*) AS cnt + FROM stock_out_line sol + INNER JOIN stock_out so ON sol.stockOutId = so.id AND so.deleted = 0 + WHERE sol.deleted = 0 AND sol.inventoryLotLineId IN (:lotLineIds) + AND so.type IN ('job', 'jo') + """.trimIndent() + val cnt = jdbcDao.queryForList(query, mapOf("lotLineIds" to lotLineIds)) + .firstOrNull()?.let { (it["cnt"] as? Number)?.toLong() ?: 0L } ?: 0L + return cnt > 0 + } + + open fun loadBomDownstream(lotLineIds: List): List { + val query = """ + SELECT DISTINCT + COALESCE(jo.code, '') AS jobOrderCode, + jo.id AS jobOrderId, + COALESCE(fg_stock_it.code, fg_bom_it.code, '') AS finishedItemCode, + COALESCE(fg_il.lotNo, fg_sil.lotNo, '') AS finishedLotNo, + fg_sil.id AS finishedStockInLineId, + COALESCE(fg_sil.acceptedQty, 0) AS fgQty, + COALESCE(fg_uc.udfudesc, '') AS fgUom, + COALESCE(sol.qty, 0) AS materialQtyUsed + FROM stock_out_line sol + INNER JOIN stock_out so ON sol.stockOutId = so.id AND so.deleted = 0 + AND so.type IN ('job', 'jo') + INNER JOIN pick_order_line pol ON sol.pickOrderLineId = pol.id AND pol.deleted = 0 + INNER JOIN pick_order po ON pol.poId = po.id AND po.deleted = 0 AND po.joId IS NOT NULL + INNER JOIN job_order jo ON po.joId = jo.id AND jo.deleted = 0 + LEFT JOIN bom b ON jo.bomId = b.id AND b.deleted = 0 + LEFT JOIN items fg_bom_it ON b.itemId = fg_bom_it.id AND fg_bom_it.deleted = 0 + LEFT JOIN stock_in_line fg_sil ON fg_sil.jobOrderId = jo.id AND fg_sil.deleted = 0 + LEFT JOIN inventory_lot fg_il ON fg_sil.inventoryLotId = fg_il.id AND fg_il.deleted = 0 + LEFT JOIN items fg_stock_it ON fg_il.itemId = fg_stock_it.id AND fg_stock_it.deleted = 0 + LEFT JOIN items fg_item ON fg_item.id = COALESCE(fg_stock_it.id, fg_bom_it.id) + LEFT JOIN item_uom fg_iu ON fg_item.id = fg_iu.itemId AND fg_iu.stockUnit = 1 AND fg_iu.deleted = 0 + LEFT JOIN uom_conversion fg_uc ON fg_iu.uomId = fg_uc.id AND fg_uc.deleted = 0 + WHERE sol.deleted = 0 AND sol.inventoryLotLineId IN (:lotLineIds) + ORDER BY jobOrderCode, finishedLotNo + """.trimIndent() + return jdbcDao.queryForList(query, mapOf("lotLineIds" to lotLineIds)).map { row -> + ItemLotTraceBomDownstream( + jobOrderCode = sql.str(row["jobOrderCode"]), + jobOrderId = sql.longVal(row["jobOrderId"]), + finishedItemCode = sql.str(row["finishedItemCode"]), + finishedLotNo = sql.str(row["finishedLotNo"]), + finishedStockInLineId = sql.longVal(row["finishedStockInLineId"]), + fgQty = sql.decimal(row["fgQty"]), + fgUom = sql.str(row["fgUom"]), + materialQtyUsed = sql.decimal(row["materialQtyUsed"]), + ) + } + } + + open fun loadBomUpstream(primaryStockInLineId: Long): List { + val query = """ + SELECT + COALESCE(jo.code, '') AS jobOrderCode, + jo.id AS jobOrderId, + COALESCE(mat_it.code, '') AS materialItemCode, + COALESCE(mat_il.lotNo, mat_sil.lotNo, '') AS materialLotNo, + mat_sil.id AS materialStockInLineId, + COALESCE(sol.qty, 0) AS materialQty, + bm.qty AS bomQtyPerUnit + FROM stock_in_line fg_sil + INNER JOIN job_order jo ON fg_sil.jobOrderId = jo.id AND jo.deleted = 0 + INNER JOIN pick_order po ON po.joId = jo.id AND po.deleted = 0 + AND po.type IN ('jo', 'JOB_ORDER') + INNER JOIN pick_order_line pol ON pol.poId = po.id AND pol.deleted = 0 + INNER JOIN stock_out_line sol ON sol.pickOrderLineId = pol.id AND sol.deleted = 0 + INNER JOIN inventory_lot_line mat_ill ON sol.inventoryLotLineId = mat_ill.id AND mat_ill.deleted = 0 + INNER JOIN inventory_lot mat_il ON mat_ill.inventoryLotId = mat_il.id AND mat_il.deleted = 0 + LEFT JOIN stock_in_line mat_sil ON mat_il.stockInLineId = mat_sil.id AND mat_sil.deleted = 0 + LEFT JOIN items mat_it ON mat_il.itemId = mat_it.id AND mat_it.deleted = 0 + LEFT JOIN bom b ON jo.bomId = b.id AND b.deleted = 0 + LEFT JOIN bom_material bm ON bm.bomId = b.id AND bm.itemId = mat_it.id AND bm.deleted = 0 + WHERE fg_sil.id = :primaryStockInLineId AND fg_sil.deleted = 0 + ORDER BY mat_it.code, mat_il.lotNo + """.trimIndent() + return jdbcDao.queryForList(query, mapOf("primaryStockInLineId" to primaryStockInLineId)).map { row -> + ItemLotTraceBomUpstream( + jobOrderCode = sql.str(row["jobOrderCode"]), + jobOrderId = sql.longVal(row["jobOrderId"]), + materialItemCode = sql.str(row["materialItemCode"]), + materialLotNo = sql.str(row["materialLotNo"]), + materialStockInLineId = sql.longVal(row["materialStockInLineId"]), + materialQty = sql.decimal(row["materialQty"]), + bomQtyPerUnit = row["bomQtyPerUnit"]?.let { sql.decimal(it) }, + ) + } + } + + open fun loadBomRecipeForFgStockIn(primaryStockInLineId: Long): List { + val joId = getJobOrderIdForFgStockIn(primaryStockInLineId) + return if (joId != null) loadBomRecipeForJobOrder(joId) else emptyList() + } + + open fun loadBomRecipeForJobOrder(jobOrderId: Long): List { + val stepByItem = loadBomMaterialStepAssignments(jobOrderId) + val query = """ + SELECT + COALESCE(mat_it.code, '') AS materialItemCode, + COALESCE(bm.itemName, mat_it.name, '') AS materialItemName, + COALESCE(bm.qty, 0) AS qtyPerUnit, + COALESCE(bm.uomName, uc.udfudesc, '') AS uom + FROM job_order jo + INNER JOIN bom b ON jo.bomId = b.id AND b.deleted = 0 + INNER JOIN bom_material bm ON bm.bomId = b.id AND bm.deleted = 0 + LEFT JOIN items mat_it ON bm.itemId = mat_it.id AND mat_it.deleted = 0 + LEFT JOIN uom_conversion uc ON bm.uomId = uc.id + WHERE jo.id = :jobOrderId AND jo.deleted = 0 + ORDER BY mat_it.code + """.trimIndent() + return jdbcDao.queryForList(query, mapOf("jobOrderId" to jobOrderId)).map { row -> + val itemCode = sql.str(row["materialItemCode"]) + val assignment = stepByItem[itemCodeKey(itemCode)] + ItemLotTraceBomRecipeLine( + materialItemCode = itemCode, + materialItemName = sql.str(row["materialItemName"]), + qtyPerUnit = sql.decimal(row["qtyPerUnit"]), + uom = sql.str(row["uom"]), + bomProcessId = assignment?.bomProcessId, + bomProcessSeqNo = assignment?.bomProcessSeqNo, + assignedStepName = assignment?.stepName ?: "", + ) + } + } + + open fun loadBomMaterialStepAssignments(jobOrderId: Long): Map { + val query = """ + SELECT + COALESCE(it.code, '') AS materialItemCode, + bp.id AS bomProcessId, + bp.seqNo AS bomProcessSeqNo, + COALESCE(p.name, bp.description, '') AS stepName + FROM job_order jo + INNER JOIN bom b ON jo.bomId = b.id AND b.deleted = 0 + INNER JOIN bom_material bm ON bm.bomId = b.id AND bm.deleted = 0 + INNER JOIN bom_process_material bpm ON bpm.bomMaterialId = bm.id AND bpm.deleted = 0 + INNER JOIN bom_process bp ON bp.id = bpm.bomProcessId AND bp.deleted = 0 + LEFT JOIN process p ON p.id = bp.processId AND p.deleted = 0 + LEFT JOIN items it ON bm.itemId = it.id AND it.deleted = 0 + WHERE jo.id = :jobOrderId AND jo.deleted = 0 + ORDER BY bp.seqNo, it.code + """.trimIndent() + val result = linkedMapOf() + jdbcDao.queryForList(query, mapOf("jobOrderId" to jobOrderId)).forEach { row -> + val key = itemCodeKey(sql.str(row["materialItemCode"])) + if (key.isEmpty() || key in result) return@forEach + val bomProcessId = sql.longVal(row["bomProcessId"]) ?: return@forEach + result[key] = BomStepAssignment( + bomProcessId = bomProcessId, + bomProcessSeqNo = sql.longVal(row["bomProcessSeqNo"]) ?: 0L, + stepName = sql.str(row["stepName"]), + ) + } + return result + } + + open fun loadStepMaterialsByJobOrder(jobOrderId: Long): Map> { + val query = """ + SELECT + bp.id AS bomProcessId, + COALESCE(it.code, '') AS materialItemCode, + COALESCE(bm.itemName, it.name, '') AS materialItemName, + COALESCE(bm.qty, 0) AS qtyPerUnit, + COALESCE(bm.uomName, uc.udfudesc, '') AS uom + FROM job_order jo + INNER JOIN bom b ON jo.bomId = b.id AND b.deleted = 0 + INNER JOIN bom_process bp ON bp.bomId = b.id AND bp.deleted = 0 + INNER JOIN bom_process_material bpm ON bpm.bomProcessId = bp.id AND bpm.deleted = 0 + INNER JOIN bom_material bm ON bm.id = bpm.bomMaterialId AND bm.deleted = 0 + LEFT JOIN items it ON bm.itemId = it.id AND it.deleted = 0 + LEFT JOIN uom_conversion uc ON bm.uomId = uc.id + WHERE jo.id = :jobOrderId AND jo.deleted = 0 + ORDER BY bp.seqNo, it.code + """.trimIndent() + val grouped = linkedMapOf>() + jdbcDao.queryForList(query, mapOf("jobOrderId" to jobOrderId)).forEach { row -> + val bomProcessId = sql.longVal(row["bomProcessId"]) ?: return@forEach + val itemCode = sql.str(row["materialItemCode"]) + val list = grouped.getOrPut(bomProcessId) { mutableListOf() } + if (list.any { it.materialItemCode.equals(itemCode, ignoreCase = true) }) return@forEach + list.add( + ItemLotTraceStepMaterialRef( + materialItemCode = itemCode, + materialItemName = sql.str(row["materialItemName"]), + qtyPerUnit = sql.decimal(row["qtyPerUnit"]), + uom = sql.str(row["uom"]), + ), + ) + } + return grouped + } + + open fun getJobOrderIdForFgStockIn(primaryStockInLineId: Long): Long? { + val query = """ + SELECT jo.id AS jobOrderId FROM stock_in_line sil + INNER JOIN job_order jo ON sil.jobOrderId = jo.id AND jo.deleted = 0 + WHERE sil.id = :primaryStockInLineId AND sil.deleted = 0 + """.trimIndent() + return jdbcDao.queryForList(query, mapOf("primaryStockInLineId" to primaryStockInLineId)) + .firstOrNull()?.let { sql.longVal(it["jobOrderId"]) } + } +} diff --git a/src/main/java/com/ffii/fpsms/modules/stock/trace/TraceExtendedLoader.kt b/src/main/java/com/ffii/fpsms/modules/stock/trace/TraceExtendedLoader.kt new file mode 100644 index 0000000..66019b1 --- /dev/null +++ b/src/main/java/com/ffii/fpsms/modules/stock/trace/TraceExtendedLoader.kt @@ -0,0 +1,112 @@ +package com.ffii.fpsms.modules.stock.trace + +import com.ffii.core.support.JdbcDao +import com.ffii.fpsms.modules.stock.web.model.* +import org.springframework.stereotype.Component + +@Component +open class TraceExtendedLoader( + private val jdbcDao: JdbcDao, + private val sql: TraceSqlSupport, +) { + open fun loadFailEvents(inventoryLotId: Long): List { + val query = """ + SELECT + f.id AS failId, + COALESCE(f.type, '') AS failType, + COALESCE(f.category, '') AS category, + COALESCE(f.qty, 0) AS qty, + COALESCE(u.name, '') AS handlerName, + f.recordDate AS recordDate, + COALESCE(po.code, '') AS pickOrderCode, + po.id AS pickOrderId, + COALESCE(wh.code, '') AS warehouseCode + FROM fail_inventory_lot_line f + LEFT JOIN user u ON f.handerId = u.id AND u.deleted = 0 + LEFT JOIN stock_out_line sol ON f.stock_out_line_id = sol.id AND sol.deleted = 0 + LEFT JOIN pick_order_line pol ON sol.pickOrderLineId = pol.id AND pol.deleted = 0 + LEFT JOIN pick_order po ON pol.poId = po.id AND po.deleted = 0 + LEFT JOIN inventory_lot_line ill ON sol.inventoryLotLineId = ill.id AND ill.deleted = 0 + LEFT JOIN warehouse wh ON ill.warehouseId = wh.id AND wh.deleted = 0 + WHERE f.deleted = 0 AND f.Lot_id = :inventoryLotId + ORDER BY recordDate DESC, f.id DESC + """.trimIndent() + return jdbcDao.queryForList(query, mapOf("inventoryLotId" to inventoryLotId)).map { row -> + ItemLotTraceFailEvent( + failId = sql.longVal(row["failId"]) ?: 0L, + failType = sql.str(row["failType"]), + category = sql.str(row["category"]), + qty = sql.decimal(row["qty"]), + handlerName = sql.str(row["handlerName"]), + recordDate = sql.formatDate(row["recordDate"]), + pickOrderCode = sql.str(row["pickOrderCode"]), + pickOrderId = sql.longVal(row["pickOrderId"]), + warehouseCode = sql.str(row["warehouseCode"]), + ) + } + } + + open fun loadLotRelations( + inventoryLotId: Long, + itemId: Long, + lotNo: String, + ): List { + val query = """ + SELECT + 'REPACK_SIBLING' AS relationType, + il2.id AS inventoryLotId, + COALESCE(il2.lotNo, '') AS lotNo, + COALESCE(it.code, '') AS itemCode, + COALESCE(it.name, '') AS itemName, + COALESCE(sil2.acceptedQty, 0) AS qty, + COALESCE(sil_src.productLotNo, '') AS productLotNo, + COALESCE(sil2.created, sil2.modified, sil2.receiptDate) AS timestamp + FROM inventory_lot il + INNER JOIN stock_in_line sil_src ON il.stockInLineId = sil_src.id AND sil_src.deleted = 0 + INNER JOIN stock_in_line sil2 ON sil2.deleted = 0 + AND sil2.itemId = il.itemId + AND sil2.productLotNo IS NOT NULL + AND TRIM(sil2.productLotNo) <> '' + AND sil2.productLotNo = sil_src.productLotNo + INNER JOIN inventory_lot il2 ON sil2.inventoryLotId = il2.id AND il2.deleted = 0 + INNER JOIN items it ON il2.itemId = it.id AND it.deleted = 0 + WHERE il.id = :inventoryLotId + AND il.deleted = 0 + AND il2.id <> :inventoryLotId + AND COALESCE(il2.lotNo, '') <> :lotNo + AND sil2.purchaseOrderId IS NULL + AND ( + sil_src.purchaseOrderId IS NULL + OR sil2.stockTransferId IS NOT NULL + OR EXISTS ( + SELECT 1 + FROM stock_transfer_record tr + INNER JOIN stock_out_line sol ON tr.stockOutLineId = sol.id AND sol.deleted = 0 + INNER JOIN inventory_lot_line ill ON sol.inventoryLotLineId = ill.id AND ill.deleted = 0 + WHERE tr.stockInLineId = sil2.id + AND ill.inventoryLotId = il.id + ) + OR ( + sil2.jobOrderId IS NOT NULL + AND sil2.jobOrderId = sil_src.jobOrderId + ) + ) + ORDER BY timestamp, il2.lotNo + """.trimIndent() + return jdbcDao.queryForList( + query, + mapOf("inventoryLotId" to inventoryLotId, "lotNo" to lotNo), + ).map { row -> + ItemLotTraceLotRelation( + relationType = sql.str(row["relationType"]), + inventoryLotId = sql.longVal(row["inventoryLotId"]), + lotNo = sql.str(row["lotNo"]), + itemCode = sql.str(row["itemCode"]), + itemName = sql.str(row["itemName"]), + qty = sql.decimal(row["qty"]), + productLotNo = sql.str(row["productLotNo"]), + timestamp = sql.formatDateTime(row["timestamp"]), + ) + }.distinctBy { "${it.itemCode}:${it.lotNo}" } + } +} diff --git a/src/main/java/com/ffii/fpsms/modules/stock/trace/TraceGraphNodeBuilder.kt b/src/main/java/com/ffii/fpsms/modules/stock/trace/TraceGraphNodeBuilder.kt new file mode 100644 index 0000000..a3a203a --- /dev/null +++ b/src/main/java/com/ffii/fpsms/modules/stock/trace/TraceGraphNodeBuilder.kt @@ -0,0 +1,855 @@ +package com.ffii.fpsms.modules.stock.trace + +import com.ffii.fpsms.modules.stock.web.model.ItemLotTraceJoPrelude +import com.ffii.fpsms.modules.stock.web.model.ItemLotTraceMaterialInput +import com.ffii.fpsms.modules.stock.web.model.ItemLotTraceQcResult +import com.ffii.fpsms.modules.stock.web.model.ItemLotTraceResponse +import java.math.BigDecimal +import java.time.LocalDate +import java.time.LocalDateTime +import java.time.format.DateTimeFormatter + +internal data class TraceGraphNode( + val id: String, + val kind: String, + val timestamp: String?, + val sortKey: Long, + val refCode: String? = null, + val refId: Long? = null, + val traceLotNo: String? = null, + val traceItemCode: String? = null, + val consoCode: String? = null, + val jobOrderCode: String? = null, + val bomProcessId: Long? = null, + val bomProcessSeqNo: Long? = null, + val feedsProductionScopeLotNo: String? = null, + var doGroupId: String? = null, + val refType: String? = null, + val warehouseCode: String? = null, + val transferFromWarehouse: String? = null, + val transferToWarehouse: String? = null, + val replenishmentStockOutLineId: Long? = null, +) + +internal object TraceGraphNodeBuilder { + private val MOVEMENT_REF_TYPES_IN_DEDICATED_ARRAYS = setOf("TRANSFER", "ADJ", "OPEN", "TKE") + private val DATE_TIME = DateTimeFormatter.ofPattern("yyyy-MM-dd HH:mm:ss") + private val DATE_ONLY = DateTimeFormatter.ISO_LOCAL_DATE + + fun buildNodes(data: ItemLotTraceResponse): List { + val hasPrelude = data.joPrelude != null + val hasScrap = data.productionSteps.any { (it.scrapQty + it.defectQty) > BigDecimal.ZERO } + val hasMaterialProduction = + data.joPrelude?.materialInputs?.let { materialInputsHaveProduction(it) } == true + val hasProduction = + data.productionSteps.isNotEmpty() || + data.byproductLots.isNotEmpty() || + hasScrap || + hasMaterialProduction + + val preludeNodes = data.joPrelude?.let { buildJoPreludeGraphNodes(it) } ?: emptyList() + val productionNodes = + if (hasProduction) { + buildProductionGraphNodes( + data.productionSteps, + data.byproductLots, + data.lot.uom, + ) + } else { + emptyList() + } + val extendedNodes = buildExtendedTraceGraphNodes(data) + val fgNodes = buildTraceGraphNodes(data) + val locationBlockNodes = buildLocationBlockGraphNodes(data.locationBlocks) + + return (preludeNodes + productionNodes + extendedNodes + fgNodes + locationBlockNodes).sortedWith( + compareBy { it.sortKey }.thenBy { it.id }, + ) + } + + internal fun materialInputsHaveProduction(inputs: List): Boolean = + inputs.any { m -> + m.productionSteps.isNotEmpty() || + (m.nestedJoPrelude?.materialInputs?.let { materialInputsHaveProduction(it) } == true) + } + + private fun buildTraceGraphNodes(data: ItemLotTraceResponse): List { + val nodes = mutableListOf() + var seq = 0 + val stockUom = data.lot.uom + + data.purchaseEvents.forEachIndexed { i, p -> + nodes += + TraceGraphNode( + id = "purchase-$i-${p.purchaseOrderLineId}", + kind = "PURCHASE", + timestamp = p.orderDate, + sortKey = parseSortKey(p.orderDate, seq++), + refCode = p.purchaseOrderCode, + refId = p.purchaseOrderId, + ) + } + + data.movements.forEachIndexed { i, m -> + val refType = norm(m.refType) + if (MOVEMENT_REF_TYPES_IN_DEDICATED_ARRAYS.contains(refType)) return@forEachIndexed + if (isStockTakeLedgerMovement(m.movementType)) return@forEachIndexed + if (refType == "DO" && data.doDeliveries.isNotEmpty()) return@forEachIndexed + if (refType == "RETURN" && data.returnEvents.isNotEmpty()) return@forEachIndexed + + val kind = + when { + m.direction == "IN" && refType == "PO" -> "RECEIPT" + m.direction == "IN" && refType == "JO" -> "JO_CREATED" + m.direction == "IN" -> "IN" + m.direction == "OUT" && refType == "DO" -> "DO_OUT" + m.direction == "OUT" && refType == "JO_PICK" -> "JO_OUT" + m.direction == "OUT" && refType == "PO_PICK" -> "PO_OUT" + else -> "OUT" + } + nodes += + TraceGraphNode( + id = "mov-$i-${m.refCode}-${m.timestamp}", + kind = kind, + timestamp = m.timestamp, + sortKey = parseSortKey(m.timestamp, seq++), + refCode = m.refCode, + refId = m.refId, + ) + } + + buildQcNodes(data.qcResults, seq).let { (qcNodes, nextSeq) -> + nodes += qcNodes + seq = nextSeq + } + + data.putawayEvents.forEachIndexed { i, p -> + if (norm(p.refType) == "TRANSFER") return@forEachIndexed + nodes += + TraceGraphNode( + id = "putaway-$i-${p.inventoryLotLineId}-${p.timestamp}", + kind = "PUTAWAY", + timestamp = p.timestamp, + sortKey = parseSortKey(p.timestamp, seq++), + refCode = p.refCode, + refId = p.refId, + refType = p.refType, + warehouseCode = p.warehouseCode, + ) + } + + data.stockTakeEvents.forEachIndexed { e, st -> + nodes += + TraceGraphNode( + id = "st-$e-${st.stockTakeCode}-${st.timestamp}", + kind = "STOCK_TAKE", + timestamp = st.timestamp, + sortKey = parseSortKey(st.timestamp, seq++), + refCode = st.stockTakeCode, + ) + } + + data.adjustments.forEachIndexed { i, a -> + if (norm(a.adjustmentType) == "TKE" && data.stockTakeEvents.isNotEmpty()) return@forEachIndexed + nodes += + TraceGraphNode( + id = "adj-$i-${a.refCode}-${a.timestamp}-${a.direction}", + kind = "ADJUSTMENT", + timestamp = a.timestamp, + sortKey = parseSortKey(a.timestamp, seq++), + refCode = a.refCode, + ) + } + + data.transfers.forEachIndexed { i, tr -> + nodes += + TraceGraphNode( + id = "tr-$i-${tr.transferCode}-${tr.timestamp}", + kind = "TRANSFER", + timestamp = tr.timestamp, + sortKey = parseSortKey(tr.timestamp, seq++), + refCode = tr.transferCode, + transferFromWarehouse = tr.fromWarehouse, + transferToWarehouse = tr.toWarehouse, + ) + } + + val totalAvailable = data.warehouseLines.fold(BigDecimal.ZERO) { acc, w -> acc + w.availableQty } + val expiry = data.lot.expiryDate + if (!expiry.isNullOrBlank()) { + runCatching { LocalDate.parse(expiry, DATE_ONLY) }.getOrNull()?.let { expDate -> + if (expDate.isBefore(LocalDate.now())) { + nodes += + TraceGraphNode( + id = "terminal-expired-${data.lot.lotNo}", + kind = "EXPIRED", + timestamp = expiry, + sortKey = parseExpirySortKey(expiry, seq + 1_000_000), + ) + } + } + } + if (totalAvailable <= BigDecimal.ZERO && data.warehouseLines.isNotEmpty()) { + val lastTs = + data.movements.firstOrNull()?.timestamp + ?: data.outboundUsage.firstOrNull()?.timestamp + ?: data.lot.stockInDate + nodes += + TraceGraphNode( + id = "terminal-depleted-${data.lot.lotNo}", + kind = "DEPLETED", + timestamp = lastTs, + sortKey = parseSortKey(lastTs, seq + 2_000_000), + ) + } + + return nodes + } + + private fun buildQcNodes( + qcResults: List, + seqStart: Int, + ): Pair, Int> { + val nodes = mutableListOf() + var seq = seqStart + groupQcResultsBySession(qcResults).forEachIndexed { i, group -> + val first = group.firstOrNull() ?: return@forEachIndexed + nodes += + TraceGraphNode( + id = "qc-$i-${first.stockInLineId}-${first.created}", + kind = "QC", + timestamp = first.created, + sortKey = parseSortKey(first.created, seq++), + ) + } + return nodes to seq + } + + private fun buildProductionGraphNodes( + productionSteps: List, + byproductLots: List, + lotUom: String, + ): List { + val nodes = mutableListOf() + var seq = 0 + + productionSteps.forEach { step -> + val ts = step.endTime ?: step.startTime + nodes += + TraceGraphNode( + id = "prod-step-${step.processLineId}", + kind = "PRODUCTION_STEP", + timestamp = ts, + sortKey = parseSortKey(ts, seq++), + refCode = null, + bomProcessId = step.bomProcessId, + bomProcessSeqNo = step.seqNo, + ) + if (step.scrapQty > BigDecimal.ZERO) { + nodes += + TraceGraphNode( + id = "scrap-${step.processLineId}", + kind = "SCRAP", + timestamp = ts, + sortKey = parseSortKey(ts, seq++) + 1, + bomProcessId = step.bomProcessId, + bomProcessSeqNo = step.seqNo, + ) + } + if (step.defectQty > BigDecimal.ZERO) { + nodes += + TraceGraphNode( + id = "defect-${step.processLineId}", + kind = "DEFECT", + timestamp = ts, + sortKey = parseSortKey(ts, seq++) + 1, + bomProcessId = step.bomProcessId, + bomProcessSeqNo = step.seqNo, + ) + } + } + + byproductLots.forEachIndexed { i, bp -> + nodes += + TraceGraphNode( + id = "byproduct-${bp.inventoryLotId ?: i}-${bp.lotNo}", + kind = "BYPRODUCT", + timestamp = bp.producedAt, + sortKey = parseSortKey(bp.producedAt, seq++), + refCode = bp.jobOrderCode, + refId = bp.jobOrderId, + traceLotNo = bp.lotNo, + traceItemCode = bp.itemCode, + ) + } + + return nodes + } + + private fun buildExtendedTraceGraphNodes(data: ItemLotTraceResponse): List { + val nodes = mutableListOf() + var seq = 0 + val stockUom = data.lot.uom + + data.openMovements.forEach { o -> + nodes += + TraceGraphNode( + id = "open-${o.stockInLineId}", + kind = "OPEN", + timestamp = o.timestamp, + sortKey = parseSortKey(o.timestamp, seq++), + refCode = o.refCode, + warehouseCode = o.warehouseCode, + ) + } + + data.failEvents.forEach { f -> + nodes += + TraceGraphNode( + id = "fail-${f.failId}", + kind = "FAIL", + timestamp = f.recordDate, + sortKey = parseSortKey(f.recordDate, seq++), + refCode = f.pickOrderCode, + refId = f.pickOrderId, + consoCode = f.pickOrderCode, + warehouseCode = f.warehouseCode, + ) + } + + data.doDeliveries.forEach { d -> + nodes += + TraceGraphNode( + id = "do-${d.stockOutLineId}", + kind = "DO_OUT", + timestamp = d.timestamp, + sortKey = parseSortKey(d.timestamp, seq++), + refCode = d.deliveryOrderCode.ifBlank { d.pickOrderCode }, + refId = d.deliveryOrderId ?: d.pickOrderId, + consoCode = d.consoCode.ifBlank { d.pickOrderCode }, + ) + } + + data.replenishmentEvents.forEach { r -> + nodes += + TraceGraphNode( + id = "replenish-${r.replenishmentId}", + kind = "REPLENISHMENT_CREATED", + timestamp = r.timestamp, + sortKey = parseSortKey(r.timestamp, seq++), + refCode = r.sourceDoCode.ifBlank { r.replenishmentCode }, + refId = r.sourceDoId, + replenishmentStockOutLineId = r.stockOutLineId, + ) + } + + data.returnEvents.forEach { r -> + nodes += + TraceGraphNode( + id = "return-${r.stockOutLineId}-${r.timestamp}", + kind = "RETURN", + timestamp = r.timestamp, + sortKey = parseSortKey(r.timestamp, seq++), + refCode = r.refCode, + warehouseCode = r.warehouseCode, + ) + } + + data.lotRelations.forEachIndexed { i, rel -> + nodes += + TraceGraphNode( + id = "repack-${rel.inventoryLotId ?: i}-${rel.lotNo}", + kind = "REPACK", + timestamp = rel.timestamp, + sortKey = parseSortKey(rel.timestamp, seq++), + traceLotNo = rel.lotNo, + traceItemCode = rel.itemCode, + ) + } + + return nodes + } + + private data class PreludeBuildContext( + val seenPurchaseLines: MutableSet = mutableSetOf(), + val seenInboundLots: MutableSet = mutableSetOf(), + val seenReceiptLots: MutableSet = mutableSetOf(), + val seenQcKeys: MutableSet = mutableSetOf(), + val seenPutawayKeys: MutableSet = mutableSetOf(), + var seq: Int = 0, + ) + + private data class LotPickRef( + val pickOrderCode: String, + val pickOrderId: Long?, + val consoCode: String, + ) + + private fun buildJoPreludeGraphNodes(joPrelude: ItemLotTraceJoPrelude): List { + val ctx = PreludeBuildContext() + val lotPicks = buildLotPickOrderMap(joPrelude.materialInputs) + val chainNodes = buildMaterialInputChainNodes(joPrelude.materialInputs, lotPicks, ctx) + val pickNodes = + joPrelude.materialInputs.mapIndexed { i, m -> + createMaterialPickNode(m, i, ctx) + } + return (chainNodes + pickNodes).sortedWith(compareBy { it.sortKey }.thenBy { it.id }) + } + + private fun buildLotPickOrderMap( + materialInputs: List, + ): Map> { + val map = mutableMapOf>() + materialInputs.forEach { m -> + val code = m.pickOrderCode.trim() + if (code.isEmpty()) return@forEach + val key = materialLotKey(m.materialItemCode, m.materialLotNo) + val list = map.getOrPut(key) { mutableListOf() } + if (list.none { it.pickOrderCode == code }) { + list += + LotPickRef( + pickOrderCode = code, + pickOrderId = m.pickOrderId, + consoCode = m.consoCode, + ) + } + val nestedInputs = m.nestedJoPrelude?.materialInputs ?: emptyList() + if (nestedInputs.isNotEmpty()) { + mergeLotPickMaps(map, buildLotPickOrderMap(nestedInputs)) + } + } + return map + } + + private fun mergeLotPickMaps( + target: MutableMap>, + source: Map>, + ) { + source.forEach { (key, picks) -> + val list = target.getOrPut(key) { mutableListOf() } + picks.forEach { p -> + if (list.none { it.pickOrderCode == p.pickOrderCode }) { + list += p + } + } + } + } + + private fun buildMaterialInputChainNodes( + materialInputs: List, + lotPicks: Map>, + ctx: PreludeBuildContext, + ): List { + val nodes = mutableListOf() + materialInputs.forEach { m -> + val joProduced = isJoProducedMaterial(m) + val nestedInputs = m.nestedJoPrelude?.materialInputs ?: emptyList() + + if (joProduced && m.nestedJoPrelude != null) { + buildNestedJoCreatedNode(m, ctx)?.let { nodes += it } + } + + if (joProduced && nestedInputs.isNotEmpty()) { + val nestedLotPicks = buildLotPickOrderMap(nestedInputs) + nodes += buildMaterialInputChainNodes(nestedInputs, nestedLotPicks, ctx) + nestedInputs.forEachIndexed { ni, nm -> + nodes += createMaterialPickNode(nm, ni, ctx, m.materialLotNo) + } + } + + if (joProduced && m.productionSteps.isNotEmpty()) { + nodes += buildMaterialProductionNodes(m, ctx) + } + + nodes += buildMaterialStockInPreludeNodes(m, lotPicks, ctx) + } + return nodes + } + + private fun buildNestedJoCreatedNode( + m: ItemLotTraceMaterialInput, + ctx: PreludeBuildContext, + ): TraceGraphNode? { + val nested = m.nestedJoPrelude ?: return null + val jo = nested.jobOrder + val joCode = jo.jobOrderCode.trim() + if (joCode.isEmpty()) return null + + val nestedInputs = nested.materialInputs + val nestedPickTs = nestedInputs.mapNotNull { it.pickedAt?.trim() }.firstOrNull() + val nestedProdTs = m.productionSteps.mapNotNull { it.startTime?.trim() }.firstOrNull() + val ts = + jo.createdAt?.trim()?.takeIf { it.isNotEmpty() } + ?: jo.planStart?.trim()?.takeIf { it.isNotEmpty() } + ?: nestedPickTs + ?: nestedProdTs + ?: m.stockInOrigin?.receiptDate?.trim()?.takeIf { it.isNotEmpty() } + + return TraceGraphNode( + id = "mat-jo-created-${jo.jobOrderId}-${m.materialLotNo}", + kind = "JO_CREATED", + timestamp = ts, + sortKey = parseSortKey(ts, ctx.seq++), + refType = "JO", + refCode = jo.jobOrderCode, + refId = jo.jobOrderId, + traceLotNo = m.materialLotNo, + traceItemCode = m.materialItemCode, + ) + } + + private fun buildMaterialProductionNodes( + m: ItemLotTraceMaterialInput, + ctx: PreludeBuildContext, + ): List { + if (m.productionSteps.isEmpty()) return emptyList() + val matUom = m.materialUom.trim() + return buildProductionGraphNodes(m.productionSteps, emptyList(), matUom).map { node -> + node.copy( + id = "mat-prod-${m.materialLotNo}-${node.id}", + traceLotNo = m.materialLotNo, + traceItemCode = m.materialItemCode, + ) + } + } + + private fun buildMaterialStockInPreludeNodes( + m: ItemLotTraceMaterialInput, + lotPicks: Map>, + ctx: PreludeBuildContext, + ): List { + val nodes = mutableListOf() + val lotId = m.materialInventoryLotId + val origin = m.stockInOrigin + val matUom = m.materialUom.trim() + + m.purchaseEvents.forEach { p -> + if (ctx.seenPurchaseLines.contains(p.purchaseOrderLineId)) return@forEach + ctx.seenPurchaseLines += p.purchaseOrderLineId + nodes += + TraceGraphNode( + id = "mat-purchase-${p.purchaseOrderLineId}-${m.materialLotNo}", + kind = "PURCHASE", + timestamp = p.orderDate, + sortKey = parseSortKey(p.orderDate, ctx.seq++), + refCode = p.purchaseOrderCode, + refId = p.purchaseOrderId, + traceLotNo = m.materialLotNo, + traceItemCode = m.materialItemCode, + ) + } + + if (origin != null && lotId != null) { + val originType = origin.type.trim().uppercase() + if (originType == "PO" && !ctx.seenReceiptLots.contains(lotId)) { + ctx.seenReceiptLots += lotId + nodes += + TraceGraphNode( + id = "mat-receipt-$lotId-${origin.stockInLineId}", + kind = "RECEIPT", + timestamp = origin.receiptDate, + sortKey = parseSortKey(origin.receiptDate, ctx.seq++), + refCode = origin.refCode, + refId = origin.refId, + traceLotNo = m.materialLotNo, + traceItemCode = m.materialItemCode, + ) + } else if (originType != "PO" && !ctx.seenInboundLots.contains(lotId)) { + val skipJoOriginBecauseNested = + originType == "JO" && + !m.nestedJoPrelude?.jobOrder?.jobOrderCode?.trim().isNullOrEmpty() + if (!skipJoOriginBecauseNested) { + ctx.seenInboundLots += lotId + val isJoOrigin = originType == "JO" + nodes += + TraceGraphNode( + id = "mat-in-$lotId-${origin.stockInLineId}", + kind = if (isJoOrigin) "JO_CREATED" else "MATERIAL_IN", + timestamp = origin.receiptDate, + sortKey = parseSortKey(origin.receiptDate, ctx.seq++), + refCode = origin.refCode, + refId = origin.refId, + traceLotNo = m.materialLotNo, + traceItemCode = m.materialItemCode, + ) + } + } + } + + groupQcResultsBySession(m.qcResults).forEach { group -> + val first = group.firstOrNull() ?: return@forEach + val key = "${first.stockInLineId}-${first.created}" + if (ctx.seenQcKeys.contains(key)) return@forEach + ctx.seenQcKeys += key + nodes += + TraceGraphNode( + id = "mat-qc-$key", + kind = "MATERIAL_QC", + timestamp = first.created, + sortKey = parseSortKey(first.created, ctx.seq++), + traceLotNo = m.materialLotNo, + traceItemCode = m.materialItemCode, + ) + } + + m.putawayEvents.forEach { p -> + val key = "${p.inventoryLotLineId}-${p.timestamp}" + if (ctx.seenPutawayKeys.contains(key)) return@forEach + if (norm(p.refType) == "TRANSFER") return@forEach + ctx.seenPutawayKeys += key + nodes += + TraceGraphNode( + id = "mat-putaway-${p.inventoryLotLineId}-${p.timestamp}", + kind = "PUTAWAY", + timestamp = p.timestamp, + sortKey = parseSortKey(p.timestamp, ctx.seq++), + refCode = p.refCode, + refId = p.refId, + traceLotNo = m.materialLotNo, + traceItemCode = m.materialItemCode, + ) + } + + return nodes + } + + private fun createMaterialPickNode( + m: ItemLotTraceMaterialInput, + index: Int, + ctx: PreludeBuildContext, + feedsProductionScopeLotNo: String? = null, + ): TraceGraphNode = + TraceGraphNode( + id = "mat-pick-$index-${m.pickOrderId}-${m.materialLotNo}-${m.pickedAt}", + kind = "MATERIAL_PICK", + timestamp = m.pickedAt, + sortKey = parseSortKey(m.pickedAt, ctx.seq++), + refCode = m.pickOrderCode, + refId = m.pickOrderId, + traceLotNo = m.materialLotNo, + traceItemCode = m.materialItemCode, + consoCode = m.consoCode, + jobOrderCode = m.jobOrderCode.trim().ifEmpty { null }, + bomProcessId = m.bomProcessId, + bomProcessSeqNo = m.bomProcessSeqNo, + feedsProductionScopeLotNo = feedsProductionScopeLotNo, + ) + + private fun isJoProducedMaterial(m: ItemLotTraceMaterialInput): Boolean = + m.productionSteps.isNotEmpty() || + m.nestedJoPrelude != null || + m.stockInOrigin?.type?.trim()?.uppercase() == "JO" + + private fun groupQcResultsBySession(results: List): List> { + val map = linkedMapOf>() + results.forEach { q -> + val key = "${q.stockInLineId}:${q.created ?: ""}" + map.getOrPut(key) { mutableListOf() } += q + } + return map.values.toList() + } + + /** + * Builds trace graph nodes for all alternate location blocks, tagged with warehouseCode + * so the frontend can visually distinguish multi-location flows. + */ + private fun buildLocationBlockGraphNodes( + locationBlocks: List, + ): List { + if (locationBlocks.isEmpty()) return emptyList() + val nodes = mutableListOf() + var seq = 0 + + locationBlocks.forEach { block -> + val whCode = block.warehouseLines.firstOrNull()?.warehouseCode ?: "" + val locPrefix = "loc-${block.inventoryLotId}" + + // Purchase events + block.purchaseEvents.forEachIndexed { i, p -> + nodes += TraceGraphNode( + id = "$locPrefix-purchase-$i-${p.purchaseOrderLineId}", + kind = "PURCHASE", timestamp = p.orderDate, + sortKey = parseSortKey(p.orderDate, seq++), + refCode = p.purchaseOrderCode, refId = p.purchaseOrderId, + warehouseCode = whCode, + ) + } + + // Origins (receipts) + block.origins.forEachIndexed { i, o -> + val kind = when (norm(o.type)) { + "PO" -> "RECEIPT" + "JO" -> "JO_CREATED" + "OPEN" -> "OPEN" + else -> "IN" + } + nodes += TraceGraphNode( + id = "$locPrefix-origin-$i-${o.stockInLineId}", + kind = kind, timestamp = o.receiptDate, + sortKey = parseSortKey(o.receiptDate, seq++), + refCode = o.refCode, refId = o.refId, + warehouseCode = whCode, + ) + } + + // QC results + val qcBySession = groupQcResultsBySession(block.qcResults) + qcBySession.forEachIndexed { i, group -> + val first = group.firstOrNull() ?: return@forEachIndexed + nodes += TraceGraphNode( + id = "$locPrefix-qc-$i-${first.stockInLineId}", + kind = "QC", timestamp = first.created, + sortKey = parseSortKey(first.created, seq++), + warehouseCode = whCode, + ) + } + + // Putaway events + block.putawayEvents.forEachIndexed { i, p -> + if (norm(p.refType) == "TRANSFER") return@forEachIndexed + nodes += TraceGraphNode( + id = "$locPrefix-putaway-$i-${p.inventoryLotLineId}", + kind = "PUTAWAY", timestamp = p.timestamp, + sortKey = parseSortKey(p.timestamp, seq++), + refCode = p.refCode, refId = p.refId, + refType = p.refType, warehouseCode = whCode, + ) + } + + // Movements (non-transfer, non-ADJ, non-TKE) + block.movements.forEachIndexed { i, m -> + val refType = norm(m.refType) + if (refType in MOVEMENT_REF_TYPES_IN_DEDICATED_ARRAYS) return@forEachIndexed + if (isStockTakeLedgerMovement(m.movementType)) return@forEachIndexed + val kind = when { + m.direction == "IN" && refType == "PO" -> "RECEIPT" + m.direction == "IN" && refType == "JO" -> "JO_CREATED" + m.direction == "IN" -> "IN" + m.direction == "OUT" && refType == "DO" -> "DO_OUT" + m.direction == "OUT" -> "OUT" + else -> "OUT" + } + nodes += TraceGraphNode( + id = "$locPrefix-mov-$i-${m.refCode}", + kind = kind, timestamp = m.timestamp, + sortKey = parseSortKey(m.timestamp, seq++), + refCode = m.refCode, refId = m.refId, + warehouseCode = whCode, + ) + } + + // Stock take events + block.stockTakeEvents.forEachIndexed { i, st -> + nodes += TraceGraphNode( + id = "$locPrefix-st-$i-${st.stockTakeCode}", + kind = "STOCK_TAKE", timestamp = st.timestamp, + sortKey = parseSortKey(st.timestamp, seq++), + refCode = st.stockTakeCode, + warehouseCode = whCode, + ) + } + + // Adjustments + block.adjustments.forEachIndexed { i, a -> + nodes += TraceGraphNode( + id = "$locPrefix-adj-$i-${a.refCode}-${a.timestamp}", + kind = "ADJUSTMENT", timestamp = a.timestamp, + sortKey = parseSortKey(a.timestamp, seq++), + refCode = a.refCode, + warehouseCode = whCode, + ) + } + + // Primary buildNodes already emits every TR-* for the traced lot; skip here to avoid duplicates. + + // DO deliveries + block.doDeliveries.forEachIndexed { i, dd -> + nodes += TraceGraphNode( + id = "$locPrefix-dodel-$i-${dd.stockOutLineId}", + kind = "DO_OUT", timestamp = dd.timestamp, + sortKey = parseSortKey(dd.timestamp, seq++), + refCode = dd.pickOrderCode, + warehouseCode = whCode, + ) + } + + block.replenishmentEvents.forEach { r -> + nodes += + TraceGraphNode( + id = "$locPrefix-replenish-${r.replenishmentId}", + kind = "REPLENISHMENT_CREATED", + timestamp = r.timestamp, + sortKey = parseSortKey(r.timestamp, seq++), + refCode = r.sourceDoCode.ifBlank { r.replenishmentCode }, + refId = r.sourceDoId, + replenishmentStockOutLineId = r.stockOutLineId, + warehouseCode = whCode, + ) + } + + // Fail events + block.failEvents.forEachIndexed { i, fe -> + nodes += TraceGraphNode( + id = "$locPrefix-fail-$i-${fe.failId}", + kind = "FAIL", timestamp = fe.recordDate, + sortKey = parseSortKey(fe.recordDate, seq++), + refCode = fe.pickOrderCode, + warehouseCode = whCode, + ) + } + + // Return events + block.returnEvents.forEachIndexed { i, re -> + nodes += TraceGraphNode( + id = "$locPrefix-return-$i-${re.stockOutLineId}", + kind = "RETURN", timestamp = re.timestamp, + sortKey = parseSortKey(re.timestamp, seq++), + refCode = re.refCode, + warehouseCode = whCode, + ) + } + + // Open movements + block.openMovements.forEachIndexed { i, om -> + nodes += TraceGraphNode( + id = "$locPrefix-open-$i-${om.stockInLineId}", + kind = "OPEN", timestamp = om.timestamp, + sortKey = parseSortKey(om.timestamp, seq++), + refCode = om.refCode, + warehouseCode = whCode, + ) + } + } + + return nodes + } + + private fun materialLotKey(itemCode: String, lotNo: String): String = "$itemCode::$lotNo" + + private fun norm(v: String?): String = v?.trim()?.uppercase() ?: "" + + private fun isStockTakeLedgerMovement(movementType: String): Boolean { + val mt = movementType.trim().uppercase() + return mt == "TKE" || mt == "STOCKTAKE" + } + + private fun parseSortKey(ts: String?, seq: Int): Long { + if (ts.isNullOrBlank()) return seq.toLong() + return runCatching { LocalDateTime.parse(ts, DATE_TIME).toEpochSecond(java.time.ZoneOffset.UTC) * 1000 } + .getOrElse { + runCatching { LocalDate.parse(ts, DATE_ONLY).atStartOfDay().toEpochSecond(java.time.ZoneOffset.UTC) * 1000 } + .getOrElse { seq.toLong() } + } + } + + /** Expiry is date-only; sort at end of that calendar day (23:59:59). */ + private fun parseExpirySortKey(expiry: String?, seq: Int): Long { + if (expiry.isNullOrBlank()) return seq.toLong() + return runCatching { + val dateStr = if (expiry.length >= 10) expiry.substring(0, 10) else expiry.trim() + LocalDate.parse(dateStr, DATE_ONLY) + .atTime(23, 59, 59) + .toEpochSecond(java.time.ZoneOffset.UTC) * 1000 + }.getOrElse { seq.toLong() } + } +} diff --git a/src/main/java/com/ffii/fpsms/modules/stock/trace/TraceGraphSemanticsBuilder.kt b/src/main/java/com/ffii/fpsms/modules/stock/trace/TraceGraphSemanticsBuilder.kt new file mode 100644 index 0000000..06f85a5 --- /dev/null +++ b/src/main/java/com/ffii/fpsms/modules/stock/trace/TraceGraphSemanticsBuilder.kt @@ -0,0 +1,1110 @@ +package com.ffii.fpsms.modules.stock.trace + +import com.ffii.fpsms.modules.stock.web.model.ItemLotTraceGraph +import com.ffii.fpsms.modules.stock.web.model.ItemLotTraceGraphEdge +import com.ffii.fpsms.modules.stock.web.model.ItemLotTraceGraphNodeRef +import com.ffii.fpsms.modules.stock.web.model.ItemLotTraceResponse +import org.springframework.stereotype.Component +import java.time.LocalDate +import java.time.LocalDateTime +import java.time.format.DateTimeFormatter + +@Component +class TraceGraphSemanticsBuilder { + fun build(response: ItemLotTraceResponse): ItemLotTraceGraph { + val rawNodes = TraceGraphNodeBuilder.buildNodes(response) + val layout = TraceGraphLayoutSupport.buildLayout(response, rawNodes) + val edges = TraceGraphEdgeSemantics.buildTraceFlowEdgePairs(layout.nodes, layout.phaseOrder) + val nodeRefs = + layout.nodes.map { n -> + ItemLotTraceGraphNodeRef( + key = n.id, + kind = n.kind, + scopeKey = productionScopeKey(n), + refCode = n.refCode, + traceLotNo = n.traceLotNo, + traceItemCode = n.traceItemCode, + warehouseCode = n.warehouseCode, + ) + } + return ItemLotTraceGraph(nodes = nodeRefs, edges = edges) + } + + private fun productionScopeKey(n: TraceGraphLayoutNode): String? { + val lot = n.traceLotNo?.trim() + return when { + n.kind == "PRODUCTION_STEP" || n.kind == "SCRAP" || n.kind == "DEFECT" || n.kind == "MATERIAL_PICK" -> lot?.ifEmpty { null } ?: "__fg__" + lot != null && lot.isNotEmpty() -> lot + else -> null + } + } +} + +internal data class TraceGraphLayout( + val nodes: List, + val phaseOrder: List, +) + +internal data class TraceGraphLayoutNode( + val id: String, + val kind: String, + val timestamp: String?, + val sortKey: Long, + val refCode: String? = null, + val refId: Long? = null, + val traceLotNo: String? = null, + val traceItemCode: String? = null, + val consoCode: String? = null, + val jobOrderCode: String? = null, + val bomProcessId: Long? = null, + val bomProcessSeqNo: Long? = null, + val feedsProductionScopeLotNo: String? = null, + var doGroupId: String? = null, + val refType: String? = null, + val warehouseCode: String? = null, + val transferFromWarehouse: String? = null, + val transferToWarehouse: String? = null, + val replenishmentStockOutLineId: Long? = null, + var phase: String = "", + var column: Int = 0, + var dayKey: String = "", + var laneIndex: Int = 0, + var sequenceIndex: Int = 0, + var branchIndex: Int = 0, + var branchSize: Int = 1, + var dayPhaseStaggerIndex: Int = 0, +) + +internal object TraceGraphLayoutSupport { + private const val DO_GROUP_MIN_SIZE = 3 + private const val PICK_GROUP_MIN_SIZE = 1 + private val DATE_TIME = DateTimeFormatter.ofPattern("yyyy-MM-dd HH:mm:ss") + private val DATE_ONLY = DateTimeFormatter.ISO_LOCAL_DATE + + // 採購 → 收貨 → 品檢 → 上架(轉倉,盤點) → 出倉/提料 → 過期 + private val FG_PHASE_ORDER = + listOf("PURCHASE", "INBOUND", "QC", "PUTAWAY", "WAREHOUSE", "STOCK_TAKE", "OUTBOUND") + + fun buildLayout( + data: ItemLotTraceResponse, + rawNodes: List, + ): TraceGraphLayout { + val hasPrelude = data.joPrelude != null + val hasScrap = data.productionSteps.any { (it.scrapQty + it.defectQty).signum() > 0 } + val hasMaterialProduction = + data.joPrelude?.materialInputs?.let { TraceGraphNodeBuilder.materialInputsHaveProduction(it) } == true + val hasProduction = + data.productionSteps.isNotEmpty() || + data.byproductLots.isNotEmpty() || + hasScrap || + hasMaterialProduction + val phaseOrder = getPhaseOrder(hasPrelude, hasProduction) + val assigned = assignColumns(rawNodes, phaseOrder) + val grouped = applyFlowNodeGrouping(assigned) + return TraceGraphLayout(nodes = grouped, phaseOrder = phaseOrder) + } + + private fun getPhaseOrder(hasPrelude: Boolean, hasProduction: Boolean): List { + if (hasPrelude) return preludePhaseOrder(hasProduction) + val phases = FG_PHASE_ORDER.toMutableList() + if (hasProduction) phases.add(phases.indexOf("QC") + 1, "PRODUCTION") + return phases + } + + // JO: 採購 → 原料收貨 → 品檢 → 上架(原料) → 建立工單 → 原料提料 → 生產步驟 + // → 上架/轉倉/盤點(成品) → 出倉/提料 → 過期/已用完 + private fun preludePhaseOrder(hasProduction: Boolean): List { + val phases = + mutableListOf( + "PURCHASE", + "INBOUND", + "QC", + "PUTAWAY", // material putaway (上架原料) + "MATERIAL_PICK", // includes JO_CREATED (建立工單) + ) + if (hasProduction) phases += "PRODUCTION" + // FG section: FG putaway is mapped to WAREHOUSE by phaseFromKind + phases += listOf("WAREHOUSE", "STOCK_TAKE", "OUTBOUND") + return phases + } + + private fun assignColumns( + sorted: List, + phaseOrder: List, + ): List { + val dayOrder = mutableListOf() + val columnByDayKey = mutableMapOf() + + val withMeta = + sorted.mapIndexed { sequenceIndex, node -> + val dayKey = dayKeyFromTimestamp(node.timestamp) + val column = + columnByDayKey.getOrPut(dayKey) { + dayOrder += dayKey + dayOrder.size - 1 + } + val phase = phaseFromKind(node.kind, phaseOrder, node.traceLotNo) + TraceGraphLayoutNode( + id = node.id, + kind = node.kind, + timestamp = node.timestamp, + sortKey = node.sortKey, + refCode = node.refCode, + refId = node.refId, + traceLotNo = node.traceLotNo, + traceItemCode = node.traceItemCode, + consoCode = node.consoCode, + jobOrderCode = node.jobOrderCode, + bomProcessId = node.bomProcessId, + bomProcessSeqNo = node.bomProcessSeqNo, + feedsProductionScopeLotNo = node.feedsProductionScopeLotNo, + doGroupId = node.doGroupId, + refType = node.refType, + warehouseCode = node.warehouseCode, + transferFromWarehouse = node.transferFromWarehouse, + transferToWarehouse = node.transferToWarehouse, + replenishmentStockOutLineId = node.replenishmentStockOutLineId, + phase = phase, + column = column, + dayKey = dayKey, + laneIndex = phaseOrder.indexOf(phase), + sequenceIndex = sequenceIndex, + ) + } + + val staggerByDay = mutableMapOf>() + withMeta.forEach { node -> + val ordered = + staggerByDay.getOrPut(node.dayKey) { + phasesOnDay(withMeta, node.dayKey, phaseOrder) + } + node.dayPhaseStaggerIndex = ordered.indexOf(node.phase).coerceAtLeast(0) + } + + val groups = mutableMapOf>() + withMeta.forEach { node -> + val key = "${node.column}:${node.laneIndex}" + groups.getOrPut(key) { mutableListOf() } += node + } + groups.values.forEach { list -> + list.sortWith(sortNodesInPhase) + list.forEachIndexed { idx, node -> + node.branchIndex = idx + node.branchSize = list.size + } + } + + return withMeta + } + + private fun applyFlowNodeGrouping(nodes: List): List = + applyMaterialPickGrouping(applyDoOutboundGrouping(nodes)) + + private fun applyDoOutboundGrouping(nodes: List): List { + val doByColumn = mutableMapOf>() + nodes.forEach { node -> + if (node.kind != "DO_OUT" || !node.doGroupId.isNullOrBlank()) return@forEach + doByColumn.getOrPut(node.column) { mutableListOf() } += node + } + + val groupNodes = mutableListOf() + val childGroupIds = mutableMapOf() + + doByColumn.forEach { (column, columnNodes) -> + if (columnNodes.size < DO_GROUP_MIN_SIZE) return@forEach + val groupId = "do-group-col-$column" + val sorted = columnNodes.sortedWith(sortNodesInPhase) + val minSort = sorted.minOf { it.sortKey } + sorted.forEach { child -> childGroupIds[child.id] = groupId } + val first = sorted.first() + groupNodes += + TraceGraphLayoutNode( + id = groupId, + kind = "DO_GROUP", + timestamp = first.timestamp, + sortKey = minSort, + refCode = first.refCode, + phase = first.phase, + column = column, + dayKey = first.dayKey, + laneIndex = first.laneIndex, + sequenceIndex = first.sequenceIndex, + dayPhaseStaggerIndex = first.dayPhaseStaggerIndex, + ) + } + + if (groupNodes.isEmpty()) return nodes + return (nodes.map { node -> + val groupId = childGroupIds[node.id] + if (groupId != null) node.copy(doGroupId = groupId) else node + } + groupNodes).sortedWith(compareBy { it.sortKey }.thenBy { it.id }) + } + + private fun applyMaterialPickGrouping(nodes: List): List { + val picksByKey = mutableMapOf>() + nodes.forEach { node -> + if (node.kind != "MATERIAL_PICK" || !node.doGroupId.isNullOrBlank()) return@forEach + val pickCode = node.refCode?.trim().orEmpty() + if (pickCode.isEmpty()) return@forEach + val key = "${node.column}::$pickCode" + picksByKey.getOrPut(key) { mutableListOf() } += node + } + + val groupNodes = mutableListOf() + val childGroupIds = mutableMapOf() + + picksByKey.forEach { (key, columnNodes) -> + if (columnNodes.size < PICK_GROUP_MIN_SIZE) return@forEach + val groupId = "pick-group-${key.replace(Regex("[^a-zA-Z0-9_-]"), "_")}" + val sorted = columnNodes.sortedWith(sortNodesInPhase) + val minSort = sorted.minOf { it.sortKey } + sorted.forEach { child -> childGroupIds[child.id] = groupId } + val first = sorted.first() + groupNodes += + TraceGraphLayoutNode( + id = groupId, + kind = "PICK_GROUP", + timestamp = first.timestamp, + sortKey = minSort, + refCode = first.refCode, + refId = first.refId, + consoCode = first.consoCode, + jobOrderCode = first.jobOrderCode, + phase = first.phase, + column = first.column, + dayKey = first.dayKey, + laneIndex = first.laneIndex, + sequenceIndex = first.sequenceIndex, + dayPhaseStaggerIndex = first.dayPhaseStaggerIndex, + ) + } + + if (groupNodes.isEmpty()) return nodes + return (nodes.map { node -> + val groupId = childGroupIds[node.id] + if (groupId != null) node.copy(doGroupId = groupId) else node + } + groupNodes).sortedWith(compareBy { it.sortKey }.thenBy { it.id }) + } + + private fun phasesOnDay( + nodes: List, + dayKey: String, + phaseOrder: List, + ): List { + val present = nodes.filter { it.dayKey == dayKey }.map { it.phase }.toSet() + return sortPhasesByEarliestEvent(nodes, present.toList(), phaseOrder, dayKey) + } + + fun dayKeyFromTimestamp(timestamp: String?): String { + if (timestamp.isNullOrBlank()) return "—" + return runCatching { LocalDateTime.parse(timestamp, DATE_TIME).toLocalDate().format(DATE_ONLY) } + .getOrElse { + timestamp.take(10).ifBlank { "—" } + } + } + + private fun phaseFromKind( + kind: String, + phaseOrder: List, + traceLotNo: String?, + ): String { + val materialPrelude = !traceLotNo.isNullOrBlank() + val isPrelude = phaseOrder.contains("MATERIAL_PICK") + return when (kind) { + "MATERIAL_IN" -> "INBOUND" + "JO_CREATED" -> { + // Keep 建立工單 in 工單提料 lane with pick groups when prelude is shown. + if (phaseOrder.contains("MATERIAL_PICK")) "MATERIAL_PICK" + else "INBOUND" + } + "MATERIAL_QC", "QC", "FAIL" -> "QC" + "MATERIAL_PICK", "PICK_GROUP" -> "MATERIAL_PICK" + "PRODUCTION_STEP", "BYPRODUCT", "SCRAP", "DEFECT" -> "PRODUCTION" + "OPEN" -> if (isPrelude) "PUTAWAY" else "INBOUND" + "DO_OUT", "DO_GROUP", "RETURN", "REPLENISHMENT_CREATED" -> "OUTBOUND" + "REPACK" -> "WAREHOUSE" + "PURCHASE" -> "PURCHASE" + "RECEIPT", "IN" -> "INBOUND" + "PUTAWAY" -> { + // Material putaways (with traceLotNo) stay in the material section. + // FG putaways in prelude go to WAREHOUSE (after PRODUCTION). + if (!materialPrelude && isPrelude) "WAREHOUSE" + else "PUTAWAY" + } + "TRANSFER", "ADJUSTMENT" -> "WAREHOUSE" + "OUT" -> "OUTBOUND" + "STOCK_TAKE" -> "STOCK_TAKE" + "EXPIRED", "DEPLETED" -> "OUTBOUND" + else -> if (phaseOrder.contains("WAREHOUSE")) "WAREHOUSE" else phaseOrder.first() + } + } + + val sortNodesInPhase: Comparator = + Comparator { a, b -> + if (a.sortKey != b.sortKey) { + a.sortKey.compareTo(b.sortKey) + } else { + a.sequenceIndex.compareTo(b.sequenceIndex) + } + } + + fun sortPhasesByEarliestEvent( + nodes: List, + phases: List, + phaseOrder: List, + dayKey: String? = null, + ): List = + phases.sortedWith { a, b -> + val ta = earliestSortKey(nodes, a, dayKey) + val tb = earliestSortKey(nodes, b, dayKey) + if (ta != tb) ta.compareTo(tb) else phaseOrder.indexOf(a).compareTo(phaseOrder.indexOf(b)) + } + + fun sortFgPhasesForCrossDayFlow( + nodes: List, + phases: List, + phaseOrder: List, + ): List = + phases.sortedWith { a, b -> + val nodeA = earliestNodeInPhase(nodes, a, null) + val nodeB = earliestNodeInPhase(nodes, b, null) + val ta = nodeA?.sortKey ?: Long.MAX_VALUE + val tb = nodeB?.sortKey ?: Long.MAX_VALUE + val sameDay = + nodeA != null && + nodeB != null && + nodeA.dayKey != "—" && + nodeA.dayKey == nodeB.dayKey + when { + sameDay -> phaseOrder.indexOf(a).compareTo(phaseOrder.indexOf(b)) + ta != tb -> ta.compareTo(tb) + else -> phaseOrder.indexOf(a).compareTo(phaseOrder.indexOf(b)) + } + } + + private fun earliestNodeInPhase( + nodes: List, + phase: String, + dayKey: String?, + ): TraceGraphLayoutNode? { + var best: TraceGraphLayoutNode? = null + nodes.forEach { n -> + if (n.phase != phase) return@forEach + if (dayKey != null && n.dayKey != dayKey) return@forEach + if (best == null || n.sortKey < best!!.sortKey) best = n + } + return best + } + + private fun earliestSortKey( + nodes: List, + phase: String, + dayKey: String?, + ): Long = earliestNodeInPhase(nodes, phase, dayKey)?.sortKey ?: Long.MAX_VALUE +} + +internal object TraceGraphEdgeSemantics { + private val NO_FLOW_EDGE_KINDS = setOf("EXPIRED", "DEPLETED", "REPACK", "BYPRODUCT") + private val WAREHOUSE_FLOW_KINDS = setOf("TRANSFER", "ADJUSTMENT") + private val INVENTORY_STATE_KINDS = + setOf( + "PUTAWAY", + "TRANSFER", + "ADJUSTMENT", + "OUT", + "DO_OUT", + "DO_GROUP", + "STOCK_TAKE", + "RETURN", + "RECEIPT", + "IN", + "OPEN", + ) + private val PRELUDE_MATERIAL_PHASES = + listOf("PURCHASE", "INBOUND", "QC", "PUTAWAY", "MATERIAL_PICK", "PRODUCTION") + + private fun shouldSkipTraceFlowEdge( + from: TraceGraphLayoutNode, + to: TraceGraphLayoutNode, + ): Boolean { + if (from.kind == "JO_CREATED" && !isMaterialPreludeNode(from)) { + if (to.kind == "MATERIAL_PICK" || to.kind == "PICK_GROUP") return false + return true + } + if ( + to.kind == "PRODUCTION_STEP" && + from.kind != "MATERIAL_PICK" && + from.kind != "PICK_GROUP" && + from.kind != "PRODUCTION_STEP" + ) { + return true + } + if ( + from.kind in listOf("QC", "FAIL", "MATERIAL_QC") && + to.kind == "PRODUCTION_STEP" + ) { + return true + } + if (from.kind == "PRODUCTION_STEP" && to.kind == "JO_CREATED") { + return true + } + if (from.kind == "PUTAWAY" && to.kind in listOf("QC", "FAIL")) { + return true + } + if ( + from.kind == "PRODUCTION_STEP" && + to.kind in + listOf( + "MATERIAL_PICK", + "PICK_GROUP", + "DO_OUT", + "DO_GROUP", + "JO_OUT", + "PO_OUT", + "OUT", + ) + ) { + return true + } + if (to.kind == "REPLENISHMENT_CREATED") { + return from.kind !in + listOf("PUTAWAY", "TRANSFER", "ADJUSTMENT", "OPEN", "RECEIPT", "IN") + } + if (from.kind == "REPLENISHMENT_CREATED") { + if (to.kind == "DO_GROUP") return false + if (to.kind != "DO_OUT") return true + val targetSolId = from.replenishmentStockOutLineId + if (targetSolId != null && + (to.id == "do-$targetSolId" || to.id.endsWith("-$targetSolId")) + ) { + return false + } + val doCode = from.refCode?.trim().orEmpty() + if (doCode.isNotEmpty() && doOutMatchesDeliveryCode(to, doCode)) return false + return true + } + return false + } + + private fun doOutMatchesDeliveryCode(doOut: TraceGraphLayoutNode, doCode: String): Boolean { + val code = doCode.trim() + if (code.isEmpty()) return false + if (doOut.refCode?.trim() == code) return true + return false + } + + private fun normWh(w: String?): String = w?.trim()?.uppercase() ?: "" + + private fun normRefType(refType: String?): String = refType?.trim()?.uppercase() ?: "" + + private fun warehouseCodesMatch(a: String?, b: String?): Boolean { + val x = normWh(a) + val y = normWh(b) + if (x.isEmpty() || y.isEmpty()) return false + if (x == y) return true + return x.startsWith("$y-") || y.startsWith("$x-") + } + + private fun pickSourcePutawayForTransfer( + putaways: List, + transfer: TraceGraphLayoutNode, + ): TraceGraphLayoutNode? { + val fromWh = normWh(transfer.transferFromWarehouse) + val candidates = putaways.filter { it.sortKey <= transfer.sortKey } + val whMatched = + if (fromWh.isNotEmpty()) { + candidates.filter { warehouseCodesMatch(it.warehouseCode, fromWh) } + } else { + candidates + } + if (whMatched.isEmpty()) return null + val nonTransfer = whMatched.filter { normRefType(it.refType) != "TRANSFER" } + val pool = if (nonTransfer.isNotEmpty()) nonTransfer else whMatched + return pool.maxWithOrNull(TraceGraphLayoutSupport.sortNodesInPhase) + } + + private fun pickDestPutawayForTransfer( + putaways: List, + transfer: TraceGraphLayoutNode, + ): TraceGraphLayoutNode? { + val toWh = normWh(transfer.transferToWarehouse) + val candidates = putaways.filter { it.sortKey >= transfer.sortKey } + val whMatched = + if (toWh.isNotEmpty()) { + candidates.filter { warehouseCodesMatch(it.warehouseCode, toWh) } + } else { + candidates + } + if (whMatched.isEmpty()) return null + val transferPutaways = whMatched.filter { normRefType(it.refType) == "TRANSFER" } + val pool = if (transferPutaways.isNotEmpty()) transferPutaways else whMatched + return pool.minWithOrNull(TraceGraphLayoutSupport.sortNodesInPhase) + } + + private fun connectPutawayToWarehousePhase( + fromList: List, + toList: List, + add: (TraceGraphLayoutNode, TraceGraphLayoutNode) -> Unit, + ) { + val transfers = toList.filter { it.kind == "TRANSFER" } + if (transfers.isNotEmpty()) { + transfers.forEach { tr -> + pickSourcePutawayForTransfer(fromList, tr)?.let { src -> add(src, tr) } + } + return + } + add(fromList.last(), toList.first()) + } + + private fun buildTransferPutawayEdges( + nodes: List, + add: (TraceGraphLayoutNode, TraceGraphLayoutNode) -> Unit, + ) { + val putaways = nodes.filter { it.kind == "PUTAWAY" && !isMaterialPreludeNode(it) } + val transfers = nodes.filter { it.kind == "TRANSFER" } + transfers.forEach { tr -> + pickSourcePutawayForTransfer(putaways, tr)?.let { src -> add(src, tr) } + } + } + + fun buildTraceFlowEdgePairs( + nodes: List, + phaseOrder: List, + ): List { + val pairs = mutableListOf() + val seen = mutableSetOf() + + fun add(from: TraceGraphLayoutNode, to: TraceGraphLayoutNode) { + if (NO_FLOW_EDGE_KINDS.contains(from.kind) || NO_FLOW_EDGE_KINDS.contains(to.kind)) return + if (shouldSkipTraceFlowEdge(from, to)) return + val key = "${from.id}->${to.id}" + if (!seen.add(key)) return + pairs += ItemLotTraceGraphEdge(fromKey = from.id, toKey = to.id) + } + + val byDay = mutableMapOf>() + nodes.forEach { n -> byDay.getOrPut(n.dayKey) { mutableListOf() } += n } + + val sortedDays = + byDay.keys.sortedWith { a, b -> + when { + a == "—" -> 1 + b == "—" -> -1 + else -> a.compareTo(b) + } + } + + fun nodesInPhase(dayNodes: List, phase: String) = + dayNodes + .filter { it.phase == phase && !NO_FLOW_EDGE_KINDS.contains(it.kind) && !isDoGroupChild(it) } + .sortedWith(TraceGraphLayoutSupport.sortNodesInPhase) + + for (dayKey in sortedDays) { + val dayNodes = byDay[dayKey] ?: continue + val phasesPresent = + TraceGraphLayoutSupport.sortPhasesByEarliestEvent( + dayNodes, + phaseOrder.filter { p -> nodesInPhase(dayNodes, p).isNotEmpty() }, + phaseOrder, + dayKey, + ) + + for (i in 0 until phasesPresent.size - 1) { + val fromList = nodesInPhase(dayNodes, phasesPresent[i]) + val toList = nodesInPhase(dayNodes, phasesPresent[i + 1]) + if (fromList.isEmpty() || toList.isEmpty()) continue + if (isMaterialPreludeNode(fromList.last()) || isMaterialPreludeNode(toList.first())) continue + // STOCK_TAKE now precedes OUTBOUND in phase order; edge flows naturally + if (phasesPresent[i] == "PUTAWAY" && phasesPresent[i + 1] == "WAREHOUSE") { + connectPutawayToWarehousePhase(fromList, toList, ::add) + continue + } + add(fromList.last(), toList.first()) + } + } + + buildMaterialLotFlowEdgePairs(nodes, phaseOrder, ::add) + buildProductionStepChainEdges(nodes, ::add) + buildProductionToFgQcEdges(nodes, ::add) + buildJoCreatedToMaterialPickEdges(nodes, ::add) + buildReplenishmentToDoOutEdges(nodes, ::add) + buildMaterialPickToProductionStepEdges(nodes, ::add) + buildProductionStepLossEdges(nodes, ::add) + buildFgLotFlowEdgePairs(nodes, phaseOrder, ::add) + buildPutawayToOutboundEdges(nodes, ::add) + buildPutawayToStockTakeEdges(nodes, ::add) + buildTransferPutawayEdges(nodes, ::add) + connectWarehousePhaseChainEdges(nodes, ::add) + connectStockTakeInboundEdges(nodes, ::add) + + return pairs + } + + private fun buildMaterialLotFlowEdgePairs( + nodes: List, + phaseOrder: List, + add: (TraceGraphLayoutNode, TraceGraphLayoutNode) -> Unit, + ) { + if (!phaseOrder.contains("MATERIAL_PICK")) return + val preludePhases = PRELUDE_MATERIAL_PHASES.filter { phaseOrder.contains(it) } + val byLot = mutableMapOf>() + + nodes.forEach { n -> + if (!isMaterialPreludeNode(n)) return@forEach + val key = materialLotKey(n) ?: return@forEach + byLot.getOrPut(key) { mutableListOf() } += n + } + + fun nodesInPhase(lotNodes: List, phase: String) = + lotNodes + .filter { it.phase == phase && !NO_FLOW_EDGE_KINDS.contains(it.kind) } + .sortedWith(TraceGraphLayoutSupport.sortNodesInPhase) + + byLot.values.forEach { lotNodes -> + val phasesPresent = + TraceGraphLayoutSupport.sortPhasesByEarliestEvent( + lotNodes, + preludePhases.filter { p -> nodesInPhase(lotNodes, p).isNotEmpty() }, + phaseOrder, + ) + + for (i in 0 until phasesPresent.size - 1) { + val fromPhase = phasesPresent[i] + val toPhase = phasesPresent[i + 1] + val fromList = nodesInPhase(lotNodes, fromPhase) + val toList = nodesInPhase(lotNodes, toPhase) + if (fromList.isEmpty() || toList.isEmpty()) continue + + if (toPhase == "MATERIAL_PICK") { + val upstream = fromList.last() + val seenPickTargets = mutableSetOf() + toList.forEach { pick -> + val target = resolvePickFlowTarget(pick, nodes) + if (!seenPickTargets.add(target.id)) return@forEach + add(upstream, target) + } + } else { + add(fromList.last(), toList.first()) + } + } + } + } + + private fun buildFgLotFlowEdgePairs( + nodes: List, + phaseOrder: List, + add: (TraceGraphLayoutNode, TraceGraphLayoutNode) -> Unit, + ) { + val fgPhases = phaseOrder.filter { it != "MATERIAL_PICK" } + val fgNodes = + nodes.filter { + !isMaterialPreludeNode(it) && + !NO_FLOW_EDGE_KINDS.contains(it.kind) && + !isDoGroupChild(it) + } + if (fgNodes.size < 2) return + + fun nodesInPhase(phase: String) = + fgNodes.filter { it.phase == phase }.sortedWith(TraceGraphLayoutSupport.sortNodesInPhase) + + val phasesPresent = + TraceGraphLayoutSupport.sortFgPhasesForCrossDayFlow( + fgNodes, + fgPhases.filter { p -> nodesInPhase(p).isNotEmpty() }, + phaseOrder, + ) + + fun pickUpstreamForTarget( + fromList: List, + target: TraceGraphLayoutNode, + ): TraceGraphLayoutNode { + val eligible = fromList.filter { it.column <= target.column || it.sortKey <= target.sortKey } + return eligible.lastOrNull() ?: fromList.last() + } + + for (i in 0 until phasesPresent.size - 1) { + val fromPhase = phasesPresent[i] + val toPhase = phasesPresent[i + 1] + val fromList = nodesInPhase(fromPhase) + val toList = nodesInPhase(toPhase) + if (fromList.isEmpty() || toList.isEmpty()) continue + + when { + fromPhase == "OUTBOUND" && toPhase == "STOCK_TAKE" -> Unit + fromPhase == "OUTBOUND" && + toPhase in listOf("WAREHOUSE", "PUTAWAY", "STOCK_TAKE") -> Unit + fromPhase == "PUTAWAY" && toPhase == "WAREHOUSE" -> + connectPutawayToWarehousePhase(fromList, toList, add) + toPhase == "OUTBOUND" && toList.size > 1 -> + toList.forEach { out -> add(pickUpstreamForTarget(fromList, out), out) } + toList.size > 1 -> { + val targets = + if (toPhase == "PUTAWAY") { + val nonTransfer = toList.filter { normRefType(it.refType) != "TRANSFER" } + if (nonTransfer.isNotEmpty()) nonTransfer else toList + } else { + toList + } + targets.forEach { to -> add(pickUpstreamForTarget(fromList, to), to) } + } + else -> add(fromList.last(), toList.first()) + } + } + } + + private fun buildMaterialPickToProductionStepEdges( + nodes: List, + add: (TraceGraphLayoutNode, TraceGraphLayoutNode) -> Unit, + ) { + val productionByScopeAndProcess = mutableMapOf>() + nodes.forEach { n -> + if (n.kind != "PRODUCTION_STEP" || n.bomProcessId == null) return@forEach + val key = "${productionScopeKey(n)}::${n.bomProcessId}" + productionByScopeAndProcess.getOrPut(key) { mutableListOf() } += n + } + + fun connectPickSource(source: TraceGraphLayoutNode, bomProcessId: Long) { + val scope = source.feedsProductionScopeLotNo?.trim()?.ifEmpty { null } ?: "__fg__" + val key = "$scope::$bomProcessId" + val targets = productionByScopeAndProcess[key] ?: return + if (targets.isEmpty()) return + val target = targets.sortedWith(TraceGraphLayoutSupport.sortNodesInPhase).first() + add(source, target) + } + + val seenGroupProcess = mutableSetOf() + nodes.filter { it.kind == "PICK_GROUP" }.forEach { group -> + val children = + nodes.filter { + it.doGroupId == group.id && it.kind == "MATERIAL_PICK" && it.bomProcessId != null + } + children.forEach { child -> + val edgeKey = "${group.id}::${child.bomProcessId}" + if (!seenGroupProcess.add(edgeKey)) return@forEach + connectPickSource( + group.copy(feedsProductionScopeLotNo = child.feedsProductionScopeLotNo), + child.bomProcessId!!, + ) + } + } + + nodes + .filter { it.kind == "MATERIAL_PICK" && it.doGroupId.isNullOrBlank() && it.bomProcessId != null } + .forEach { pick -> connectPickSource(pick, pick.bomProcessId!!) } + } + + private fun buildProductionToFgQcEdges( + nodes: List, + add: (TraceGraphLayoutNode, TraceGraphLayoutNode) -> Unit, + ) { + val prodSteps = + nodes.filter { + it.kind == "PRODUCTION_STEP" && !isMaterialPreludeNode(it) && !isDoGroupChild(it) + } + val qcNodes = nodes.filter { it.kind == "QC" && !isMaterialPreludeNode(it) } + if (prodSteps.isEmpty() || qcNodes.isEmpty()) return + + val lastProd = prodSteps.sortedWith(TraceGraphLayoutSupport.sortNodesInPhase).last() + val firstQc = qcNodes.sortedWith(TraceGraphLayoutSupport.sortNodesInPhase).first() + if (firstQc.sortKey >= lastProd.sortKey) add(lastProd, firstQc) + } + + private fun buildProductionStepChainEdges( + nodes: List, + add: (TraceGraphLayoutNode, TraceGraphLayoutNode) -> Unit, + ) { + val byScope = mutableMapOf>() + nodes + .filter { it.kind == "PRODUCTION_STEP" && !isDoGroupChild(it) } + .forEach { n -> + byScope.getOrPut(productionScopeKey(n)) { mutableListOf() } += n + } + byScope.values.forEach { list -> + val sorted = + list.sortedWith { a, b -> + val sa = a.bomProcessSeqNo + val sb = b.bomProcessSeqNo + when { + sa != null && sb != null && sa != sb -> sa.compareTo(sb) + sa != null && sb == null -> -1 + sa == null && sb != null -> 1 + else -> TraceGraphLayoutSupport.sortNodesInPhase.compare(a, b) + } + } + for (i in 0 until sorted.size - 1) { + add(sorted[i], sorted[i + 1]) + } + } + } + + private fun buildProductionStepLossEdges( + nodes: List, + add: (TraceGraphLayoutNode, TraceGraphLayoutNode) -> Unit, + ) { + val stepNodes = nodes.filter { it.kind == "PRODUCTION_STEP" && !isDoGroupChild(it) } + val scrapNodes = nodes.filter { it.kind == "SCRAP" && !isDoGroupChild(it) } + val defectNodes = nodes.filter { it.kind == "DEFECT" && !isDoGroupChild(it) } + + stepNodes.forEach { step -> + val processLineId = step.id.removePrefix("prod-step-") + scrapNodes.filter { it.id == "scrap-$processLineId" }.forEach { scrap -> + add(step, scrap) + } + defectNodes.filter { it.id == "defect-$processLineId" }.forEach { defect -> + add(step, defect) + } + } + } + + private fun joCodeForPickTarget( + target: TraceGraphLayoutNode, + nodes: List, + ): String? { + // Prefer jobOrderCode — po.consoCode is often a TI ticket or blank, and refCode is PI-*. + fun fromNode(n: TraceGraphLayoutNode): String? { + val jo = n.jobOrderCode?.trim()?.ifEmpty { null } + if (jo != null) return jo + val conso = n.consoCode?.trim()?.ifEmpty { null } ?: return null + return if (conso.startsWith("JO-", ignoreCase = true) || conso.startsWith("JO_")) conso else null + } + + if (target.kind == "PICK_GROUP") { + fromNode(target)?.let { return it } + return nodes.firstOrNull { it.doGroupId == target.id && fromNode(it) != null }?.let { fromNode(it) } + } + if (target.kind == "MATERIAL_PICK") { + return fromNode(target) + } + return null + } + + private fun buildPutawayToOutboundEdges( + nodes: List, + add: (TraceGraphLayoutNode, TraceGraphLayoutNode) -> Unit, + ) { + val putaways = + nodes + .filter { it.kind == "PUTAWAY" && !isMaterialPreludeNode(it) } + .sortedWith(TraceGraphLayoutSupport.sortNodesInPhase) + if (putaways.isEmpty()) return + + val outboundTargets = + nodes.filter { + !isDoGroupChild(it) && + it.kind in listOf("DO_OUT", "DO_GROUP", "JO_OUT", "PO_OUT", "OUT", "REPLENISHMENT_CREATED") + } + if (outboundTargets.isEmpty()) return + + outboundTargets.forEach { target -> + val from = pickUpstreamForPutaway(putaways, target) + if (!shouldSkipTraceFlowEdge(from, target)) add(from, target) + } + } + + private fun buildPutawayToStockTakeEdges( + nodes: List, + add: (TraceGraphLayoutNode, TraceGraphLayoutNode) -> Unit, + ) { + val putaways = + nodes + .filter { it.kind == "PUTAWAY" && !isMaterialPreludeNode(it) } + .sortedWith(TraceGraphLayoutSupport.sortNodesInPhase) + if (putaways.isEmpty()) return + + nodes.filter { it.kind == "STOCK_TAKE" && !isDoGroupChild(it) }.forEach { stockTake -> + val from = pickUpstreamForPutaway(putaways, stockTake) + if (!shouldSkipTraceFlowEdge(from, stockTake)) add(from, stockTake) + } + } + + private fun pickUpstreamForPutaway( + fromList: List, + target: TraceGraphLayoutNode, + ): TraceGraphLayoutNode { + val eligible = fromList.filter { it.column <= target.column || it.sortKey <= target.sortKey } + return eligible.lastOrNull() ?: fromList.last() + } + + private fun resolveDoOutFlowTarget( + doOut: TraceGraphLayoutNode, + nodes: List, + ): TraceGraphLayoutNode { + val groupId = doOut.doGroupId?.trim()?.ifEmpty { null } ?: return doOut + return nodes.firstOrNull { it.id == groupId } ?: doOut + } + + private fun findDoOutForReplenishment( + replenishment: TraceGraphLayoutNode, + nodes: List, + ): TraceGraphLayoutNode? { + val doOuts = nodes.filter { it.kind == "DO_OUT" } + val stockOutLineId = replenishment.replenishmentStockOutLineId + if (stockOutLineId != null) { + val bySol = + doOuts.firstOrNull { + it.id == "do-$stockOutLineId" || it.id.endsWith("-$stockOutLineId") + } + if (bySol != null) return bySol + } + val doCode = replenishment.refCode?.trim().orEmpty() + if (doCode.isEmpty()) return null + return doOuts.firstOrNull { doOutMatchesDeliveryCode(it, doCode) } + } + + private fun buildReplenishmentToDoOutEdges( + nodes: List, + add: (TraceGraphLayoutNode, TraceGraphLayoutNode) -> Unit, + ) { + nodes.filter { it.kind == "REPLENISHMENT_CREATED" }.forEach { replenishment -> + val doOut = findDoOutForReplenishment(replenishment, nodes) ?: return@forEach + add(replenishment, resolveDoOutFlowTarget(doOut, nodes)) + } + } + + private fun buildJoCreatedToMaterialPickEdges( + nodes: List, + add: (TraceGraphLayoutNode, TraceGraphLayoutNode) -> Unit, + ) { + // Include material-scoped JO_CREATED so nested JO stock-in links to that JO’s 提料. + val joCreatedNodes = nodes.filter { it.kind == "JO_CREATED" } + if (joCreatedNodes.isEmpty()) return + + val pickTargets = + nodes.filter { + !isDoGroupChild(it) && + (it.kind == "PICK_GROUP" || (it.kind == "MATERIAL_PICK" && it.doGroupId.isNullOrBlank())) + } + if (pickTargets.isEmpty()) return + + pickTargets.forEach { target -> + val pickJoCode = joCodeForPickTarget(target, nodes) + val matches = + joCreatedNodes.filter { jo -> + val joCode = jo.refCode?.trim() + !joCode.isNullOrEmpty() && joCode == pickJoCode + } + if (matches.isEmpty()) return@forEach + + val feedLot = + when (target.kind) { + "PICK_GROUP" -> + nodes + .firstOrNull { + it.doGroupId == target.id && !it.feedsProductionScopeLotNo.isNullOrBlank() + } + ?.feedsProductionScopeLotNo + ?.trim() + .orEmpty() + else -> target.feedsProductionScopeLotNo?.trim().orEmpty() + } + + val pool = + if (feedLot.isNotEmpty()) { + val scoped = matches.filter { it.traceLotNo?.trim() == feedLot } + if (scoped.isNotEmpty()) scoped else matches + } else { + // FG 工單提料: prefer FG 建立工單 (no material lot), not nested JO output. + val fg = matches.filter { it.traceLotNo.isNullOrBlank() } + if (fg.isNotEmpty()) fg else matches + } + val joCreated = pool.sortedWith(TraceGraphLayoutSupport.sortNodesInPhase).first() + add(joCreated, target) + } + } + + private fun connectWarehousePhaseChainEdges( + nodes: List, + add: (TraceGraphLayoutNode, TraceGraphLayoutNode) -> Unit, + ) { + val chainNodes = + nodes + .filter { + !isMaterialPreludeNode(it) && + !NO_FLOW_EDGE_KINDS.contains(it.kind) && + !isDoGroupChild(it) + } + .sortedWith(TraceGraphLayoutSupport.sortNodesInPhase) + + chainNodes.filter { it.kind in WAREHOUSE_FLOW_KINDS }.forEach { warehouseNode -> + if (warehouseNode.kind == "TRANSFER") { + val putaways = + chainNodes.filter { it.kind == "PUTAWAY" && !isMaterialPreludeNode(it) } + pickSourcePutawayForTransfer(putaways, warehouseNode)?.let { src -> + add(src, warehouseNode) + return@forEach + } + } + + val predecessors = chainNodes.filter { it.sortKey < warehouseNode.sortKey } + val warehousePredecessors = predecessors.filter { it.kind in WAREHOUSE_FLOW_KINDS } + if (warehousePredecessors.isNotEmpty()) { + add(warehousePredecessors.last(), warehouseNode) + return@forEach + } + + val inventoryPredecessors = predecessors.filter { it.kind in INVENTORY_STATE_KINDS } + val lastBefore = + inventoryPredecessors.lastOrNull() + ?: predecessors.lastOrNull() + if (lastBefore != null) add(lastBefore, warehouseNode) + } + } + + private fun connectStockTakeInboundEdges( + nodes: List, + add: (TraceGraphLayoutNode, TraceGraphLayoutNode) -> Unit, + ) { + val chainNodes = + nodes + .filter { + !isMaterialPreludeNode(it) && + !NO_FLOW_EDGE_KINDS.contains(it.kind) && + !isDoGroupChild(it) + } + .sortedWith(TraceGraphLayoutSupport.sortNodesInPhase) + + val inventoryPredecessorKinds = + setOf("PUTAWAY", "TRANSFER", "ADJUSTMENT", "REPACK", "RECEIPT", "IN", "OPEN") + + chainNodes.filter { it.kind == "STOCK_TAKE" }.forEach { stockTake -> + val predecessors = chainNodes.filter { it.kind != "STOCK_TAKE" && it.sortKey < stockTake.sortKey } + if (predecessors.isEmpty()) return@forEach + + val putawayPredecessors = + predecessors.filter { it.kind == "PUTAWAY" && !isMaterialPreludeNode(it) } + val inventoryPredecessors = predecessors.filter { it.kind in inventoryPredecessorKinds } + val from = + putawayPredecessors.lastOrNull() + ?: inventoryPredecessors.lastOrNull() + ?: predecessors.lastOrNull() + if (from != null) add(from, stockTake) + } + } + + private fun isMaterialPreludeNode(n: TraceGraphLayoutNode): Boolean = + n.kind in listOf("MATERIAL_IN", "MATERIAL_QC", "MATERIAL_PICK") || + (n.kind == "JO_CREATED" && !n.traceLotNo.isNullOrBlank()) || + (n.kind == "PUTAWAY" && !n.traceLotNo.isNullOrBlank()) || + (n.kind == "PRODUCTION_STEP" && !n.traceLotNo.isNullOrBlank()) || + ((n.kind == "SCRAP" || n.kind == "DEFECT") && !n.traceLotNo.isNullOrBlank()) || + ((n.kind == "PURCHASE" || n.kind == "RECEIPT") && !n.traceLotNo.isNullOrBlank()) + + private fun materialLotKey(n: TraceGraphLayoutNode): String? { + val lot = n.traceLotNo?.trim()?.ifEmpty { null } ?: return null + val item = n.traceItemCode?.trim().orEmpty() + return "$item::$lot" + } + + private fun resolvePickFlowTarget( + pick: TraceGraphLayoutNode, + nodes: List, + ): TraceGraphLayoutNode { + val groupId = pick.doGroupId?.trim()?.ifEmpty { null } + if (groupId == null) return pick + return nodes.firstOrNull { it.id == groupId } ?: pick + } + + private fun productionScopeKey(n: TraceGraphLayoutNode): String = n.traceLotNo?.trim()?.ifEmpty { null } ?: "__fg__" + + private fun isDoGroupChild(n: TraceGraphLayoutNode): Boolean = !n.doGroupId.isNullOrBlank() +} diff --git a/src/main/java/com/ffii/fpsms/modules/stock/trace/TraceInboundLoader.kt b/src/main/java/com/ffii/fpsms/modules/stock/trace/TraceInboundLoader.kt new file mode 100644 index 0000000..0b62350 --- /dev/null +++ b/src/main/java/com/ffii/fpsms/modules/stock/trace/TraceInboundLoader.kt @@ -0,0 +1,396 @@ +package com.ffii.fpsms.modules.stock.trace + +import com.ffii.core.support.JdbcDao +import com.ffii.fpsms.modules.stock.web.model.* +import org.springframework.stereotype.Component + +@Component +open class TraceInboundLoader( + private val jdbcDao: JdbcDao, + private val sql: TraceSqlSupport, +) { + open fun loadOrigins(inventoryLotId: Long): List = + loadOriginsForLots(listOf(inventoryLotId)) + + open fun loadOriginsForLots(inventoryLotIds: List): List { + if (inventoryLotIds.isEmpty()) return emptyList() + val query = """ + SELECT + sil.inventoryLotId AS inventoryLotId, + sil.id AS stockInLineId, + CASE + WHEN sil.purchaseOrderId IS NOT NULL THEN 'PO' + WHEN sil.jobOrderId IS NOT NULL THEN 'JO' + WHEN sil.stockTakeLineId IS NOT NULL THEN 'TKE' + WHEN sil.stockTransferId IS NOT NULL THEN 'TRANSFER' + WHEN UPPER(COALESCE(sil.type, '')) IN ('ADJ', 'OPEN') THEN UPPER(sil.type) + ELSE COALESCE(UPPER(sil.type), 'OTHER') + END AS type, + COALESCE(po.code, jo.code, st.code, si.code, '') AS refCode, + COALESCE(po.id, jo.id, st.id, si.id) AS refId, + COALESCE(sp_si.code, sp_po.code, '') AS supplierCode, + COALESCE(sp_si.name, sp_po.name, '') AS supplierName, + COALESCE(sil.dnNo, '') AS dnNo, + sil.receiptDate AS receiptDate, + COALESCE(sil.acceptedQty, 0) AS acceptedQty, + COALESCE(sil.status, '') AS status + FROM stock_in_line sil + LEFT JOIN stock_in si ON sil.stockInId = si.id AND si.deleted = 0 + LEFT JOIN purchase_order po ON sil.purchaseOrderId = po.id AND po.deleted = 0 + LEFT JOIN job_order jo ON sil.jobOrderId = jo.id AND jo.deleted = 0 + LEFT JOIN stock_take_line stl ON sil.stockTakeLineId = stl.id AND stl.deleted = 0 + LEFT JOIN stock_take st ON stl.stockTakeId = st.id AND st.deleted = 0 + LEFT JOIN shop sp_si ON si.supplierId = sp_si.id AND sp_si.deleted = 0 + LEFT JOIN shop sp_po ON po.supplierId = sp_po.id AND sp_po.deleted = 0 + WHERE sil.inventoryLotId IN (:inventoryLotIds) AND sil.deleted = 0 + ORDER BY sil.receiptDate DESC, sil.id DESC + """.trimIndent() + return jdbcDao.queryForList(query, mapOf("inventoryLotIds" to inventoryLotIds)).map { row -> + ItemLotTraceOrigin( + stockInLineId = sql.longVal(row["stockInLineId"]) ?: 0L, + type = sql.str(row["type"]), + refCode = sql.str(row["refCode"]), + refId = sql.longVal(row["refId"]), + supplierCode = sql.str(row["supplierCode"]), + supplierName = sql.str(row["supplierName"]), + dnNo = sql.str(row["dnNo"]), + receiptDate = sql.formatDate(row["receiptDate"]), + acceptedQty = sql.decimal(row["acceptedQty"]), + status = sql.str(row["status"]), + ) + } + } + + open fun loadPurchaseEvents(stockInLineIds: List): List { + if (stockInLineIds.isEmpty()) return emptyList() + val query = """ + SELECT + pol.id AS purchaseOrderLineId, + po.id AS purchaseOrderId, + COALESCE(po.code, '') AS purchaseOrderCode, + COALESCE(i.code, pol.itemNo, '') AS itemCode, + COALESCE(i.name, '') AS itemName, + COALESCE(pol.qtyM18, pol.qty, 0) AS orderQty, + COALESCE(pa.putAwayQty, 0) AS putAwayQty, + COALESCE(ucm18.udfudesc, uc.udfudesc, '') AS purchaseUnit, + COALESCE(sp.code, '') AS supplierCode, + COALESCE(sp.name, '') AS supplierName, + po.orderDate AS orderDate, + COALESCE(pol.status, '') AS status + FROM stock_in_line sil + INNER JOIN purchase_order_line pol ON sil.purchaseOrderLineId = pol.id AND pol.deleted = 0 + INNER JOIN purchase_order po ON pol.purchaseOrderId = po.id AND po.deleted = 0 + LEFT JOIN shop sp ON po.supplierId = sp.id AND sp.deleted = 0 + LEFT JOIN items i ON pol.itemId = i.id AND i.deleted = 0 + LEFT JOIN item_uom iu ON iu.itemId = pol.itemId AND iu.purchaseUnit = true AND iu.deleted = 0 + LEFT JOIN uom_conversion uc ON uc.id = iu.uomId AND uc.deleted = 0 + LEFT JOIN uom_conversion ucm18 ON ucm18.id = pol.uomIdM18 AND ucm18.deleted = 0 + LEFT JOIN ( + SELECT + sil_pa.purchaseOrderLineId, + SUM( + COALESCE(ill.inQty, 0) + * COALESCE(iu_s.ratioN, 1) / NULLIF(COALESCE(iu_s.ratioD, 1), 0) + / NULLIF( + COALESCE(iu_p.ratioN, 1) / NULLIF(COALESCE(iu_p.ratioD, 1), 0), + 0 + ) + ) AS putAwayQty + FROM stock_in_line sil_pa + INNER JOIN inventory_lot_line ill ON sil_pa.inventoryLotLineId = ill.id AND ill.deleted = 0 + INNER JOIN items it_pa ON sil_pa.itemId = it_pa.id AND it_pa.deleted = 0 + LEFT JOIN item_uom iu_s ON iu_s.itemId = it_pa.id AND iu_s.stockUnit = true AND iu_s.deleted = 0 + LEFT JOIN item_uom iu_p ON iu_p.itemId = it_pa.id AND iu_p.purchaseUnit = true AND iu_p.deleted = 0 + WHERE sil_pa.deleted = 0 + AND sil_pa.purchaseOrderLineId IS NOT NULL + AND COALESCE(ill.inQty, 0) > 0 + GROUP BY sil_pa.purchaseOrderLineId + ) pa ON pa.purchaseOrderLineId = pol.id + WHERE sil.deleted = 0 + AND sil.id IN (:stockInLineIds) + AND sil.purchaseOrderLineId IS NOT NULL + GROUP BY + pol.id, po.id, po.code, i.code, pol.itemNo, i.name, + pol.qtyM18, pol.qty, pa.putAwayQty, ucm18.udfudesc, uc.udfudesc, + sp.code, sp.name, po.orderDate, pol.status + ORDER BY po.orderDate ASC, pol.id ASC + """.trimIndent() + val fromLines = jdbcDao.queryForList(query, mapOf("stockInLineIds" to stockInLineIds)).map { row -> + mapPurchaseEventRow(row) + } + val headerQuery = """ + SELECT + po.id AS purchaseOrderId, + COALESCE(po.code, '') AS purchaseOrderCode, + COALESCE(i.code, '') AS itemCode, + COALESCE(i.name, '') AS itemName, + COALESCE(SUM(sil.acceptedQty), 0) AS orderQty, + COALESCE(uc.udfudesc, '') AS purchaseUnit, + COALESCE(sp.code, '') AS supplierCode, + COALESCE(sp.name, '') AS supplierName, + po.orderDate AS orderDate, + COALESCE(MIN(sil.status), '') AS status + FROM stock_in_line sil + INNER JOIN purchase_order po ON sil.purchaseOrderId = po.id AND po.deleted = 0 + LEFT JOIN shop sp ON po.supplierId = sp.id AND sp.deleted = 0 + LEFT JOIN items i ON sil.itemId = i.id AND i.deleted = 0 + LEFT JOIN item_uom iu ON iu.itemId = sil.itemId AND iu.purchaseUnit = true AND iu.deleted = 0 + LEFT JOIN uom_conversion uc ON uc.id = iu.uomId AND uc.deleted = 0 + WHERE sil.deleted = 0 + AND sil.id IN (:stockInLineIds) + AND sil.purchaseOrderId IS NOT NULL + AND sil.purchaseOrderLineId IS NULL + GROUP BY po.id, po.code, i.code, i.name, uc.udfudesc, sp.code, sp.name, po.orderDate + ORDER BY po.orderDate ASC, po.id ASC + """.trimIndent() + val fromHeader = jdbcDao.queryForList(headerQuery, mapOf("stockInLineIds" to stockInLineIds)).map { row -> + ItemLotTracePurchaseEvent( + purchaseOrderLineId = 0L, + purchaseOrderId = sql.longVal(row["purchaseOrderId"]), + purchaseOrderCode = sql.str(row["purchaseOrderCode"]), + itemCode = sql.str(row["itemCode"]), + itemName = sql.str(row["itemName"]), + orderQty = sql.decimal(row["orderQty"]), + putAwayQty = java.math.BigDecimal.ZERO, + purchaseUnit = sql.str(row["purchaseUnit"]), + supplierCode = sql.str(row["supplierCode"]), + supplierName = sql.str(row["supplierName"]), + orderDate = sql.formatDateTime(row["orderDate"]), + status = sql.str(row["status"]), + ) + } + return (fromLines + fromHeader).distinctBy { + if (it.purchaseOrderLineId > 0L) "line-${it.purchaseOrderLineId}" else "po-${it.purchaseOrderId}" + } + } + + private fun mapPurchaseEventRow(row: Map): ItemLotTracePurchaseEvent = + ItemLotTracePurchaseEvent( + purchaseOrderLineId = sql.longVal(row["purchaseOrderLineId"]) ?: 0L, + purchaseOrderId = sql.longVal(row["purchaseOrderId"]), + purchaseOrderCode = sql.str(row["purchaseOrderCode"]), + itemCode = sql.str(row["itemCode"]), + itemName = sql.str(row["itemName"]), + orderQty = sql.decimal(row["orderQty"]), + putAwayQty = sql.decimal(row["putAwayQty"]), + purchaseUnit = sql.str(row["purchaseUnit"]), + supplierCode = sql.str(row["supplierCode"]), + supplierName = sql.str(row["supplierName"]), + orderDate = sql.formatDateTime(row["orderDate"]), + status = sql.str(row["status"]), + ) + + open fun loadQcResults(stockInLineIds: List): List { + if (stockInLineIds.isEmpty()) return emptyList() + val query = """ + SELECT + qr.stockInLineId AS stockInLineId, + COALESCE(qr.qcPassed, 1) AS qcPassed, + COALESCE(qr.failQty, 0) AS failQty, + COALESCE(sil.acceptedQty, 0) AS acceptedQty, + COALESCE(qr.remarks, '') AS remarks, + COALESCE(qr.createdBy, '') AS handledBy, + qr.created AS created, + COALESCE(qi.code, '') AS qcItemCode, + COALESCE(qi.name, '') AS qcItemName, + COALESCE(qi.description, '') AS qcItemDescription, + CASE + WHEN sil.jobOrderId IS NOT NULL THEN 'EPQC' + WHEN sil.purchaseOrderId IS NOT NULL THEN 'IQC' + WHEN UPPER(COALESCE(qr.type, '')) IN ('IQC', 'IPQC', 'EPQC', 'FQC') THEN UPPER(qr.type) + ELSE 'IQC' + END AS qcType + FROM qc_result qr + LEFT JOIN qc_item qi ON qr.qcItemId = qi.id AND qi.deleted = 0 + LEFT JOIN stock_in_line sil ON qr.stockInLineId = sil.id AND sil.deleted = 0 + WHERE qr.deleted = 0 AND qr.stockInLineId IN (:stockInLineIds) + ORDER BY qr.created DESC, qi.code ASC + """.trimIndent() + return jdbcDao.queryForList(query, mapOf("stockInLineIds" to stockInLineIds)).map { row -> + ItemLotTraceQcResult( + stockInLineId = sql.longVal(row["stockInLineId"]) ?: 0L, + qcPassed = (row["qcPassed"] as? Number)?.toInt() != 0, + failQty = sql.decimal(row["failQty"]), + acceptedQty = sql.decimal(row["acceptedQty"]), + remarks = sql.str(row["remarks"]), + handledBy = sql.str(row["handledBy"]), + created = sql.formatDateTime(row["created"]), + qcItemCode = sql.str(row["qcItemCode"]), + qcItemName = sql.str(row["qcItemName"]), + qcItemDescription = sql.str(row["qcItemDescription"]), + qcType = sql.str(row["qcType"]), + ) + } + } + + /** FP-MTMS Version Checklist | Functions Ref. No. 22 | v1.0.0 | 2026-07-20 */ + open fun loadPutawayEvents(inventoryLotId: Long): List = + loadPutawayEventsByLotIds(listOf(inventoryLotId))[inventoryLotId].orEmpty() + + /** + * Putaway card qty must be the original shelf-in amount, not the live [InventoryLotLine.inQty]. + * Stock-take 盤盈 adds variance onto the same lot line inQty (see applyVarianceAdjustment), + * which would otherwise inflate the 已上架 node (e.g. 150 → 1500). + */ + private fun putawayQtySql(illIdExpr: String): String = """ + GREATEST( + COALESCE(ill.inQty, 0) - COALESCE(( + SELECT SUM(COALESCE(sil_tke.acceptedQty, 0)) + FROM stock_in_line sil_tke + INNER JOIN stock_take_line stl_tke + ON sil_tke.stockTakeLineId = stl_tke.id AND stl_tke.deleted = 0 + WHERE stl_tke.inventoryLotLineId = $illIdExpr + AND sil_tke.deleted = 0 + AND UPPER(COALESCE(sil_tke.type, '')) = 'TKE' + AND COALESCE(sil_tke.acceptedQty, 0) > 0 + ), 0), + 0 + ) + """.trimIndent() + + /** FP-MTMS Version Checklist | Functions Ref. No. 22 | v1.0.0 | 2026-07-20 */ + open fun loadPutawayEventsByLotIds(lotIds: List): Map> { + if (lotIds.isEmpty()) return emptyMap() + val illPutawayQty = putawayQtySql("ill.id") + val fromIllSql = """ + SELECT + il.id AS inventoryLotId, + ill.id AS inventoryLotLineId, + COALESCE(sil_link.id, sil_primary.id) AS stockInLineId, + COALESCE(wh.code, '') AS warehouseCode, + $illPutawayQty AS qty, + COALESCE(ill.createdBy, sil_link.createdBy, sil_primary.createdBy, '') AS handledBy, + COALESCE(ill.created, sil_link.receiptDate, sil_primary.receiptDate, sil_link.created, sil_primary.created) AS timestamp, + CASE + WHEN COALESCE(sil_link.purchaseOrderId, sil_primary.purchaseOrderId) IS NOT NULL THEN 'PO' + WHEN COALESCE(sil_link.jobOrderId, sil_primary.jobOrderId) IS NOT NULL THEN 'JO' + WHEN COALESCE(sil_link.stockTakeLineId, sil_primary.stockTakeLineId) IS NOT NULL THEN 'TKE' + WHEN COALESCE(sil_link.stockTransferId, sil_primary.stockTransferId) IS NOT NULL THEN 'TRANSFER' + WHEN UPPER(COALESCE(sil_link.type, sil_primary.type, '')) IN ('ADJ', 'OPEN') THEN UPPER(COALESCE(sil_link.type, sil_primary.type)) + ELSE 'STOCK_IN' + END AS refType, + COALESCE(po.code, jo.code, st.code, si.code, '') AS refCode, + COALESCE(po.id, jo.id, st.id, si.id) AS refId, + COALESCE(sil_link.status, sil_primary.status, '') AS status + FROM inventory_lot_line ill + INNER JOIN inventory_lot il ON ill.inventoryLotId = il.id AND il.deleted = 0 + LEFT JOIN warehouse wh ON ill.warehouseId = wh.id AND wh.deleted = 0 + LEFT JOIN stock_in_line sil_link ON sil_link.inventoryLotLineId = ill.id AND sil_link.deleted = 0 + LEFT JOIN stock_in_line sil_primary ON sil_primary.id = il.stockInLineId AND sil_primary.deleted = 0 + LEFT JOIN stock_in si ON COALESCE(sil_link.stockInId, sil_primary.stockInId) = si.id AND si.deleted = 0 + LEFT JOIN purchase_order po ON COALESCE(sil_link.purchaseOrderId, sil_primary.purchaseOrderId) = po.id AND po.deleted = 0 + LEFT JOIN job_order jo ON COALESCE(sil_link.jobOrderId, sil_primary.jobOrderId) = jo.id AND jo.deleted = 0 + LEFT JOIN stock_take_line stl ON COALESCE(sil_link.stockTakeLineId, sil_primary.stockTakeLineId) = stl.id AND stl.deleted = 0 + LEFT JOIN stock_take st ON stl.stockTakeId = st.id AND st.deleted = 0 + WHERE il.id IN (:lotIds) AND ill.deleted = 0 + """.trimIndent() + val fromSilSql = """ + SELECT + il.id AS inventoryLotId, + COALESCE(ill.id, sil.inventoryLotLineId) AS inventoryLotLineId, + sil.id AS stockInLineId, + COALESCE(wh.code, '') AS warehouseCode, + CASE + WHEN ill.id IS NOT NULL THEN $illPutawayQty + ELSE COALESCE(sil.acceptedQty, 0) + END AS qty, + COALESCE(ill.createdBy, sil.createdBy, '') AS handledBy, + COALESCE(ill.created, sil.receiptDate, sil.created) AS timestamp, + CASE + WHEN sil.purchaseOrderId IS NOT NULL THEN 'PO' + WHEN sil.jobOrderId IS NOT NULL THEN 'JO' + WHEN sil.stockTakeLineId IS NOT NULL THEN 'TKE' + WHEN sil.stockTransferId IS NOT NULL THEN 'TRANSFER' + WHEN UPPER(COALESCE(sil.type, '')) IN ('ADJ', 'OPEN') THEN UPPER(sil.type) + ELSE 'STOCK_IN' + END AS refType, + COALESCE(po.code, jo.code, st.code, si.code, '') AS refCode, + COALESCE(po.id, jo.id, st.id, si.id) AS refId, + COALESCE(sil.status, '') AS status + FROM stock_in_line sil + INNER JOIN inventory_lot il ON sil.inventoryLotId = il.id AND il.deleted = 0 + LEFT JOIN inventory_lot_line ill ON sil.inventoryLotLineId = ill.id + LEFT JOIN warehouse wh ON ill.warehouseId = wh.id AND wh.deleted = 0 + LEFT JOIN stock_in si ON sil.stockInId = si.id AND si.deleted = 0 + LEFT JOIN purchase_order po ON sil.purchaseOrderId = po.id AND po.deleted = 0 + LEFT JOIN job_order jo ON sil.jobOrderId = jo.id AND jo.deleted = 0 + LEFT JOIN stock_take_line stl ON sil.stockTakeLineId = stl.id AND stl.deleted = 0 + LEFT JOIN stock_take st ON stl.stockTakeId = st.id AND st.deleted = 0 + WHERE il.id IN (:lotIds) AND sil.deleted = 0 AND sil.inventoryLotLineId IS NOT NULL + """.trimIndent() + + val grouped = mutableMapOf>() + val seen = mutableSetOf() + fun addRow(row: Map) { + val lotId = sql.longVal(row["inventoryLotId"]) ?: return + val illId = sql.longVal(row["inventoryLotLineId"]) ?: 0L + val timestamp = sql.formatDateTime(row["timestamp"]) + val dedupeKey = "$lotId-$illId-$timestamp" + if (!seen.add(dedupeKey)) return + grouped.getOrPut(lotId) { mutableListOf() }.add( + ItemLotTracePutawayEvent( + inventoryLotLineId = illId, + stockInLineId = sql.longVal(row["stockInLineId"]), + warehouseCode = sql.str(row["warehouseCode"]), + qty = sql.decimal(row["qty"]), + handledBy = sql.str(row["handledBy"]), + timestamp = timestamp, + refType = sql.str(row["refType"]), + refCode = sql.str(row["refCode"]), + refId = sql.longVal(row["refId"]), + status = sql.str(row["status"]), + ), + ) + } + jdbcDao.queryForList(fromIllSql, mapOf("lotIds" to lotIds)).forEach(::addRow) + jdbcDao.queryForList(fromSilSql, mapOf("lotIds" to lotIds)).forEach(::addRow) + grouped.values.forEach { list -> + list.sortWith(compareBy { it.timestamp ?: "" }.thenBy { it.inventoryLotLineId }) + } + return grouped + } + + open fun synthesizeMaterialPutawayEvent( + origin: ItemLotTraceOrigin, + pickedInventoryLotLineId: Long?, + pickedWarehouseCode: String, + ): ItemLotTracePutawayEvent = + ItemLotTracePutawayEvent( + inventoryLotLineId = pickedInventoryLotLineId ?: 0L, + stockInLineId = origin.stockInLineId, + warehouseCode = pickedWarehouseCode, + qty = origin.acceptedQty, + handledBy = "", + timestamp = origin.receiptDate, + refType = origin.type, + refCode = origin.refCode, + refId = origin.refId, + status = origin.status, + ) + + open fun resolveMaterialPutawayEvents( + lotId: Long?, + origin: ItemLotTraceOrigin?, + pickedInventoryLotLineId: Long?, + pickedWarehouseCode: String, + putawayByLot: Map>, + ): List { + if (lotId == null) return emptyList() + val fromLot = putawayByLot[lotId].orEmpty() + if (fromLot.isNotEmpty()) return fromLot + if (origin == null) return emptyList() + + val originType = origin.type.trim().uppercase() + val immediateInbound = originType in setOf("ADJ", "OPEN", "JO", "TRANSFER") + val poInbound = originType == "PO" + if (!immediateInbound && !poInbound) return emptyList() + + return listOf( + synthesizeMaterialPutawayEvent( + origin = origin, + pickedInventoryLotLineId = pickedInventoryLotLineId, + pickedWarehouseCode = pickedWarehouseCode, + ), + ) + } +} diff --git a/src/main/java/com/ffii/fpsms/modules/stock/trace/TraceJoPreludeLoader.kt b/src/main/java/com/ffii/fpsms/modules/stock/trace/TraceJoPreludeLoader.kt new file mode 100644 index 0000000..a431b1c --- /dev/null +++ b/src/main/java/com/ffii/fpsms/modules/stock/trace/TraceJoPreludeLoader.kt @@ -0,0 +1,357 @@ +package com.ffii.fpsms.modules.stock.trace + +import com.ffii.core.support.JdbcDao +import com.ffii.fpsms.modules.stock.web.model.* +import org.springframework.stereotype.Component +import java.math.BigDecimal + +data class MaterialInputRow( + val jobOrderCode: String, + val jobOrderId: Long?, + val materialItemCode: String, + val materialItemName: String, + val materialLotNo: String, + val materialStockInLineId: Long?, + val materialInventoryLotId: Long?, + val pickedInventoryLotLineId: Long?, + val pickedWarehouseCode: String, + val materialQty: BigDecimal, + val materialUom: String, + val bomQtyPerUnit: BigDecimal?, + val pickOrderCode: String, + val pickOrderId: Long?, + val consoCode: String, + val pickedAt: String?, + val processingStatus: String, + val matchStatus: String, +) + +@Component +open class TraceJoPreludeLoader( + private val jdbcDao: JdbcDao, + private val sql: TraceSqlSupport, + private val bomLoader: TraceBomLoader, + private val productionLoader: TraceProductionLoader, + private val inboundLoader: TraceInboundLoader, + private val lotResolver: TraceLotResolver, +) { + open fun loadJoPrelude(primaryStockInLineId: Long): ItemLotTraceJoPrelude? { + val joId = bomLoader.getJobOrderIdForFgStockIn(primaryStockInLineId) ?: return null + val jobOrder = loadJoContext(joId) ?: return null + val pickOrders = loadJoPickOrders(joId) + val materialInputs = loadJoMaterialInputs(primaryStockInLineId, mutableSetOf()) + return ItemLotTraceJoPrelude( + jobOrder = jobOrder, + pickOrders = pickOrders, + materialInputs = materialInputs, + ) + } + + open fun loadJoContext(jobOrderId: Long): ItemLotTraceJoContext? { + val query = """ + SELECT + jo.id AS jobOrderId, + COALESCE(jo.code, '') AS jobOrderCode, + jo.planStart AS planStart, + ( + SELECT MIN(jpo.created) + FROM pick_order po + INNER JOIN jo_pick_order jpo + ON jpo.pick_order_id = po.id AND jpo.deleted = 0 + WHERE po.joId = jo.id AND po.deleted = 0 + ) AS createdAt, + COALESCE(jo.reqQty, 0) AS reqQty, + COALESCE(jo.status, '') AS status + FROM job_order jo + WHERE jo.id = :jobOrderId AND jo.deleted = 0 + """.trimIndent() + val row = jdbcDao.queryForList(query, mapOf("jobOrderId" to jobOrderId)).firstOrNull() ?: return null + return ItemLotTraceJoContext( + jobOrderId = sql.longVal(row["jobOrderId"]) ?: jobOrderId, + jobOrderCode = sql.str(row["jobOrderCode"]), + planStart = sql.formatDateTime(row["planStart"]), + createdAt = sql.formatDateTime(row["createdAt"]), + reqQty = sql.decimal(row["reqQty"]), + status = sql.str(row["status"]), + ) + } + + open fun loadJoPickOrders(jobOrderId: Long): List { + val headerSql = """ + SELECT + po.id AS pickOrderId, + COALESCE(po.code, '') AS pickOrderCode, + COALESCE(po.consoCode, '') AS consoCode, + COALESCE(po.status, '') AS status, + po.targetDate AS targetDate, + po.completeDate AS completeDate, + po.releasedDate AS releasedDate + FROM pick_order po + WHERE po.joId = :jobOrderId AND po.deleted = 0 + AND po.type IN ('jo', 'JOB_ORDER') + ORDER BY po.code + """.trimIndent() + val headers = jdbcDao.queryForList(headerSql, mapOf("jobOrderId" to jobOrderId)) + if (headers.isEmpty()) return emptyList() + + val lineSql = """ + SELECT + pol.poId AS pickOrderId, + pol.id AS pickOrderLineId, + COALESCE(it.code, '') AS itemCode, + COALESCE(it.name, '') AS itemName, + COALESCE(pol.qty, 0) AS requiredQty, + COALESCE(pol.status, '') AS lineStatus, + COALESCE(SUM(sol.qty), 0) AS pickedQty + FROM pick_order_line pol + INNER JOIN pick_order po ON pol.poId = po.id AND po.deleted = 0 + LEFT JOIN items it ON pol.itemId = it.id AND it.deleted = 0 + LEFT JOIN stock_out_line sol ON sol.pickOrderLineId = pol.id AND sol.deleted = 0 + WHERE po.joId = :jobOrderId AND pol.deleted = 0 + AND po.type IN ('jo', 'JOB_ORDER') + GROUP BY pol.id, pol.poId, it.code, it.name, pol.qty, pol.status + ORDER BY it.code + """.trimIndent() + val linesByPo = jdbcDao.queryForList(lineSql, mapOf("jobOrderId" to jobOrderId)) + .groupBy { sql.longVal(it["pickOrderId"]) } + + return headers.map { row -> + val poId = sql.longVal(row["pickOrderId"]) ?: 0L + val lines = (linesByPo[poId] ?: emptyList()).map { lineRow -> + ItemLotTraceJoPickLine( + pickOrderLineId = sql.longVal(lineRow["pickOrderLineId"]) ?: 0L, + itemCode = sql.str(lineRow["itemCode"]), + itemName = sql.str(lineRow["itemName"]), + requiredQty = sql.decimal(lineRow["requiredQty"]), + pickedQty = sql.decimal(lineRow["pickedQty"]), + status = sql.str(lineRow["lineStatus"]), + ) + } + ItemLotTraceJoPickOrder( + pickOrderId = poId, + pickOrderCode = sql.str(row["pickOrderCode"]), + consoCode = sql.str(row["consoCode"]), + status = sql.str(row["status"]), + targetDate = sql.formatDate(row["targetDate"]), + completeDate = sql.formatDateTime(row["completeDate"]), + releasedDate = sql.formatDateTime(row["releasedDate"]), + lines = lines, + ) + } + } + + open fun loadJoMaterialInputs( + primaryStockInLineId: Long, + visitedStockInLineIds: MutableSet, + ): List { + if (primaryStockInLineId in visitedStockInLineIds) return emptyList() + visitedStockInLineIds.add(primaryStockInLineId) + + val query = """ + SELECT + COALESCE(jo.code, '') AS jobOrderCode, + jo.id AS jobOrderId, + COALESCE(mat_it.code, '') AS materialItemCode, + COALESCE(mat_it.name, '') AS materialItemName, + COALESCE(mat_il.lotNo, mat_sil.lotNo, '') AS materialLotNo, + mat_sil.id AS materialStockInLineId, + mat_il.id AS materialInventoryLotId, + mat_ill.id AS pickedInventoryLotLineId, + COALESCE(wh_pick.code, '') AS pickedWarehouseCode, + COALESCE(sol.qty, 0) AS materialQty, + COALESCE(mat_uc.udfudesc, '') AS materialUom, + bm.qty AS bomQtyPerUnit, + COALESCE(po.code, '') AS pickOrderCode, + po.id AS pickOrderId, + COALESCE(po.consoCode, '') AS consoCode, + COALESCE(sol.endTime, sol.pickTime, sol.created) AS pickedAt, + CASE + WHEN sol.status = 'completed' THEN 'completed' + WHEN sol.status = 'rejected' THEN 'rejected' + WHEN sol.status = 'created' THEN 'pending' + ELSE 'pending' + END AS processingStatus, + COALESCE(jpo.match_status, '') AS matchStatus + FROM stock_in_line fg_sil + INNER JOIN job_order jo ON fg_sil.jobOrderId = jo.id AND jo.deleted = 0 + INNER JOIN pick_order po ON po.joId = jo.id AND po.deleted = 0 + AND po.type IN ('jo', 'JOB_ORDER') + INNER JOIN pick_order_line pol ON pol.poId = po.id AND pol.deleted = 0 + INNER JOIN stock_out_line sol ON sol.pickOrderLineId = pol.id AND sol.deleted = 0 + INNER JOIN inventory_lot_line mat_ill ON sol.inventoryLotLineId = mat_ill.id AND mat_ill.deleted = 0 + INNER JOIN inventory_lot mat_il ON mat_ill.inventoryLotId = mat_il.id AND mat_il.deleted = 0 + LEFT JOIN warehouse wh_pick ON mat_ill.warehouseId = wh_pick.id AND wh_pick.deleted = 0 + LEFT JOIN stock_in_line mat_sil ON mat_il.stockInLineId = mat_sil.id AND mat_sil.deleted = 0 + LEFT JOIN items mat_it ON mat_il.itemId = mat_it.id AND mat_it.deleted = 0 + LEFT JOIN item_uom mat_iu ON mat_it.id = mat_iu.itemId AND mat_iu.stockUnit = 1 AND mat_iu.deleted = 0 + LEFT JOIN uom_conversion mat_uc ON mat_iu.uomId = mat_uc.id AND mat_uc.deleted = 0 + LEFT JOIN bom b ON jo.bomId = b.id AND b.deleted = 0 + LEFT JOIN bom_material bm ON bm.bomId = b.id AND bm.itemId = mat_it.id AND bm.deleted = 0 + LEFT JOIN jo_pick_order jpo ON jpo.pick_order_id = po.id + AND jpo.item_id = mat_it.id AND jpo.deleted = 0 + WHERE fg_sil.id = :primaryStockInLineId AND fg_sil.deleted = 0 + ORDER BY mat_it.code, mat_il.lotNo, sol.created + """.trimIndent() + val rows = jdbcDao.queryForList(query, mapOf("primaryStockInLineId" to primaryStockInLineId)).map { row -> + MaterialInputRow( + jobOrderCode = sql.str(row["jobOrderCode"]), + jobOrderId = sql.longVal(row["jobOrderId"]), + materialItemCode = sql.str(row["materialItemCode"]), + materialItemName = sql.str(row["materialItemName"]), + materialLotNo = sql.str(row["materialLotNo"]), + materialStockInLineId = sql.longVal(row["materialStockInLineId"]), + materialInventoryLotId = sql.longVal(row["materialInventoryLotId"]), + pickedInventoryLotLineId = sql.longVal(row["pickedInventoryLotLineId"]), + pickedWarehouseCode = sql.str(row["pickedWarehouseCode"]), + materialQty = sql.decimal(row["materialQty"]), + materialUom = sql.str(row["materialUom"]), + bomQtyPerUnit = row["bomQtyPerUnit"]?.let { sql.decimal(it) }, + pickOrderCode = sql.str(row["pickOrderCode"]), + pickOrderId = sql.longVal(row["pickOrderId"]), + consoCode = sql.str(row["consoCode"]), + pickedAt = sql.formatDateTime(row["pickedAt"]), + processingStatus = sql.str(row["processingStatus"]), + matchStatus = sql.str(row["matchStatus"]), + ) + } + if (rows.isEmpty()) return emptyList() + + val joIdForMapping = rows.firstNotNullOfOrNull { it.jobOrderId } + val stepAssignments = joIdForMapping?.let { bomLoader.loadBomMaterialStepAssignments(it) } ?: emptyMap() + + val lotIds = rows.mapNotNull { it.materialInventoryLotId }.distinct() + val silIdsByLot = lotResolver.loadStockInLineIdsForLots(lotIds) + val allSilIds = silIdsByLot.values.flatten().distinct() + val allOrigins = inboundLoader.loadOriginsForLots(lotIds) + val allQc = inboundLoader.loadQcResults(allSilIds) + val putawayByLot = inboundLoader.loadPutawayEventsByLotIds(lotIds) + + fun materialStockInOriginForLot(lotId: Long?): ItemLotTraceOrigin? { + if (lotId == null) return null + val silIds = silIdsByLot[lotId]?.toSet() ?: emptySet() + if (silIds.isEmpty()) return null + val origins = allOrigins.filter { it.stockInLineId in silIds } + if (origins.isEmpty()) return null + + fun earliestOfType(type: String): ItemLotTraceOrigin? = + origins + .filter { it.type.equals(type, ignoreCase = true) } + .minWithOrNull( + compareBy { it.receiptDate ?: "9999-12-31" } + .thenBy { it.stockInLineId }, + ) + + earliestOfType("PO")?.let { return it } + earliestOfType("JO")?.let { return it } + earliestOfType("TRANSFER")?.let { return it } + + return origins + .filter { o -> + val t = o.type.uppercase() + t !in setOf("ADJ", "OPEN", "TKE") + } + .minWithOrNull( + compareBy { it.receiptDate ?: "9999-12-31" } + .thenBy { it.stockInLineId }, + ) + ?: origins.minWithOrNull( + compareBy { it.receiptDate ?: "9999-12-31" } + .thenBy { it.stockInLineId }, + ) + } + + fun qcForLot(lotId: Long?): List { + if (lotId == null) return emptyList() + val silIds = silIdsByLot[lotId]?.toSet() ?: emptySet() + return allQc.filter { it.stockInLineId in silIds } + } + + fun purchaseForLot(lotId: Long?): List { + if (lotId == null) return emptyList() + val silIds = silIdsByLot[lotId].orEmpty() + return inboundLoader.loadPurchaseEvents(silIds) + } + + return rows.map { r -> + val stockInOrigin = materialStockInOriginForLot(r.materialInventoryLotId) + val resolvedSilId = bomLoader.resolveJoProducedStockInLineId( + r.materialStockInLineId, + r.materialInventoryLotId, + silIdsByLot, + ) + val joProduced = bomLoader.isJoProducedMaterial( + resolvedSilId, + r.materialInventoryLotId, + stockInOrigin, + ) + val producingJoId = resolvedSilId?.let { bomLoader.getJobOrderIdForFgStockIn(it) } + ?: r.materialInventoryLotId?.let { lotResolver.resolveJobOrderIdForLot(it, resolvedSilId) } + val nestedJoPrelude = if (joProduced && producingJoId != null) { + val jobOrder = loadJoContext(producingJoId) + if (jobOrder != null) { + val nestedSilId = resolvedSilId ?: producingJoId.let { joId -> + jdbcDao.queryForList( + """ + SELECT sil.id AS id FROM stock_in_line sil + WHERE sil.jobOrderId = :joId AND sil.deleted = 0 + ORDER BY sil.created DESC LIMIT 1 + """.trimIndent(), + mapOf("joId" to joId), + ).firstOrNull()?.let { sql.longVal(it["id"]) } + } + val materialInputs = nestedSilId + ?.let { loadJoMaterialInputs(it, visitedStockInLineIds) } + ?: emptyList() + ItemLotTraceJoPrelude( + jobOrder = jobOrder, + pickOrders = loadJoPickOrders(producingJoId), + materialInputs = materialInputs, + ) + } else { + null + } + } else { + null + } + val productionSteps = if (joProduced && producingJoId != null) { + productionLoader.loadProductionSteps(producingJoId) + } else { + emptyList() + } + val assignment = stepAssignments[bomLoader.itemCodeKey(r.materialItemCode)] + ItemLotTraceMaterialInput( + jobOrderCode = r.jobOrderCode, + jobOrderId = r.jobOrderId, + materialItemCode = r.materialItemCode, + materialItemName = r.materialItemName, + materialLotNo = r.materialLotNo, + materialStockInLineId = resolvedSilId ?: r.materialStockInLineId, + materialInventoryLotId = r.materialInventoryLotId, + materialQty = r.materialQty, + materialUom = r.materialUom, + bomQtyPerUnit = r.bomQtyPerUnit, + bomProcessId = assignment?.bomProcessId, + bomProcessSeqNo = assignment?.bomProcessSeqNo, + assignedStepName = assignment?.stepName ?: "", + pickOrderCode = r.pickOrderCode, + pickOrderId = r.pickOrderId, + consoCode = r.consoCode, + pickedAt = r.pickedAt, + processingStatus = r.processingStatus, + matchStatus = r.matchStatus, + stockInOrigin = stockInOrigin, + purchaseEvents = purchaseForLot(r.materialInventoryLotId), + qcResults = qcForLot(r.materialInventoryLotId), + putawayEvents = inboundLoader.resolveMaterialPutawayEvents( + lotId = r.materialInventoryLotId, + origin = stockInOrigin, + pickedInventoryLotLineId = r.pickedInventoryLotLineId, + pickedWarehouseCode = r.pickedWarehouseCode, + putawayByLot = putawayByLot, + ), + nestedJoPrelude = nestedJoPrelude, + productionSteps = productionSteps, + ) + } + } +} diff --git a/src/main/java/com/ffii/fpsms/modules/stock/trace/TraceLotResolver.kt b/src/main/java/com/ffii/fpsms/modules/stock/trace/TraceLotResolver.kt new file mode 100644 index 0000000..576b188 --- /dev/null +++ b/src/main/java/com/ffii/fpsms/modules/stock/trace/TraceLotResolver.kt @@ -0,0 +1,319 @@ +package com.ffii.fpsms.modules.stock.trace + +import com.ffii.core.support.JdbcDao +import com.ffii.fpsms.modules.stock.entity.InventoryLotLineRepository +import com.ffii.fpsms.modules.stock.entity.InventoryLotRepository +import com.ffii.fpsms.modules.stock.entity.StockInLineRepository +import com.ffii.fpsms.modules.stock.web.model.* +import org.springframework.http.HttpStatus +import org.springframework.stereotype.Component +import org.springframework.web.server.ResponseStatusException + +@Component +open class TraceLotResolver( + private val jdbcDao: JdbcDao, + private val inventoryLotRepository: InventoryLotRepository, + private val inventoryLotLineRepository: InventoryLotLineRepository, + private val stockInLineRepository: StockInLineRepository, + private val sql: TraceSqlSupport, +) { + open fun resolveInventoryLotId( + stockInLineId: Long?, + inventoryLotLineId: Long?, + lotNo: String?, + itemId: Long?, + ): Long { + stockInLineId?.let { silId -> + val sil = stockInLineRepository.findById(silId).orElseThrow { + ResponseStatusException(HttpStatus.NOT_FOUND, "stockInLineId $silId not found") + } + val lotId = sil.inventoryLot?.id + ?: inventoryLotRepository.findFirstByStockInLineIdAndDeletedFalseOrderByIdAsc(silId)?.id + return lotId ?: throw ResponseStatusException( + HttpStatus.NOT_FOUND, + "No inventory lot for stockInLineId $silId", + ) + } + + inventoryLotLineId?.let { illId -> + val ill = inventoryLotLineRepository.findById(illId).orElseThrow { + ResponseStatusException(HttpStatus.NOT_FOUND, "inventoryLotLineId $illId not found") + } + return ill.inventoryLot?.id + ?: throw ResponseStatusException(HttpStatus.NOT_FOUND, "No inventory lot for inventoryLotLineId $illId") + } + + if (!lotNo.isNullOrBlank() && itemId != null) { + val trimmed = lotNo.trim() + val inventoryLotId = resolvePrimaryInventoryLotIdByLotNo(trimmed, itemId) + ?: throw ResponseStatusException( + HttpStatus.NOT_FOUND, + "Lot not found: lotNo=$trimmed itemId=$itemId", + ) + return inventoryLotId + } + + throw ResponseStatusException( + HttpStatus.BAD_REQUEST, + "Provide stockInLineId, inventoryLotLineId, or lotNo+itemId/itemCode", + ) + } + + /** lotNo+itemId is not unique — pick the lot with the most available qty (tie: lowest id). */ + open fun resolvePrimaryInventoryLotIdByLotNo(lotNo: String, itemId: Long): Long? { + val query = """ + SELECT il.id AS inventoryLotId + FROM inventory_lot il + LEFT JOIN inventory_lot_line ill ON ill.inventoryLotId = il.id AND ill.deleted = 0 + WHERE il.deleted = 0 + AND il.itemId = :itemId + AND il.lotNo = :lotNo + GROUP BY il.id + ORDER BY COALESCE(SUM(COALESCE(ill.inQty, 0) - COALESCE(ill.outQty, 0)), 0) DESC, il.id ASC + LIMIT 1 + """.trimIndent() + return jdbcDao.queryForList(query, mapOf("itemId" to itemId, "lotNo" to lotNo)) + .firstOrNull() + ?.let { sql.longVal(it["inventoryLotId"]) } + } + + open fun loadAlternateLocations( + itemId: Long, + lotNo: String, + currentInventoryLotId: Long, + ): List { + if (lotNo.isBlank()) return emptyList() + val query = """ + SELECT + il.id AS inventoryLotId, + il.stockInLineId AS stockInLineId, + ill.id AS inventoryLotLineId, + COALESCE(wh.code, '') AS warehouseCode, + COALESCE(ill.inQty, 0) - COALESCE(ill.outQty, 0) AS availableQty + FROM inventory_lot il + INNER JOIN inventory_lot_line ill ON ill.inventoryLotId = il.id AND ill.deleted = 0 + LEFT JOIN warehouse wh ON ill.warehouseId = wh.id AND wh.deleted = 0 + WHERE il.deleted = 0 + AND il.itemId = :itemId + AND il.lotNo = :lotNo + AND il.id <> :currentInventoryLotId + ORDER BY wh.code, il.id, ill.id + """.trimIndent() + return jdbcDao.queryForList( + query, + mapOf("itemId" to itemId, "lotNo" to lotNo, "currentInventoryLotId" to currentInventoryLotId), + ).map { row -> + ItemLotTraceAlternateLocation( + inventoryLotId = sql.longVal(row["inventoryLotId"]) ?: 0L, + stockInLineId = sql.longVal(row["stockInLineId"]), + inventoryLotLineId = sql.longVal(row["inventoryLotLineId"]) ?: 0L, + warehouseCode = sql.str(row["warehouseCode"]), + availableQty = sql.decimal(row["availableQty"]), + ) + } + } + + open fun loadLotInfo(inventoryLotId: Long): ItemLotTraceLotInfo { + val query = """ + SELECT + il.id AS inventoryLotId, + COALESCE(il.lotNo, '') AS lotNo, + it.id AS itemId, + COALESCE(it.code, '') AS itemCode, + COALESCE(it.name, '') AS itemName, + il.expiryDate AS expiryDate, + (SELECT sil.productionDate + FROM stock_in_line sil + WHERE sil.inventoryLotId = il.id + AND sil.jobOrderId IS NOT NULL + AND sil.deleted = 0 + ORDER BY sil.receiptDate DESC, sil.id DESC + LIMIT 1) AS productionDate, + il.stockInDate AS stockInDate, + COALESCE(uc.udfudesc, '') AS uom, + il.stockInLineId AS primaryStockInLineId + FROM inventory_lot il + INNER JOIN items it ON il.itemId = it.id AND it.deleted = 0 + LEFT JOIN item_uom iu ON it.id = iu.itemId AND iu.stockUnit = 1 + LEFT JOIN uom_conversion uc ON iu.uomId = uc.id + WHERE il.id = :inventoryLotId AND il.deleted = 0 + """.trimIndent() + val row = jdbcDao.queryForList(query, mapOf("inventoryLotId" to inventoryLotId)).firstOrNull() + ?: throw ResponseStatusException(HttpStatus.NOT_FOUND, "Inventory lot $inventoryLotId not found") + + return ItemLotTraceLotInfo( + inventoryLotId = inventoryLotId, + lotNo = sql.str(row["lotNo"]), + itemId = sql.longVal(row["itemId"]) ?: inventoryLotId, + itemCode = sql.str(row["itemCode"]), + itemName = sql.str(row["itemName"]), + expiryDate = sql.formatDate(row["expiryDate"]), + productionDate = sql.formatDateTime(row["productionDate"]), + stockInDate = sql.formatDateTime(row["stockInDate"]), + uom = sql.str(row["uom"]), + primaryStockInLineId = sql.longVal(row["primaryStockInLineId"]), + ) + } + + open fun loadLotLineIds(inventoryLotId: Long): List { + val query = """ + SELECT id FROM inventory_lot_line + WHERE inventoryLotId = :inventoryLotId AND deleted = 0 + """.trimIndent() + return jdbcDao.queryForList(query, mapOf("inventoryLotId" to inventoryLotId)) + .mapNotNull { sql.longVal(it["id"]) } + } + + open fun loadStockInLineIds(inventoryLotId: Long): List { + val query = """ + SELECT sil.id AS id + FROM stock_in_line sil + LEFT JOIN inventory_lot_line ill ON sil.inventoryLotLineId = ill.id AND ill.deleted = 0 + WHERE sil.deleted = 0 + AND ( + sil.inventoryLotId = :inventoryLotId + OR ill.inventoryLotId = :inventoryLotId + ) + """.trimIndent() + return jdbcDao.queryForList(query, mapOf("inventoryLotId" to inventoryLotId)) + .mapNotNull { sql.longVal(it["id"]) } + .distinct() + } + + open fun loadWarehouseLines(inventoryLotId: Long): List { + val query = """ + SELECT + ill.id AS inventoryLotLineId, + COALESCE(wh.code, '') AS warehouseCode, + COALESCE(ill.inQty, 0) AS inQty, + COALESCE(ill.outQty, 0) AS outQty, + COALESCE(ill.issueQty, 0) AS issueQty, + COALESCE(ill.status, '') AS status, + COALESCE(ill.inQty, 0) - COALESCE(ill.outQty, 0) AS availableQty + FROM inventory_lot_line ill + LEFT JOIN warehouse wh ON ill.warehouseId = wh.id AND wh.deleted = 0 + WHERE ill.inventoryLotId = :inventoryLotId AND ill.deleted = 0 + ORDER BY wh.code + """.trimIndent() + return jdbcDao.queryForList(query, mapOf("inventoryLotId" to inventoryLotId)).map { row -> + ItemLotTraceWarehouseLine( + inventoryLotLineId = sql.longVal(row["inventoryLotLineId"]) ?: 0L, + warehouseCode = sql.str(row["warehouseCode"]), + inQty = sql.decimal(row["inQty"]), + outQty = sql.decimal(row["outQty"]), + issueQty = sql.decimal(row["issueQty"]), + status = sql.str(row["status"]), + availableQty = sql.decimal(row["availableQty"]), + ) + } + } + + /** Batch variant of [loadLotLineIds]. */ + open fun loadLotLineIdsForLots(inventoryLotIds: List): Map> { + if (inventoryLotIds.isEmpty()) return emptyMap() + val query = """ + SELECT inventoryLotId, id FROM inventory_lot_line + WHERE inventoryLotId IN (:inventoryLotIds) AND deleted = 0 + ORDER BY id + """.trimIndent() + val grouped = mutableMapOf>() + jdbcDao.queryForList(query, mapOf("inventoryLotIds" to inventoryLotIds)).forEach { row -> + val lotId = sql.longVal(row["inventoryLotId"]) ?: return@forEach + val illId = sql.longVal(row["id"]) ?: return@forEach + grouped.getOrPut(lotId) { mutableListOf() }.add(illId) + } + return grouped + } + + open fun loadStockInLineIdsForLots(inventoryLotIds: List): Map> { + if (inventoryLotIds.isEmpty()) return emptyMap() + val query = """ + SELECT inventoryLotId, id FROM stock_in_line + WHERE inventoryLotId IN (:inventoryLotIds) AND deleted = 0 + """.trimIndent() + val grouped = mutableMapOf>() + jdbcDao.queryForList(query, mapOf("inventoryLotIds" to inventoryLotIds)).forEach { row -> + val lotId = sql.longVal(row["inventoryLotId"]) ?: return@forEach + val silId = sql.longVal(row["id"]) ?: return@forEach + grouped.getOrPut(lotId) { mutableListOf() }.add(silId) + } + return grouped + } + + open fun loadLotInfoBatch(inventoryLotIds: List): Map { + if (inventoryLotIds.isEmpty()) return emptyMap() + val query = """ + SELECT + il.id AS inventoryLotId, + COALESCE(il.lotNo, '') AS lotNo, + it.id AS itemId, + COALESCE(it.code, '') AS itemCode, + COALESCE(it.name, '') AS itemName, + il.expiryDate AS expiryDate, + (SELECT sil.productionDate + FROM stock_in_line sil + WHERE sil.inventoryLotId = il.id + AND sil.jobOrderId IS NOT NULL + AND sil.deleted = 0 + ORDER BY sil.receiptDate DESC, sil.id DESC + LIMIT 1) AS productionDate, + il.stockInDate AS stockInDate, + COALESCE(uc.udfudesc, '') AS uom, + il.stockInLineId AS primaryStockInLineId + FROM inventory_lot il + INNER JOIN items it ON il.itemId = it.id AND it.deleted = 0 + LEFT JOIN item_uom iu ON it.id = iu.itemId AND iu.stockUnit = 1 + LEFT JOIN uom_conversion uc ON iu.uomId = uc.id + WHERE il.id IN (:inventoryLotIds) AND il.deleted = 0 + """.trimIndent() + return jdbcDao.queryForList(query, mapOf("inventoryLotIds" to inventoryLotIds)) + .associate { row -> + val lotId = sql.longVal(row["inventoryLotId"]) ?: return@associate 0L to ItemLotTraceLotInfo( + inventoryLotId = 0L, lotNo = "", itemId = 0L, itemCode = "", itemName = "", + expiryDate = null, productionDate = null, stockInDate = null, uom = "", + primaryStockInLineId = null, + ) + lotId to ItemLotTraceLotInfo( + inventoryLotId = lotId, + lotNo = sql.str(row["lotNo"]), + itemId = sql.longVal(row["itemId"]) ?: lotId, + itemCode = sql.str(row["itemCode"]), + itemName = sql.str(row["itemName"]), + expiryDate = sql.formatDate(row["expiryDate"]), + productionDate = sql.formatDateTime(row["productionDate"]), + stockInDate = sql.formatDateTime(row["stockInDate"]), + uom = sql.str(row["uom"]), + primaryStockInLineId = sql.longVal(row["primaryStockInLineId"]), + ) + } + } + + open fun resolveJobOrderIdForLot(inventoryLotId: Long, primaryStockInLineId: Long?): Long? { + primaryStockInLineId?.let { silId -> + jdbcDao.queryForList( + """ + SELECT jo.id AS jobOrderId FROM stock_in_line sil + INNER JOIN job_order jo ON sil.jobOrderId = jo.id AND jo.deleted = 0 + WHERE sil.id = :primaryStockInLineId AND sil.deleted = 0 + """.trimIndent(), + mapOf("primaryStockInLineId" to silId), + ).firstOrNull()?.let { return sql.longVal(it["jobOrderId"]) } + } + val query = """ + SELECT jo.id AS jobOrderId + FROM stock_in_line sil + INNER JOIN job_order jo ON sil.jobOrderId = jo.id AND jo.deleted = 0 + LEFT JOIN inventory_lot_line ill ON sil.inventoryLotLineId = ill.id AND ill.deleted = 0 + WHERE sil.deleted = 0 + AND sil.jobOrderId IS NOT NULL + AND ( + sil.inventoryLotId = :inventoryLotId + OR ill.inventoryLotId = :inventoryLotId + ) + ORDER BY sil.created DESC + LIMIT 1 + """.trimIndent() + return jdbcDao.queryForList(query, mapOf("inventoryLotId" to inventoryLotId)) + .firstOrNull()?.let { sql.longVal(it["jobOrderId"]) } + } +} diff --git a/src/main/java/com/ffii/fpsms/modules/stock/trace/TraceMovementLoader.kt b/src/main/java/com/ffii/fpsms/modules/stock/trace/TraceMovementLoader.kt new file mode 100644 index 0000000..519a12b --- /dev/null +++ b/src/main/java/com/ffii/fpsms/modules/stock/trace/TraceMovementLoader.kt @@ -0,0 +1,351 @@ +package com.ffii.fpsms.modules.stock.trace + +import com.ffii.core.support.JdbcDao +import com.ffii.fpsms.modules.stock.web.model.* +import org.springframework.stereotype.Component + +@Component +open class TraceMovementLoader( + private val jdbcDao: JdbcDao, + private val sql: TraceSqlSupport, +) { + open fun loadMovements(inventoryLotId: Long, lotLineIds: List): List { + if (lotLineIds.isEmpty()) return emptyList() + val query = """ + SELECT * FROM ( + SELECT + 'IN' AS direction, + COALESCE(sil.acceptedQty, ill.inQty, 0) AS qty, + COALESCE(UPPER(sil.type), 'IN') AS movementType, + CASE + WHEN sil.purchaseOrderId IS NOT NULL THEN 'PO' + WHEN sil.jobOrderId IS NOT NULL THEN 'JO' + WHEN sil.stockTakeLineId IS NOT NULL THEN 'TKE' + WHEN sil.stockTransferId IS NOT NULL THEN 'TRANSFER' + WHEN UPPER(COALESCE(sil.type, '')) IN ('ADJ', 'OPEN') THEN UPPER(sil.type) + ELSE 'STOCK_IN' + END AS refType, + COALESCE(po.code, jo.code, st.code, si.code, '') AS refCode, + COALESCE(po.id, jo.id, st.id, si.id) AS refId, + COALESCE(wh.code, '') AS warehouseCode, + COALESCE(sil.createdBy, '') AS handledBy, + COALESCE( + ( + SELECT MIN(jpo.created) + FROM pick_order po + INNER JOIN jo_pick_order jpo + ON jpo.pick_order_id = po.id AND jpo.deleted = 0 + WHERE po.joId = sil.jobOrderId AND po.deleted = 0 + ), + sil.created, + sil.modified, + sil.receiptDate + ) AS timestamp, + COALESCE(sil.remarks, '') AS remarks, + '' AS processingStatus, + '' AS matchStatus + FROM stock_in_line sil + LEFT JOIN stock_in si ON sil.stockInId = si.id AND si.deleted = 0 + LEFT JOIN purchase_order po ON sil.purchaseOrderId = po.id AND po.deleted = 0 + LEFT JOIN job_order jo ON sil.jobOrderId = jo.id AND jo.deleted = 0 + LEFT JOIN stock_take_line stl ON sil.stockTakeLineId = stl.id AND stl.deleted = 0 + LEFT JOIN stock_take st ON stl.stockTakeId = st.id AND st.deleted = 0 + LEFT JOIN inventory_lot_line ill ON sil.inventoryLotLineId = ill.id AND ill.deleted = 0 + LEFT JOIN warehouse wh ON ill.warehouseId = wh.id AND wh.deleted = 0 + WHERE sil.deleted = 0 + AND ( + sil.inventoryLotId = :inventoryLotId + OR ill.inventoryLotId = :inventoryLotId + ) + + UNION ALL + + SELECT + 'OUT' AS direction, + COALESCE(sol.qty, 0) AS qty, + COALESCE(UPPER(so.type), UPPER(sol.type), 'OUT') AS movementType, + CASE + WHEN sol.pickOrderLineId IS NOT NULL AND UPPER(COALESCE(po.type, '')) = 'DO' THEN 'DO' + WHEN sol.pickOrderLineId IS NOT NULL AND UPPER(COALESCE(po.type, '')) = 'JO' THEN 'JO_PICK' + WHEN sol.pickOrderLineId IS NOT NULL AND po.joId IS NOT NULL THEN 'JO_PICK' + WHEN sol.pickOrderLineId IS NOT NULL AND po.doId IS NOT NULL THEN 'DO' + WHEN sol.pickOrderLineId IS NOT NULL AND dpo.id IS NOT NULL THEN 'DO' + WHEN sol.pickOrderLineId IS NOT NULL AND dpor.id IS NOT NULL THEN 'DO' + WHEN sol.pickOrderLineId IS NOT NULL THEN 'PO_PICK' + WHEN sol.stockTransferId IS NOT NULL THEN 'TRANSFER' + WHEN UPPER(COALESCE(so.type, '')) IN ('ADJ', 'TKE') THEN UPPER(so.type) + ELSE 'STOCK_OUT' + END AS refType, + COALESCE(po.code, do_tbl.code, so.consoPickOrderCode, so.deliveryOrderCode, '') AS refCode, + COALESCE(po.id, do_tbl.id, so.id) AS refId, + COALESCE(wh.code, '') AS warehouseCode, + COALESCE(picker_user.name, sol.createdBy, '') AS handledBy, + COALESCE(sol.endTime, sol.pickTime, sol.created) AS timestamp, + COALESCE(so.remarks, '') AS remarks, + CASE + WHEN sol.status = 'completed' THEN 'completed' + WHEN sol.status = 'rejected' THEN 'rejected' + WHEN sol.status = 'created' THEN 'pending' + ELSE 'pending' + END AS processingStatus, + COALESCE(jpo.match_status, '') AS matchStatus + FROM stock_out_line sol + INNER JOIN stock_out so ON sol.stockOutId = so.id AND so.deleted = 0 + LEFT JOIN pick_order_line pol ON sol.pickOrderLineId = pol.id AND pol.deleted = 0 + LEFT JOIN pick_order po ON pol.poId = po.id AND po.deleted = 0 + LEFT JOIN do_pick_order dpo + ON po.id = dpo.pick_order_id AND dpo.deleted = 0 + LEFT JOIN do_pick_order_record dpor + ON po.id = dpor.pick_order_id AND dpor.deleted = 0 + LEFT JOIN delivery_order do_tbl + ON do_tbl.deleted = 0 + AND do_tbl.id = COALESCE(po.doId, dpo.do_order_id, dpor.do_order_id) + LEFT JOIN inventory_lot_line ill ON sol.inventoryLotLineId = ill.id AND ill.deleted = 0 + LEFT JOIN inventory_lot il ON ill.inventoryLotId = il.id AND il.deleted = 0 + LEFT JOIN jo_pick_order jpo ON jpo.pick_order_id = po.id + AND jpo.item_id = il.itemId AND jpo.deleted = 0 + LEFT JOIN warehouse wh ON ill.warehouseId = wh.id AND wh.deleted = 0 + LEFT JOIN user picker_user ON sol.handled_by = picker_user.id AND picker_user.deleted = 0 + WHERE sol.deleted = 0 AND sol.inventoryLotLineId IN (:lotLineIds) + ) movements + ORDER BY timestamp DESC + """.trimIndent() + return jdbcDao.queryForList( + query, + mapOf("inventoryLotId" to inventoryLotId, "lotLineIds" to lotLineIds), + ).map { row -> + fun col(vararg keys: String): Any? { + for (k in keys) { + if (row.containsKey(k)) return row[k] + } + val lower = row.keys.associateBy { it.lowercase() } + for (k in keys) { + lower[k.lowercase()]?.let { return row[it] } + } + return null + } + ItemLotTraceMovement( + direction = sql.str(col("direction")), + qty = sql.decimal(col("qty")), + movementType = sql.str(col("movementType")), + refType = sql.str(col("refType")), + refCode = sql.str(col("refCode")), + refId = sql.longVal(col("refId")), + warehouseCode = sql.str(col("warehouseCode")), + handledBy = sql.str(col("handledBy")), + timestamp = sql.formatDateTime(col("timestamp")), + remarks = sql.str(col("remarks")), + processingStatus = sql.str(col("processingStatus")), + matchStatus = sql.str(col("matchStatus")), + ) + } + } + + /** FP-MTMS Version Checklist | Functions Ref. No. 25 | v1.0.0 | 2026-07-20 */ + open fun loadStockTakeEvents(lotLineIds: List): List { + if (lotLineIds.isEmpty()) return emptyList() + val query = """ + SELECT + stl.id AS stockTakeLineId, + COALESCE(st.code, '') AS stockTakeCode, + COALESCE(il.lotNo, str.lotNo, '') AS lotNo, + COALESCE(it.code, str.itemCode, '') AS itemCode, + COALESCE(it.name, str.itemName, '') AS itemName, + COALESCE(wh.code, '') AS warehouseCode, + COALESCE(NULLIF(st.stockTakeSection, ''), NULLIF(str.stockTakeSection, ''), '') AS stockTakeSection, + COALESCE(st.stockTakeRoundId, str.stockTakeRoundId) AS stockTakeRoundId, + COALESCE(NULLIF(st.stockTakeRoundName, ''), '') AS stockTakeRoundName, + COALESCE(str.varianceQty, stl.finalQty - stl.initialQty) AS varianceQty, + stl.initialQty AS beforeQty, + stl.finalQty AS afterQty, + COALESCE(NULLIF(str.approverName, ''), NULLIF(str.stockTakerName, ''), '') AS approver, + COALESCE(str.approverTime, stl.completeDate, stl.modified) AS timestamp, + COALESCE(str.bookQty, 0) AS bookQty, + str.pickerFirstStockTakeQty AS pickerFirstQty, + str.pickerFirstBadQty AS pickerFirstBadQty, + str.pickerSecondStockTakeQty AS pickerSecondQty, + str.pickerSecondBadQty AS pickerSecondBadQty, + str.approverStockTakeQty AS approverQty, + str.approverBadQty AS approverBadQty, + str.stockTakerName AS stockTakerName, + NULLIF(str.approverName, '') AS approverName, + str.stockTakeStartTime AS stockTakeStartTime, + str.stockTakeEndTime AS stockTakeEndTime, + str.approverTime AS approverTime, + str.status AS recordStatus, + str.lastSelect AS lastSelect, + str.varianceQty AS recordVarianceQty, + str.id AS stockTakeRecordId + FROM stock_take_line stl + INNER JOIN stock_take st ON stl.stockTakeId = st.id AND st.deleted = 0 + LEFT JOIN stocktakerecord str ON stl.stockTakeRecordId = str.id AND str.deleted = 0 + LEFT JOIN inventory_lot_line ill ON stl.inventoryLotLineId = ill.id AND ill.deleted = 0 + LEFT JOIN inventory_lot il ON ill.inventoryLotId = il.id AND il.deleted = 0 + LEFT JOIN items it ON il.itemId = it.id AND it.deleted = 0 + LEFT JOIN warehouse wh ON ill.warehouseId = wh.id AND wh.deleted = 0 + WHERE stl.deleted = 0 AND stl.inventoryLotLineId IN (:lotLineIds) + ORDER BY timestamp DESC + """.trimIndent() + return jdbcDao.queryForList(query, mapOf("lotLineIds" to lotLineIds)).map { row -> + val stockTakeLineId = sql.longVal(row["stockTakeLineId"]) + val hasRecordDetail = sql.longVal(row["stockTakeRecordId"]) != null + ItemLotTraceStockTakeEvent( + stockTakeCode = sql.str(row["stockTakeCode"]), + lotNo = sql.str(row["lotNo"]), + itemCode = sql.str(row["itemCode"]), + itemName = sql.str(row["itemName"]), + warehouseCode = sql.str(row["warehouseCode"]), + stockTakeSection = sql.str(row["stockTakeSection"]), + stockTakeRoundId = sql.longVal(row["stockTakeRoundId"]), + stockTakeRoundName = sql.str(row["stockTakeRoundName"]), + varianceQty = sql.decimalOrNull(row["varianceQty"]) ?: java.math.BigDecimal.ZERO, + beforeQty = sql.decimalOrNull(row["beforeQty"]) ?: java.math.BigDecimal.ZERO, + afterQty = sql.decimalOrNull(row["afterQty"]), + approver = sql.str(row["approver"]), + timestamp = sql.formatDateTime(row["timestamp"]), + recordDetail = if (hasRecordDetail) { + ItemLotTraceStockTakeRecordDetail( + stockTakeLineId = stockTakeLineId ?: 0L, + bookQty = sql.decimalOrNull(row["bookQty"]) ?: java.math.BigDecimal.ZERO, + pickerFirstQty = sql.decimalOrNull(row["pickerFirstQty"]), + pickerFirstBadQty = sql.decimalOrNull(row["pickerFirstBadQty"]), + pickerSecondQty = sql.decimalOrNull(row["pickerSecondQty"]), + pickerSecondBadQty = sql.decimalOrNull(row["pickerSecondBadQty"]), + approverQty = sql.decimalOrNull(row["approverQty"]), + approverBadQty = sql.decimalOrNull(row["approverBadQty"]), + stockTakerName = sql.strOrNull(row["stockTakerName"]), + approverName = sql.strOrNull(row["approverName"]), + stockTakeStartTime = sql.formatDateTime(row["stockTakeStartTime"]), + stockTakeEndTime = sql.formatDateTime(row["stockTakeEndTime"]), + approverTime = sql.formatDateTime(row["approverTime"]), + recordStatus = sql.strOrNull(row["recordStatus"]), + lastSelect = sql.intVal(row["lastSelect"]), + varianceQty = sql.decimalOrNull(row["recordVarianceQty"]), + ) + } else null, + ) + } + } + + open fun loadAdjustments(inventoryLotId: Long, lotLineIds: List): List { + if (lotLineIds.isEmpty()) return emptyList() + val query = """ + SELECT * FROM ( + SELECT + COALESCE(UPPER(sil.type), 'ADJ') AS adjustmentType, + 'IN' AS direction, + COALESCE(sil.acceptedQty, 0) AS qty, + COALESCE(sil.remarks, '') AS reason, + COALESCE(sil.createdBy, '') AS handledBy, + COALESCE(sil.created, sil.modified, sil.receiptDate) AS timestamp, + COALESCE(si.code, '') AS refCode, + COALESCE(wh.code, '') AS warehouseCode + FROM stock_in_line sil + INNER JOIN stock_in si ON sil.stockInId = si.id AND si.deleted = 0 + LEFT JOIN inventory_lot_line ill ON sil.inventoryLotLineId = ill.id AND ill.deleted = 0 + LEFT JOIN warehouse wh ON ill.warehouseId = wh.id AND wh.deleted = 0 + WHERE sil.deleted = 0 AND sil.inventoryLotId = :inventoryLotId + AND UPPER(COALESCE(sil.type, '')) = 'ADJ' + + UNION ALL + + SELECT + COALESCE(UPPER(so.type), 'ADJ') AS adjustmentType, + 'OUT' AS direction, + COALESCE(sol.qty, 0) AS qty, + COALESCE(so.remarks, '') AS reason, + COALESCE(sol.createdBy, '') AS handledBy, + COALESCE(sol.endTime, sol.created) AS timestamp, + COALESCE(so.consoPickOrderCode, so.deliveryOrderCode, '') AS refCode, + COALESCE(wh.code, '') AS warehouseCode + FROM stock_out_line sol + INNER JOIN stock_out so ON sol.stockOutId = so.id AND so.deleted = 0 + LEFT JOIN inventory_lot_line ill ON sol.inventoryLotLineId = ill.id AND ill.deleted = 0 + LEFT JOIN warehouse wh ON ill.warehouseId = wh.id AND wh.deleted = 0 + WHERE sol.deleted = 0 AND sol.inventoryLotLineId IN (:lotLineIds) + AND UPPER(COALESCE(so.type, sol.type, '')) IN ('ADJ', 'TKE') + ) adj + ORDER BY timestamp DESC + """.trimIndent() + return jdbcDao.queryForList( + query, + mapOf("inventoryLotId" to inventoryLotId, "lotLineIds" to lotLineIds), + ).map { row -> + ItemLotTraceAdjustment( + adjustmentType = sql.str(row["adjustmentType"]), + direction = sql.str(row["direction"]), + qty = sql.decimal(row["qty"]), + reason = sql.str(row["reason"]), + handledBy = sql.str(row["handledBy"]), + timestamp = sql.formatDateTime(row["timestamp"]), + refCode = sql.str(row["refCode"]), + warehouseCode = sql.str(row["warehouseCode"]), + ) + } + } + + open fun loadTransfers(inventoryLotId: Long): List { + val query = """ + SELECT + COALESCE(str.startLocation, wh_from.code, '') AS fromWarehouse, + COALESCE(str.targetLocation, wh_to.code, '') AS toWarehouse, + COALESCE(str.transferredQty, 0) AS qty, + CONCAT('TR-', str.id) AS transferCode, + COALESCE(str.created, str.modified, sil.created, sil.modified, sil.receiptDate) AS timestamp + FROM stock_transfer_record str + LEFT JOIN stock_in_line sil ON str.stockInLineId = sil.id AND sil.deleted = 0 + LEFT JOIN stock_out_line sol ON str.stockOutLineId = sol.id AND sol.deleted = 0 + LEFT JOIN inventory_lot_line ill_from ON sol.inventoryLotLineId = ill_from.id AND ill_from.deleted = 0 + LEFT JOIN warehouse wh_from ON ill_from.warehouseId = wh_from.id AND wh_from.deleted = 0 + LEFT JOIN inventory_lot_line ill_to ON sil.inventoryLotLineId = ill_to.id AND ill_to.deleted = 0 + LEFT JOIN warehouse wh_to ON ill_to.warehouseId = wh_to.id AND wh_to.deleted = 0 + WHERE str.deleted = 0 + AND ( + sil.inventoryLotId = :inventoryLotId + OR ill_from.inventoryLotId = :inventoryLotId + ) + ORDER BY timestamp DESC + """.trimIndent() + return jdbcDao.queryForList(query, mapOf("inventoryLotId" to inventoryLotId)).map { row -> + ItemLotTraceTransfer( + fromWarehouse = sql.str(row["fromWarehouse"]), + toWarehouse = sql.str(row["toWarehouse"]), + qty = sql.decimal(row["qty"]), + transferCode = sql.str(row["transferCode"]), + timestamp = sql.formatDateTime(row["timestamp"]), + ) + } + } + + open fun loadOpenMovements(inventoryLotId: Long): List { + val query = """ + SELECT + sil.id AS stockInLineId, + COALESCE(sil.acceptedQty, 0) AS qty, + COALESCE(wh.code, '') AS warehouseCode, + COALESCE(si.code, '') AS refCode, + COALESCE(sil.createdBy, '') AS handledBy, + COALESCE(sil.created, sil.modified, sil.receiptDate) AS timestamp, + COALESCE(sil.remarks, '') AS remarks + FROM stock_in_line sil + INNER JOIN stock_in si ON sil.stockInId = si.id AND si.deleted = 0 + LEFT JOIN inventory_lot_line ill ON sil.inventoryLotLineId = ill.id AND ill.deleted = 0 + LEFT JOIN warehouse wh ON ill.warehouseId = wh.id AND wh.deleted = 0 + WHERE sil.deleted = 0 + AND sil.inventoryLotId = :inventoryLotId + AND UPPER(COALESCE(sil.type, '')) = 'OPEN' + ORDER BY timestamp DESC + """.trimIndent() + return jdbcDao.queryForList(query, mapOf("inventoryLotId" to inventoryLotId)).map { row -> + ItemLotTraceOpenMovement( + stockInLineId = sql.longVal(row["stockInLineId"]) ?: 0L, + qty = sql.decimal(row["qty"]), + warehouseCode = sql.str(row["warehouseCode"]), + refCode = sql.str(row["refCode"]), + handledBy = sql.str(row["handledBy"]), + timestamp = sql.formatDateTime(row["timestamp"]), + remarks = sql.str(row["remarks"]), + ) + } + } +} diff --git a/src/main/java/com/ffii/fpsms/modules/stock/trace/TraceOutboundLoader.kt b/src/main/java/com/ffii/fpsms/modules/stock/trace/TraceOutboundLoader.kt new file mode 100644 index 0000000..1c4c599 --- /dev/null +++ b/src/main/java/com/ffii/fpsms/modules/stock/trace/TraceOutboundLoader.kt @@ -0,0 +1,431 @@ +package com.ffii.fpsms.modules.stock.trace + +import com.ffii.core.support.JdbcDao +import com.ffii.fpsms.modules.deliveryOrder.service.DeliveryOrderService +import com.ffii.fpsms.modules.deliveryOrder.service.DoReplenishmentService +import com.ffii.fpsms.modules.stock.web.model.* +import org.springframework.stereotype.Component + +@Component +open class TraceOutboundLoader( + private val jdbcDao: JdbcDao, + private val sql: TraceSqlSupport, + private val deliveryOrderService: DeliveryOrderService, + private val doReplenishmentService: DoReplenishmentService, +) { + open fun loadOutboundUsage(inventoryLotId: Long): List { + val materialSql = """ + SELECT + sol.id AS stockOutLineId, + COALESCE(sol.qty, 0) AS qty, + COALESCE(po.code, '') AS pickOrderCode, + po.id AS pickOrderId, + COALESCE(po.consoCode, '') AS consoCode, + COALESCE(jo.code, '') AS jobOrderCode, + jo.id AS jobOrderId, + '' AS deliveryOrderCode, + COALESCE(picker_user.name, jpo_handler_user.name, sol.createdBy, '') AS handler, + COALESCE(sol.endTime, sol.pickTime, sol.created) AS timestamp, + 'MATERIAL' AS usageType + FROM stock_out_line sol + INNER JOIN stock_out so ON sol.stockOutId = so.id AND so.deleted = 0 + AND so.type IN ('job', 'jo') + INNER JOIN inventory_lot_line ill ON sol.inventoryLotLineId = ill.id AND ill.deleted = 0 + INNER JOIN inventory_lot il ON ill.inventoryLotId = il.id AND il.deleted = 0 + LEFT JOIN pick_order_line pol ON sol.pickOrderLineId = pol.id AND pol.deleted = 0 + LEFT JOIN pick_order po ON pol.poId = po.id AND po.deleted = 0 + LEFT JOIN job_order jo ON po.joId = jo.id AND jo.deleted = 0 + LEFT JOIN user picker_user ON sol.handled_by = picker_user.id AND picker_user.deleted = 0 + LEFT JOIN jo_pick_order jpo ON po.id = jpo.pick_order_id AND pol.itemId = jpo.item_id AND jpo.deleted = 0 + LEFT JOIN user jpo_handler_user ON jpo.handled_by = jpo_handler_user.id AND jpo_handler_user.deleted = 0 + WHERE sol.deleted = 0 AND il.id = :inventoryLotId + """.trimIndent() + + val fgSql = """ + SELECT + sol.id AS stockOutLineId, + COALESCE(sol.qty, 0) AS qty, + COALESCE(po.code, '') AS pickOrderCode, + po.id AS pickOrderId, + COALESCE(po.consoCode, '') AS consoCode, + '' AS jobOrderCode, + NULL AS jobOrderId, + COALESCE(do_tbl.code, '') AS deliveryOrderCode, + COALESCE(picker_user.name, sol.createdBy, '') AS handler, + COALESCE(sol.endTime, sol.pickTime, sol.created) AS timestamp, + 'FG_DELIVERY' AS usageType + FROM stock_out_line sol + INNER JOIN stock_out so ON sol.stockOutId = so.id AND so.deleted = 0 AND so.type = 'do' + INNER JOIN inventory_lot_line ill ON sol.inventoryLotLineId = ill.id AND ill.deleted = 0 + INNER JOIN inventory_lot il ON ill.inventoryLotId = il.id AND il.deleted = 0 + LEFT JOIN pick_order_line pol ON sol.pickOrderLineId = pol.id AND pol.deleted = 0 + LEFT JOIN pick_order po ON pol.poId = po.id AND po.deleted = 0 + LEFT JOIN delivery_order do_tbl ON po.doId = do_tbl.id AND do_tbl.deleted = 0 + LEFT JOIN user picker_user ON sol.handled_by = picker_user.id AND picker_user.deleted = 0 + WHERE sol.deleted = 0 AND il.id = :inventoryLotId + """.trimIndent() + + val args = mapOf("inventoryLotId" to inventoryLotId) + val material = jdbcDao.queryForList(materialSql, args) + val fg = jdbcDao.queryForList(fgSql, args) + return (material + fg).map { row -> mapOutboundUsage(row) } + } + + open fun mapOutboundUsage(row: Map): ItemLotTraceOutboundUsage = + ItemLotTraceOutboundUsage( + stockOutLineId = sql.longVal(row["stockOutLineId"]) ?: 0L, + qty = sql.decimal(row["qty"]), + pickOrderCode = sql.str(row["pickOrderCode"]), + pickOrderId = sql.longVal(row["pickOrderId"]), + consoCode = sql.str(row["consoCode"]), + jobOrderCode = sql.str(row["jobOrderCode"]), + jobOrderId = sql.longVal(row["jobOrderId"]), + deliveryOrderCode = sql.str(row["deliveryOrderCode"]), + handler = sql.str(row["handler"]), + timestamp = sql.formatDateTime(row["timestamp"]), + usageType = sql.str(row["usageType"]), + ) + + private fun doDeliverySelectSql(): String = """ + SELECT + sol.id AS stockOutLineId, + COALESCE(sol.qty, 0) AS qty, + COALESCE(po.code, '') AS pickOrderCode, + po.id AS pickOrderId, + COALESCE(po.consoCode, '') AS consoCode, + COALESCE(do_tbl.code, so.deliveryOrderCode, dpor.delivery_order_code, '') AS deliveryOrderCode, + do_tbl.id AS deliveryOrderId, + COALESCE(do_tbl.isExtra, 0) AS deliveryOrderIsExtra, + pol.id AS pickOrderLineId, + pol.qty AS pickOrderLineQty, + ( + SELECT COALESCE(SUM(sol_all.qty), 0) + FROM stock_out_line sol_all + WHERE sol_all.pickOrderLineId = pol.id + AND sol_all.deleted = 0 + ) AS pickOrderLinePickedQty, + il.itemId AS itemId, + COALESCE(dop_direct.ticketNo, dop_conso.ticketNo) AS dopTicketNo, + COALESCE(dpo.ticket_no, dpor.ticket_no, '') AS legacyTicketNo, + COALESCE( + dop_direct.releaseType, + dop_conso.releaseType, + dpo.release_type, + dpor.release_type, + '' + ) AS releaseType, + COALESCE(dop_direct.id, dop_conso.id) AS deliveryOrderPickOrderId, + COALESCE(dop_direct.relationshipId, dop_conso.relationshipId) AS relationshipId, + COALESCE( + dop_direct.deliveryNoteCode, + dop_conso.deliveryNoteCode, + dpor.deliveryNoteCode, + '' + ) AS deliveryNoteCode, + dpor.id AS doPickOrderRecordId, + COALESCE(po.consoCode, '') AS pickOrderConsoCode, + COALESCE(so.consoPickOrderCode, '') AS stockOutConsoCode, + COALESCE(shop_tbl.code, dpo.ShopCode, dpor.ShopCode, '') AS shopCode, + COALESCE(shop_tbl.name, dpo.ShopName, dpor.ShopName, '') AS shopName, + COALESCE(picker_user.name, sol.createdBy, '') AS handler, + COALESCE(sol.endTime, sol.pickTime, sol.created) AS timestamp + FROM stock_out_line sol + INNER JOIN stock_out so ON sol.stockOutId = so.id AND so.deleted = 0 + INNER JOIN inventory_lot_line ill ON sol.inventoryLotLineId = ill.id AND ill.deleted = 0 + INNER JOIN inventory_lot il ON ill.inventoryLotId = il.id AND il.deleted = 0 + LEFT JOIN pick_order_line pol ON sol.pickOrderLineId = pol.id AND pol.deleted = 0 + LEFT JOIN pick_order po ON pol.poId = po.id AND po.deleted = 0 + LEFT JOIN do_pick_order dpo + ON po.id = dpo.pick_order_id AND dpo.deleted = 0 + LEFT JOIN do_pick_order_record dpor + ON po.id = dpor.pick_order_id AND dpor.deleted = 0 + LEFT JOIN delivery_order do_tbl + ON do_tbl.deleted = 0 + AND do_tbl.id = COALESCE(po.doId, dpo.do_order_id, dpor.do_order_id) + LEFT JOIN delivery_order_pick_order dop_direct + ON po.deliveryOrderPickOrderId = dop_direct.id AND dop_direct.deleted = 0 + LEFT JOIN delivery_order_pick_order dop_conso + ON po.consoCode = dop_conso.ticketNo AND dop_conso.deleted = 0 + LEFT JOIN shop shop_tbl ON do_tbl.shopId = shop_tbl.id AND shop_tbl.deleted = 0 + LEFT JOIN user picker_user ON sol.handled_by = picker_user.id AND picker_user.deleted = 0 + """.trimIndent() + + /** FP-MTMS Version Checklist | Functions Ref. No. 24 | v1.0.0 | 2026-07-20 */ + open fun loadDoDeliveries(inventoryLotId: Long): List { + val query = """ + ${doDeliverySelectSql()} + WHERE sol.deleted = 0 AND il.id = :inventoryLotId + AND ( + UPPER(COALESCE(so.type, '')) = 'DO' + OR po.doId IS NOT NULL + OR dpo.id IS NOT NULL + OR dpor.id IS NOT NULL + ) + ORDER BY timestamp DESC + """.trimIndent() + return mapDoDeliveryRows( + jdbcDao.queryForList(query, mapOf("inventoryLotId" to inventoryLotId)), + ) + } + + /** + * FP-MTMS Version Checklist | Functions Ref. No. 24 | v1.0.0 | 2026-07-20 + * Fallback when lot-scoped query misses DO picks still present in movements. + */ + open fun loadDoDeliveriesByPickOrderCodes(pickOrderCodes: List): List { + val codes = pickOrderCodes.map { it.trim() }.filter { it.isNotEmpty() }.distinct() + if (codes.isEmpty()) return emptyList() + val query = """ + ${doDeliverySelectSql()} + WHERE sol.deleted = 0 + AND po.code IN (:pickOrderCodes) + AND ( + UPPER(COALESCE(so.type, '')) = 'DO' + OR po.doId IS NOT NULL + OR dpo.id IS NOT NULL + OR dpor.id IS NOT NULL + ) + ORDER BY timestamp DESC + """.trimIndent() + return mapDoDeliveryRows(jdbcDao.queryForList(query, mapOf("pickOrderCodes" to codes))) + } + + private fun mapDoDeliveryRows(rows: List>): List { + val deliveryOrderIds = rows.mapNotNull { sql.longVal(it["deliveryOrderId"]) }.distinct() + val replenishIndex = doReplenishmentService.buildReplenishPdfIndex(deliveryOrderIds) + return rows.map { row -> mapDoDelivery(row, replenishIndex) } + } + + /** FP-MTMS Version Checklist | Functions Ref. No. 24 | v1.0.0 | 2026-07-20 */ + private fun mapDoDelivery( + row: Map, + replenishIndex: DoReplenishmentService.ReplenishPdfIndex, + ): ItemLotTraceDoDelivery { + val deliveryOrderId = sql.longVal(row["deliveryOrderId"]) + val pickOrderLineId = sql.longVal(row["pickOrderLineId"]) + val itemId = sql.longVal(row["itemId"]) + val ticketNo = resolveOutboundTicketNo( + dopTicketNo = sql.str(row["dopTicketNo"]).takeIf { it.isNotBlank() }, + legacyTicketNo = sql.str(row["legacyTicketNo"]).takeIf { it.isNotBlank() }, + pickOrderConsoCode = sql.str(row["pickOrderConsoCode"]).takeIf { it.isNotBlank() }, + stockOutConsoCode = sql.str(row["stockOutConsoCode"]).takeIf { it.isNotBlank() }, + ) + val releaseType = sql.str(row["releaseType"]).takeIf { it.isNotBlank() } + val deliveryOrderIsExtra = truthy(row["deliveryOrderIsExtra"]) + val pickOrderConsoCode = sql.str(row["pickOrderConsoCode"]).takeIf { it.isNotBlank() } + val dopId = sql.longVal(row["deliveryOrderPickOrderId"]) + val relationshipId = sql.longVal(row["relationshipId"]) + val isExtra = resolveDoOutboundIsExtra( + ticketNo = ticketNo, + pickOrderConsoCode = pickOrderConsoCode, + releaseType = releaseType, + deliveryOrderIsExtra = deliveryOrderIsExtra, + dopId = dopId, + relationshipId = relationshipId, + ) + val isReplenish = deliveryOrderId != null && + replenishIndex.matches(deliveryOrderId, itemId, pickOrderLineId) + // Multi-lot picks: compare POL.qty to SUM(SOL.qty) for that line, not a single SOL. + val pickOrderLineQty = sql.decimalOrNull(row["pickOrderLineQty"]) + val pickOrderLinePickedQty = sql.decimalOrNull(row["pickOrderLinePickedQty"]) + val qtyChanged = + pickOrderLineId != null && + pickOrderLineQty != null && + pickOrderLinePickedQty != null && + pickOrderLineQty.compareTo(pickOrderLinePickedQty) != 0 + return ItemLotTraceDoDelivery( + stockOutLineId = sql.longVal(row["stockOutLineId"]) ?: 0L, + qty = sql.decimal(row["qty"]), + pickOrderCode = sql.str(row["pickOrderCode"]), + pickOrderId = sql.longVal(row["pickOrderId"]), + consoCode = sql.str(row["consoCode"]), + ticketNo = ticketNo.orEmpty(), + deliveryOrderCode = sql.str(row["deliveryOrderCode"]), + deliveryOrderId = deliveryOrderId, + shopCode = sql.str(row["shopCode"]), + shopName = sql.str(row["shopName"]), + handler = sql.str(row["handler"]), + timestamp = sql.formatDateTime(row["timestamp"]), + releaseType = releaseType, + deliveryOrderPickOrderId = dopId, + relationshipId = relationshipId, + deliveryNoteCode = sql.str(row["deliveryNoteCode"]), + doPickOrderRecordId = sql.longVal(row["doPickOrderRecordId"]), + isExtra = isExtra, + isReplenish = isReplenish, + qtyChanged = qtyChanged, + ) + } + + private fun truthy(v: Any?): Boolean = when (v) { + null -> false + is Boolean -> v + is Number -> v.toInt() != 0 + else -> v.toString().equals("true", ignoreCase = true) || v.toString() == "1" + } + + companion object { + /** + * Ticket priority: workbench [delivery_order_pick_order] → legacy GoodPick ticket + * ([do_pick_order] / [do_pick_order_record]) → TI-* conso → other conso. + */ + internal fun resolveOutboundTicketNo( + dopTicketNo: String?, + legacyTicketNo: String? = null, + pickOrderConsoCode: String?, + stockOutConsoCode: String?, + ): String? { + dopTicketNo?.trim()?.takeIf { it.isNotEmpty() }?.let { return it } + legacyTicketNo?.trim()?.takeIf { it.isNotEmpty() }?.let { return it } + return sequenceOf(pickOrderConsoCode, stockOutConsoCode) + .mapNotNull { it?.trim()?.takeIf { s -> s.isNotEmpty() } } + .firstOrNull { it.startsWith("TI-", ignoreCase = true) } + ?: pickOrderConsoCode?.trim()?.takeIf { it.isNotEmpty() } + ?: stockOutConsoCode?.trim()?.takeIf { it.isNotEmpty() } + } + + internal fun isExtraReleaseType(releaseType: String?): Boolean = + when (releaseType?.trim()?.lowercase()) { + "isextra", "isextrabatch", "isextrasingle" -> true + else -> false + } + + internal fun parseTicketTypeLetter(ticketNo: String?): String? { + val parts = ticketNo?.trim()?.split("-").orEmpty() + return parts.getOrNull(1) + } + + internal fun isTiETicket(ticketNo: String?): Boolean = + parseTicketTypeLetter(ticketNo)?.equals("E", ignoreCase = true) == true || + ticketNo?.trim()?.startsWith("TI-E-", ignoreCase = true) == true + + internal fun isTiMTicket(ticketNo: String?): Boolean = + ticketNo?.trim()?.startsWith("TI-M-", ignoreCase = true) == true + + /** + * Aligns with [DeliveryOrderService.isExtraDeliveryTicket]: + * - TI-E / conso TI-E- → extra + * - TI-M → per DO [isExtra], conso TI-E-, or retired TI-E lineage via [relationshipId] + * - Other tickets → releaseType / DO flag + */ + internal fun resolveDoOutboundIsExtra( + ticketNo: String?, + pickOrderConsoCode: String?, + releaseType: String?, + deliveryOrderIsExtra: Boolean, + dopId: Long?, + relationshipId: Long?, + ): Boolean { + val resolvedTicket = ticketNo?.trim()?.takeIf { it.isNotEmpty() } + val conso = pickOrderConsoCode?.trim()?.takeIf { it.isNotEmpty() } + val consoIsTiE = isTiETicket(conso) + + if (isTiMTicket(resolvedTicket)) { + if (deliveryOrderIsExtra) return true + if (consoIsTiE) return true + if (dopId != null && relationshipId != null && relationshipId != dopId) return true + return false + } + + if (isTiETicket(resolvedTicket) || consoIsTiE) return true + + if (isExtraReleaseType(releaseType)) return true + return deliveryOrderIsExtra + } + } + + open fun loadReplenishmentEvents(inventoryLotId: Long): List { + val query = """ + SELECT + dr.id AS replenishmentId, + dr.code AS replenishmentCode, + COALESCE(dr.sourceDoCode, '') AS sourceDoCode, + dr.sourceDoId AS sourceDoId, + COALESCE(dr.shopCode, '') AS shopCode, + COALESCE(dr.shopName, '') AS shopName, + COALESCE(dr.itemNo, '') AS itemNo, + COALESCE(dr.itemName, '') AS itemName, + dr.itemId AS itemId, + COALESCE(dr.reason, '') AS reason, + COALESCE(dr.replenishQty, 0) AS replenishQty, + dr.pickOrderLineId AS pickOrderLineId, + dr.created AS timestamp, + COALESCE(dr.createdBy, '') AS handler, + sol.id AS stockOutLineId + FROM stock_out_line sol + INNER JOIN inventory_lot_line ill ON sol.inventoryLotLineId = ill.id AND ill.deleted = 0 + INNER JOIN inventory_lot il ON ill.inventoryLotId = il.id AND il.deleted = 0 + INNER JOIN pick_order_line pol ON sol.pickOrderLineId = pol.id AND pol.deleted = 0 + INNER JOIN do_replenishment dr ON dr.pickOrderLineId = pol.id AND dr.deleted = 0 + AND dr.itemId = il.itemId + WHERE sol.deleted = 0 AND il.id = :inventoryLotId + ORDER BY dr.created DESC + """.trimIndent() + return jdbcDao.queryForList(query, mapOf("inventoryLotId" to inventoryLotId)) + .map { row -> mapReplenishmentEvent(row) } + } + + private fun mapReplenishmentEvent(row: Map): ItemLotTraceReplenishmentEvent = + ItemLotTraceReplenishmentEvent( + replenishmentId = sql.longVal(row["replenishmentId"]) ?: 0L, + replenishmentCode = sql.str(row["replenishmentCode"]), + sourceDoCode = sql.str(row["sourceDoCode"]), + sourceDoId = sql.longVal(row["sourceDoId"]), + shopCode = sql.str(row["shopCode"]), + shopName = sql.str(row["shopName"]), + itemNo = sql.str(row["itemNo"]), + itemName = sql.str(row["itemName"]), + reason = sql.str(row["reason"]), + replenishQty = sql.decimal(row["replenishQty"]), + handler = sql.str(row["handler"]), + timestamp = sql.formatDateTime(row["timestamp"]), + pickOrderLineId = sql.longVal(row["pickOrderLineId"]), + stockOutLineId = sql.longVal(row["stockOutLineId"]), + ) + + open fun loadReturnEvents(inventoryLotId: Long, lotLineIds: List): List { + if (lotLineIds.isEmpty()) return emptyList() + val query = """ + SELECT + sol.id AS stockOutLineId, + COALESCE(sol.qty, 0) AS qty, + COALESCE(so.consoPickOrderCode, so.deliveryOrderCode, '') AS refCode, + COALESCE(UPPER(so.type), UPPER(sol.type), 'RETURN') AS movementType, + COALESCE(wh.code, '') AS warehouseCode, + COALESCE(picker_user.name, sol.createdBy, '') AS handler, + COALESCE(sol.endTime, sol.pickTime, sol.created) AS timestamp, + COALESCE(so.remarks, '') AS remarks + FROM stock_out_line sol + INNER JOIN stock_out so ON sol.stockOutId = so.id AND so.deleted = 0 + INNER JOIN inventory_lot_line ill ON sol.inventoryLotLineId = ill.id AND ill.deleted = 0 + LEFT JOIN warehouse wh ON ill.warehouseId = wh.id AND wh.deleted = 0 + LEFT JOIN user picker_user ON sol.handled_by = picker_user.id AND picker_user.deleted = 0 + WHERE sol.deleted = 0 + AND ill.inventoryLotId = :inventoryLotId + AND sol.inventoryLotLineId IN (:lotLineIds) + AND ( + UPPER(COALESCE(so.type, '')) IN ('RETURN', 'RTN', 'SUPPLIER_RETURN') + OR UPPER(COALESCE(sol.type, '')) IN ('RETURN', 'RTN') + OR COALESCE(so.remarks, '') LIKE '%退貨%' + OR COALESCE(so.remarks, '') LIKE '%退货%' + OR COALESCE(so.remarks, '') LIKE '%return%' + ) + ORDER BY timestamp DESC + """.trimIndent() + return jdbcDao.queryForList( + query, + mapOf("inventoryLotId" to inventoryLotId, "lotLineIds" to lotLineIds), + ).map { row -> + ItemLotTraceReturnEvent( + stockOutLineId = sql.longVal(row["stockOutLineId"]) ?: 0L, + qty = sql.decimal(row["qty"]), + refCode = sql.str(row["refCode"]), + movementType = sql.str(row["movementType"]), + warehouseCode = sql.str(row["warehouseCode"]), + handler = sql.str(row["handler"]), + timestamp = sql.formatDateTime(row["timestamp"]), + remarks = sql.str(row["remarks"]), + ) + } + } +} diff --git a/src/main/java/com/ffii/fpsms/modules/stock/trace/TraceProductionLoader.kt b/src/main/java/com/ffii/fpsms/modules/stock/trace/TraceProductionLoader.kt new file mode 100644 index 0000000..7117e9d --- /dev/null +++ b/src/main/java/com/ffii/fpsms/modules/stock/trace/TraceProductionLoader.kt @@ -0,0 +1,120 @@ +package com.ffii.fpsms.modules.stock.trace + +import com.ffii.core.support.JdbcDao +import com.ffii.fpsms.modules.stock.web.model.* +import org.springframework.stereotype.Component + +@Component +open class TraceProductionLoader( + private val jdbcDao: JdbcDao, + private val sql: TraceSqlSupport, + private val bomLoader: TraceBomLoader, +) { + open fun loadProductionSteps(jobOrderId: Long): List { + val stepMaterials = bomLoader.loadStepMaterialsByJobOrder(jobOrderId) + val query = """ + SELECT + ppl.id AS processLineId, + ppl.bomProcessId AS bomProcessId, + COALESCE(ppl.name, '') AS stepName, + COALESCE(ppl.description, '') AS description, + COALESCE(e.code, '') AS equipmentCode, + COALESCE(e.name, ppl.equipment_name, '') AS equipmentName, + COALESCE(u.name, '') AS operatorName, + ppl.startTime AS startTime, + ppl.endTime AS endTime, + COALESCE(ppl.outputFromProcessQty, 0) AS outputQty, + COALESCE(ppl.scrapQty, 0) AS scrapQty, + ( + COALESCE(ppl.defectQty, 0) + + COALESCE(ppl.defectQty2, 0) + + COALESCE(ppl.defectQty3, 0) + ) AS defectQty, + COALESCE(ppl.status, '') AS status, + ppl.seqNo AS seqNo + FROM productprocess pp + INNER JOIN productprocessline ppl ON ppl.productprocessid = pp.id AND ppl.deleted = 0 + LEFT JOIN equipment e ON ppl.equipmentId = e.id AND e.deleted = 0 + LEFT JOIN user u ON ppl.operatorId = u.id AND u.deleted = 0 + WHERE pp.jobOrderId = :jobOrderId AND pp.deleted = 0 + ORDER BY ppl.seqNo, ppl.id + """.trimIndent() + return jdbcDao.queryForList(query, mapOf("jobOrderId" to jobOrderId)).map { row -> + val bomProcessId = sql.longVal(row["bomProcessId"]) + ItemLotTraceProductionStep( + processLineId = sql.longVal(row["processLineId"]) ?: 0L, + stepName = sql.str(row["stepName"]), + description = sql.str(row["description"]), + equipmentCode = sql.str(row["equipmentCode"]), + equipmentName = sql.str(row["equipmentName"]), + operatorName = sql.str(row["operatorName"]), + startTime = sql.formatDateTime(row["startTime"]), + endTime = sql.formatDateTime(row["endTime"]), + outputQty = sql.decimal(row["outputQty"]), + scrapQty = sql.decimal(row["scrapQty"]), + defectQty = sql.decimal(row["defectQty"]), + status = sql.str(row["status"]), + seqNo = sql.longVal(row["seqNo"]), + bomProcessId = bomProcessId, + stepMaterials = bomProcessId?.let { stepMaterials[it].orEmpty() } ?: emptyList(), + ) + } + } + + open fun loadByproductLots(jobOrderId: Long, currentItemId: Long): List { + val query = """ + SELECT + il.id AS inventoryLotId, + sil.id AS stockInLineId, + COALESCE(il.lotNo, '') AS lotNo, + COALESCE(bp_it.code, '') AS itemCode, + COALESCE(bp_it.name, '') AS itemName, + COALESCE(sil.acceptedQty, 0) AS qty, + COALESCE(uc.udfudesc, '') AS uom, + COALESCE(ppl.name, '') AS processStepName, + COALESCE(sil.created, sil.modified, sil.receiptDate) AS producedAt, + COALESCE(jo.code, '') AS jobOrderCode, + jo.id AS jobOrderId + FROM job_order jo + INNER JOIN stock_in_line sil ON sil.jobOrderId = jo.id AND sil.deleted = 0 + INNER JOIN inventory_lot il ON sil.inventoryLotId = il.id AND il.deleted = 0 + INNER JOIN items bp_it ON il.itemId = bp_it.id AND bp_it.deleted = 0 + LEFT JOIN item_uom iu ON bp_it.id = iu.itemId AND iu.stockUnit = 1 + LEFT JOIN uom_conversion uc ON iu.uomId = uc.id + LEFT JOIN productprocess pp ON pp.jobOrderId = jo.id AND pp.deleted = 0 + LEFT JOIN productprocessline ppl ON ppl.productprocessid = pp.id + AND ppl.deleted = 0 + AND ppl.byproductId = bp_it.id + WHERE jo.id = :jobOrderId + AND jo.deleted = 0 + AND il.itemId <> :currentItemId + AND EXISTS ( + SELECT 1 + FROM productprocess pp2 + INNER JOIN productprocessline ppl2 ON ppl2.productprocessid = pp2.id AND ppl2.deleted = 0 + WHERE pp2.jobOrderId = jo.id + AND pp2.deleted = 0 + AND ppl2.byproductId = il.itemId + ) + ORDER BY producedAt, il.lotNo + """.trimIndent() + return jdbcDao.queryForList( + query, + mapOf("jobOrderId" to jobOrderId, "currentItemId" to currentItemId), + ).map { row -> + ItemLotTraceByproductLot( + inventoryLotId = sql.longVal(row["inventoryLotId"]), + stockInLineId = sql.longVal(row["stockInLineId"]), + lotNo = sql.str(row["lotNo"]), + itemCode = sql.str(row["itemCode"]), + itemName = sql.str(row["itemName"]), + qty = sql.decimal(row["qty"]), + uom = sql.str(row["uom"]), + processStepName = sql.str(row["processStepName"]), + producedAt = sql.formatDateTime(row["producedAt"]), + jobOrderCode = sql.str(row["jobOrderCode"]), + jobOrderId = sql.longVal(row["jobOrderId"]), + ) + }.distinctBy { "${it.itemCode}:${it.lotNo}" } + } +} diff --git a/src/main/java/com/ffii/fpsms/modules/stock/trace/TraceSqlSupport.kt b/src/main/java/com/ffii/fpsms/modules/stock/trace/TraceSqlSupport.kt new file mode 100644 index 0000000..f4f9d4a --- /dev/null +++ b/src/main/java/com/ffii/fpsms/modules/stock/trace/TraceSqlSupport.kt @@ -0,0 +1,61 @@ +package com.ffii.fpsms.modules.stock.trace + +import org.springframework.stereotype.Component +import java.math.BigDecimal +import java.time.LocalDate +import java.time.LocalDateTime +import java.time.format.DateTimeFormatter + +@Component +open class TraceSqlSupport { + val dateFmt: DateTimeFormatter = DateTimeFormatter.ISO_LOCAL_DATE + val dateTimeFmt: DateTimeFormatter = DateTimeFormatter.ofPattern("yyyy-MM-dd HH:mm:ss") + + open fun str(v: Any?): String = v?.toString()?.trim() ?: "" + + open fun strOrNull(v: Any?): String? = v?.toString()?.trim()?.ifBlank { null } + + open fun longVal(v: Any?): Long? = when (v) { + null -> null + is Number -> v.toLong() + else -> v.toString().toLongOrNull() + } + + open fun intVal(v: Any?): Int? = when (v) { + null -> null + is Number -> v.toInt() + else -> v.toString().toIntOrNull() + } + + open fun decimal(v: Any?): BigDecimal = when (v) { + null -> BigDecimal.ZERO + is BigDecimal -> v + is Number -> BigDecimal.valueOf(v.toDouble()) + else -> v.toString().toBigDecimalOrNull() ?: BigDecimal.ZERO + } + + open fun decimalOrNull(v: Any?): BigDecimal? = when (v) { + null -> null + is BigDecimal -> v + is Number -> BigDecimal.valueOf(v.toDouble()) + else -> v.toString().toBigDecimalOrNull() + } + + open fun formatDate(v: Any?): String? = when (v) { + null -> null + is LocalDate -> v.format(dateFmt) + is java.sql.Date -> v.toLocalDate().format(dateFmt) + is LocalDateTime -> v.toLocalDate().format(dateFmt) + is java.sql.Timestamp -> v.toLocalDateTime().toLocalDate().format(dateFmt) + else -> v.toString().takeIf { it.isNotBlank() } + } + + open fun formatDateTime(v: Any?): String? = when (v) { + null -> null + is LocalDateTime -> v.format(dateTimeFmt) + is java.sql.Timestamp -> v.toLocalDateTime().format(dateTimeFmt) + is LocalDate -> v.atStartOfDay().format(dateTimeFmt) + is java.sql.Date -> v.toLocalDate().atStartOfDay().format(dateTimeFmt) + else -> v.toString().takeIf { it.isNotBlank() } + } +} diff --git a/src/main/java/com/ffii/fpsms/modules/stock/trace/TraceUpstreamLoader.kt b/src/main/java/com/ffii/fpsms/modules/stock/trace/TraceUpstreamLoader.kt new file mode 100644 index 0000000..ba19682 --- /dev/null +++ b/src/main/java/com/ffii/fpsms/modules/stock/trace/TraceUpstreamLoader.kt @@ -0,0 +1,246 @@ +package com.ffii.fpsms.modules.stock.trace + +import com.ffii.core.support.JdbcDao +import org.springframework.stereotype.Component + +/** + * Resolves upstream inventory lots for trace merge after warehouse transfer / lot split. + * Merges PO, JO, QC, putaway history from source lots into the traced (destination) lot view + * via [ItemLotTraceOrchestrator] calling [resolveUpstreamLotIds] (siblings with the same + * lotNo are left to alternateLocations / locationBlocks). + */ +@Component +open class TraceUpstreamLoader( + private val jdbcDao: JdbcDao, + private val sql: TraceSqlSupport, +) { + data class JoProvenance( + val jobOrderId: Long, + val joStockInLineId: Long, + val upstreamLotIds: List, + ) + + /** + * Inbound transfer sources for [inventoryLotId] (this lot received qty via transfer). + * Mirrors [TraceMovementLoader.loadTransfers] lot matching (sil + ill_to on dest, ill_from on source). + */ + open fun findInboundTransferSourceLotIds(inventoryLotId: Long): List { + val query = """ + SELECT ill_from.inventoryLotId AS sourceInventoryLotId + FROM stock_transfer_record str + LEFT JOIN stock_in_line sil ON str.stockInLineId = sil.id AND sil.deleted = 0 + LEFT JOIN stock_out_line sol ON str.stockOutLineId = sol.id AND sol.deleted = 0 + LEFT JOIN inventory_lot_line ill_from ON sol.inventoryLotLineId = ill_from.id AND ill_from.deleted = 0 + LEFT JOIN inventory_lot_line ill_to ON sil.inventoryLotLineId = ill_to.id AND ill_to.deleted = 0 + WHERE str.deleted = 0 + AND ill_from.inventoryLotId IS NOT NULL + AND ill_from.inventoryLotId <> :inventoryLotId + AND ( + sil.inventoryLotId = :inventoryLotId + OR ill_to.inventoryLotId = :inventoryLotId + ) + GROUP BY ill_from.inventoryLotId + ORDER BY MIN(str.id) ASC + """.trimIndent() + return jdbcDao.queryForList(query, mapOf("inventoryLotId" to inventoryLotId)) + .mapNotNull { sql.longVal(it["sourceInventoryLotId"]) } + .distinct() + } + + /** Walk inbound transfer chain and collect every source lot (PO / JO / any origin). */ + open fun collectTransferUpstreamLotIds(tracedLotId: Long, maxDepth: Int = 12): List { + val found = linkedSetOf() + var frontier = listOf(tracedLotId) + val visited = mutableSetOf(tracedLotId) + repeat(maxDepth) { + val sources = frontier.flatMap { lotId -> findInboundTransferSourceLotIds(lotId) } + .filter { it !in visited } + if (sources.isEmpty()) return found.toList() + sources.forEach { id -> + found.add(id) + visited.add(id) + } + frontier = sources + } + return found.toList() + } + + /** Other inventory lots with the same lotNo+itemId (transfer split / multi-warehouse). */ + open fun findSiblingLotIdsBySameLotNo( + itemId: Long, + lotNo: String, + excludeInventoryLotId: Long, + ): List { + if (lotNo.isBlank()) return emptyList() + val query = """ + SELECT il.id AS inventoryLotId + FROM inventory_lot il + WHERE il.deleted = 0 + AND il.itemId = :itemId + AND il.lotNo = :lotNo + AND il.id <> :excludeInventoryLotId + ORDER BY il.stockInDate ASC, il.id ASC + """.trimIndent() + return jdbcDao.queryForList( + query, + mapOf("itemId" to itemId, "lotNo" to lotNo, "excludeInventoryLotId" to excludeInventoryLotId), + ).mapNotNull { sql.longVal(it["inventoryLotId"]) }.distinct() + } + + /** JO receipt lots with same lotNo (legacy helper for JO-only provenance). */ + open fun findJoSourceLotIdsBySameLotNo( + itemId: Long, + lotNo: String, + excludeInventoryLotId: Long, + ): List { + if (lotNo.isBlank()) return emptyList() + val query = """ + SELECT il.id AS inventoryLotId + FROM inventory_lot il + INNER JOIN stock_in_line sil ON sil.inventoryLotId = il.id AND sil.deleted = 0 + WHERE il.deleted = 0 + AND il.itemId = :itemId + AND il.lotNo = :lotNo + AND il.id <> :excludeInventoryLotId + AND sil.jobOrderId IS NOT NULL + GROUP BY il.id + ORDER BY MIN(sil.receiptDate) ASC, il.id ASC + """.trimIndent() + return jdbcDao.queryForList( + query, + mapOf("itemId" to itemId, "lotNo" to lotNo, "excludeInventoryLotId" to excludeInventoryLotId), + ).mapNotNull { sql.longVal(it["inventoryLotId"]) }.distinct() + } + + open fun findJoSourceLotIdsByProductLotNo(tracedInventoryLotId: Long): List { + val query = """ + SELECT il_jo.id AS inventoryLotId + FROM stock_in_line sil_trace + INNER JOIN inventory_lot il_trace ON sil_trace.inventoryLotId = il_trace.id AND il_trace.deleted = 0 + INNER JOIN stock_in_line sil_jo ON sil_jo.deleted = 0 + AND sil_jo.jobOrderId IS NOT NULL + AND sil_jo.productLotNo IS NOT NULL + AND TRIM(sil_jo.productLotNo) <> '' + AND sil_jo.productLotNo = sil_trace.productLotNo + INNER JOIN inventory_lot il_jo ON sil_jo.inventoryLotId = il_jo.id AND il_jo.deleted = 0 + WHERE sil_trace.deleted = 0 + AND sil_trace.inventoryLotId = :tracedInventoryLotId + AND il_jo.id <> :tracedInventoryLotId + GROUP BY il_jo.id + ORDER BY MIN(sil_jo.receiptDate) ASC, il_jo.id ASC + """.trimIndent() + val fromSil = jdbcDao.queryForList(query, mapOf("tracedInventoryLotId" to tracedInventoryLotId)) + .mapNotNull { sql.longVal(it["inventoryLotId"]) } + if (fromSil.isNotEmpty()) return fromSil.distinct() + + val viaLotLine = """ + SELECT il_jo.id AS inventoryLotId + FROM inventory_lot_line ill_trace + INNER JOIN stock_in_line sil_trace ON sil_trace.inventoryLotLineId = ill_trace.id AND sil_trace.deleted = 0 + INNER JOIN stock_in_line sil_jo ON sil_jo.deleted = 0 + AND sil_jo.jobOrderId IS NOT NULL + AND sil_jo.productLotNo IS NOT NULL + AND TRIM(sil_jo.productLotNo) <> '' + AND sil_jo.productLotNo = sil_trace.productLotNo + INNER JOIN inventory_lot il_jo ON sil_jo.inventoryLotId = il_jo.id AND il_jo.deleted = 0 + WHERE ill_trace.deleted = 0 + AND ill_trace.inventoryLotId = :tracedInventoryLotId + AND il_jo.id <> :tracedInventoryLotId + GROUP BY il_jo.id + ORDER BY MIN(sil_jo.receiptDate) ASC, il_jo.id ASC + """.trimIndent() + return jdbcDao.queryForList(viaLotLine, mapOf("tracedInventoryLotId" to tracedInventoryLotId)) + .mapNotNull { sql.longVal(it["inventoryLotId"]) } + .distinct() + } + + /** + * All upstream lots whose inbound history should be merged into the trace response. + */ + open fun resolveUpstreamLotIds(tracedLotId: Long, itemId: Long, lotNo: String): List { + val ids = linkedSetOf() + collectTransferUpstreamLotIds(tracedLotId).forEach { ids.add(it) } + findSiblingLotIdsBySameLotNo(itemId, lotNo, tracedLotId).forEach { ids.add(it) } + findJoSourceLotIdsByProductLotNo(tracedLotId).forEach { ids.add(it) } + return ids.filter { it != tracedLotId } + } + + open fun findJoStockInLineId(inventoryLotId: Long): Long? { + val query = """ + SELECT sil.id AS stockInLineId + FROM stock_in_line sil + LEFT JOIN inventory_lot_line ill ON sil.inventoryLotLineId = ill.id AND ill.deleted = 0 + WHERE sil.deleted = 0 + AND sil.jobOrderId IS NOT NULL + AND ( + sil.inventoryLotId = :inventoryLotId + OR ill.inventoryLotId = :inventoryLotId + ) + ORDER BY sil.receiptDate DESC, sil.id DESC + LIMIT 1 + """.trimIndent() + return jdbcDao.queryForList(query, mapOf("inventoryLotId" to inventoryLotId)) + .firstOrNull()?.let { sql.longVal(it["stockInLineId"]) } + } + + open fun findJobOrderIdForStockInLine(stockInLineId: Long): Long? { + val query = """ + SELECT jo.id AS jobOrderId + FROM stock_in_line sil + INNER JOIN job_order jo ON sil.jobOrderId = jo.id AND jo.deleted = 0 + WHERE sil.id = :stockInLineId AND sil.deleted = 0 + """.trimIndent() + return jdbcDao.queryForList(query, mapOf("stockInLineId" to stockInLineId)) + .firstOrNull()?.let { sql.longVal(it["jobOrderId"]) } + } + + private fun provenanceFromJoLot(joLotId: Long, tracedLotId: Long): JoProvenance? { + val joSil = findJoStockInLineId(joLotId) ?: return null + val joId = findJobOrderIdForStockInLine(joSil) ?: return null + val upstream = if (joLotId == tracedLotId) emptyList() else listOf(joLotId) + return JoProvenance(jobOrderId = joId, joStockInLineId = joSil, upstreamLotIds = upstream) + } + + private fun walkTransferChainForJo(tracedLotId: Long, maxDepth: Int): JoProvenance? { + val pathFromTraced = mutableListOf() + var current = tracedLotId + val visited = mutableSetOf() + + while (current !in visited && pathFromTraced.size < maxDepth) { + visited.add(current) + val joSil = findJoStockInLineId(current) + if (joSil != null) { + val joId = findJobOrderIdForStockInLine(joSil) ?: return null + val upstream = if (current == tracedLotId) { + emptyList() + } else { + (pathFromTraced + current).distinct().filter { it != tracedLotId } + } + return JoProvenance(jobOrderId = joId, joStockInLineId = joSil, upstreamLotIds = upstream) + } + val sources = findInboundTransferSourceLotIds(current).filter { it !in visited } + val next = sources.firstOrNull() ?: break + pathFromTraced.add(next) + current = next + } + return null + } + + open fun resolveJoProvenance( + tracedLotId: Long, + itemId: Long? = null, + lotNo: String? = null, + maxDepth: Int = 12, + ): JoProvenance? { + walkTransferChainForJo(tracedLotId, maxDepth)?.let { return it } + if (itemId != null && !lotNo.isNullOrBlank()) { + findJoSourceLotIdsBySameLotNo(itemId, lotNo, tracedLotId) + .firstOrNull() + ?.let { return provenanceFromJoLot(it, tracedLotId) } + } + findJoSourceLotIdsByProductLotNo(tracedLotId) + .firstOrNull() + ?.let { return provenanceFromJoLot(it, tracedLotId) } + return null + } +} diff --git a/src/main/java/com/ffii/fpsms/modules/stock/web/InventoryController.kt b/src/main/java/com/ffii/fpsms/modules/stock/web/InventoryController.kt index 143d339..20cf216 100644 --- a/src/main/java/com/ffii/fpsms/modules/stock/web/InventoryController.kt +++ b/src/main/java/com/ffii/fpsms/modules/stock/web/InventoryController.kt @@ -4,12 +4,7 @@ import com.ffii.core.response.RecordsRes import com.ffii.fpsms.modules.stock.entity.projection.InventoryInfo import com.ffii.fpsms.modules.stock.service.InventoryService import com.ffii.fpsms.modules.stock.web.model.SearchInventoryRequest -import org.springframework.core.io.ByteArrayResource -import org.springframework.core.io.Resource -import org.springframework.http.HttpHeaders -import org.springframework.http.ResponseEntity import org.springframework.web.bind.annotation.* -import java.time.LocalDate @RestController @RequestMapping("/inventory") @@ -26,14 +21,19 @@ class InventoryController( return inventoryService.allInventoriesByPage(request); } + /** + * FP-MTMS Version Checklist | Functions Ref. No. 19 | v1.0.0 | 2026-07-17 + * Inventory search page only: latest inventory row per item, no baseUnit/uomId filter. + */ + @GetMapping("/searchLatest/getRecordByPage") + fun searchInventoriesByLatestInventory( + @ModelAttribute request: SearchInventoryRequest, + ): RecordsRes { + return inventoryService.searchInventoriesByLatestInventory(request) + } + @PostMapping("/import-bom") fun importInventory() { val reportResult = inventoryService.importInventoryExcel() -// val filename = "bom_excel_issue_log_${LocalDate.now()}.xlsx" - -// return ResponseEntity.ok() -// .header(HttpHeaders.CONTENT_DISPOSITION, "attachment; filename=\"$filename\"") -// .header(HttpHeaders.CONTENT_TYPE, "application/vnd.openxmlformats-officedocument.spreadsheetml.sheet") -// .body(ByteArrayResource(reportResult)) } -} \ No newline at end of file +} diff --git a/src/main/java/com/ffii/fpsms/modules/stock/web/InventoryLotLineController.kt b/src/main/java/com/ffii/fpsms/modules/stock/web/InventoryLotLineController.kt index 46bdf3c..9310e05 100644 --- a/src/main/java/com/ffii/fpsms/modules/stock/web/InventoryLotLineController.kt +++ b/src/main/java/com/ffii/fpsms/modules/stock/web/InventoryLotLineController.kt @@ -7,6 +7,7 @@ import com.ffii.fpsms.modules.stock.entity.StockInLineRepository import com.ffii.fpsms.modules.stock.entity.projection.InventoryLotLineInfo import com.ffii.fpsms.modules.stock.entity.projection.LotLineToQrcode import com.ffii.fpsms.modules.stock.service.InventoryLotLineService +import com.ffii.fpsms.modules.stock.service.ItemLotTraceService import com.ffii.fpsms.modules.stock.service.StockInLineService import com.ffii.fpsms.modules.stock.web.model.ExportQrCodeRequest import com.ffii.fpsms.modules.stock.web.model.LotLineInfo @@ -28,6 +29,8 @@ import com.ffii.fpsms.modules.stock.web.model.PrintLabelForInventoryLotLineReque import com.ffii.fpsms.modules.stock.web.model.UpdateInventoryLotLineStatusRequest import com.ffii.fpsms.modules.stock.web.model.QrCodeAnalysisRequest import com.ffii.fpsms.modules.stock.web.model.QrCodeAnalysisResponse +import com.ffii.fpsms.modules.stock.web.model.ItemLotTraceResponse +import com.ffii.fpsms.modules.stock.web.model.ItemLotTraceLocationBlock import com.ffii.fpsms.modules.stock.web.model.WorkbenchItemLotsResponse import org.springframework.http.HttpStatus import org.springframework.web.server.ResponseStatusException @@ -39,6 +42,7 @@ class InventoryLotLineController ( private val inventoryLotLineService: InventoryLotLineService, private val stockInLineRepository: StockInLineRepository, private val stockInLineService: StockInLineService, + private val itemLotTraceService: ItemLotTraceService, ){ // @PostMapping("/test") @@ -68,18 +72,35 @@ class InventoryLotLineController ( val stockInLine = stockInLineRepository.findById(stockInLineId).orElseThrow { ResponseStatusException(HttpStatus.NOT_FOUND, "stockInLineId $stockInLineId not found") } - val inventoryLotLine = stockInLine.inventoryLotLine!! + // NOR putaway historically may leave inventoryLotLineId null; fall back via inventoryLot. + val inventoryLotLine = stockInLine.inventoryLotLine + ?: stockInLine.inventoryLot?.inventoryLotLines?.firstOrNull() + ?: throw ResponseStatusException( + HttpStatus.NOT_FOUND, + "No inventory lot line for stockInLineId $stockInLineId", + ) val zero = BigDecimal.ZERO + val item = inventoryLotLine.inventoryLot?.item + ?: throw ResponseStatusException( + HttpStatus.NOT_FOUND, + "Item not found for stockInLineId $stockInLineId", + ) + val uom = inventoryLotLine.stockUom?.uom return LotLineInfo( - inventoryLotLineId = inventoryLotLine.id!!, - itemId = inventoryLotLine.inventoryLot!!.item!!.id!!, - itemNo = inventoryLotLine.inventoryLot!!.item!!.code!!, - itemName = inventoryLotLine.inventoryLot!!.item!!.name!!, - lotNo = stockInLine.lotNo!!, + inventoryLotLineId = inventoryLotLine.id + ?: throw ResponseStatusException(HttpStatus.NOT_FOUND, "inventoryLotLine id missing"), + itemId = item.id + ?: throw ResponseStatusException(HttpStatus.NOT_FOUND, "item id missing"), + itemNo = item.code ?: "", + itemName = item.name ?: "", + lotNo = stockInLine.lotNo + ?: inventoryLotLine.inventoryLot?.lotNo + ?: "", remainingQty = (inventoryLotLine.inQty ?: zero) .minus(inventoryLotLine.outQty ?: zero) .minus(inventoryLotLine.holdQty ?: zero), - uom = inventoryLotLine.stockUom!!.uom!!.udfudesc!! + uom = uom?.udfudesc ?: uom?.code ?: "", + uomId = uom?.id, ) } @@ -150,20 +171,52 @@ class InventoryLotLineController ( fun updateInventoryLotLineQuantities(@RequestBody request: UpdateInventoryLotLineQuantitiesRequest): MessageResponse { return inventoryLotLineService.updateInventoryLotLineQuantities(request) } + /** FP-MTMS Version Checklist | Functions Ref. No. 10 | v1.0.0 | 2026-07-17 */ + @GetMapping("/trace") + fun traceLot( + @RequestParam(required = false) stockInLineId: Long?, + @RequestParam(required = false) inventoryLotLineId: Long?, + @RequestParam(required = false) lotNo: String?, + @RequestParam(required = false) itemId: Long?, + @RequestParam(required = false) itemCode: String?, + ): ItemLotTraceResponse { + return itemLotTraceService.trace(stockInLineId, inventoryLotLineId, lotNo, itemId, itemCode) + } + + /** + * FP-MTMS Version Checklist | Functions Ref. No. 10 | v1.0.0 | 2026-07-17 + * Lazy-load: single location trace for an alternate inventory lot. + * Returns location-scoped trace data (movements, origins, QC, outbound, stock take, etc.) + * without the lot-level fields (BOM, JO prelude, production steps, byproducts). + */ + @GetMapping("/trace/location/{inventoryLotId}") + fun traceLocation(@PathVariable inventoryLotId: Long): ItemLotTraceLocationBlock { + return itemLotTraceService.traceLocation(inventoryLotId) + } + @PostMapping("/analyze-qr-code") fun analyzeQrCode(@RequestBody request: QrCodeAnalysisRequest): QrCodeAnalysisResponse { return inventoryLotLineService.analyzeQrCode(request) } - /** DO workbench label modal: same-item lots use pickable qty inQty - outQty (no hold). */ + /** + * FP-MTMS Version Checklist | Functions Ref. No. 34 | v1.0.1 | 2026-07-22 + * DO workbench label modal: same-item lots use pickable qty inQty - outQty (no hold). + */ @PostMapping("/workbench/analyze-qr-code") fun analyzeQrCodeWorkbench(@RequestBody request: QrCodeAnalysisRequest): QrCodeAnalysisResponse { return inventoryLotLineService.analyzeQrCodeWorkbench(request) } - /** Workbench label modal fallback: load same-item available lots without stockInLineId. */ + /** + * FP-MTMS Version Checklist | Functions Ref. No. 34 | v1.0.1 | 2026-07-22 + * Workbench label modal fallback: load same-item available lots without stockInLineId. + */ @GetMapping("/workbench/available-lots-by-item/{itemId}") - fun listWorkbenchAvailableLotsByItem(@PathVariable itemId: Long): WorkbenchItemLotsResponse { - return inventoryLotLineService.listWorkbenchAvailableLotsByItem(itemId) + fun listWorkbenchAvailableLotsByItem( + @PathVariable itemId: Long, + @RequestParam(required = false) uomId: Long?, + ): WorkbenchItemLotsResponse { + return inventoryLotLineService.listWorkbenchAvailableLotsByItem(itemId, uomId) } } \ No newline at end of file diff --git a/src/main/java/com/ffii/fpsms/modules/stock/web/model/ItemLotTraceResponse.kt b/src/main/java/com/ffii/fpsms/modules/stock/web/model/ItemLotTraceResponse.kt new file mode 100644 index 0000000..eceda0c --- /dev/null +++ b/src/main/java/com/ffii/fpsms/modules/stock/web/model/ItemLotTraceResponse.kt @@ -0,0 +1,538 @@ +package com.ffii.fpsms.modules.stock.web.model + +import com.fasterxml.jackson.annotation.JsonProperty +import java.math.BigDecimal + +data class ItemLotTraceLotInfo( + val inventoryLotId: Long, + val lotNo: String, + val itemId: Long, + val itemCode: String, + val itemName: String, + val expiryDate: String?, + val productionDate: String?, + val stockInDate: String?, + val uom: String, + val primaryStockInLineId: Long?, +) + +data class ItemLotTraceWarehouseLine( + val inventoryLotLineId: Long, + val warehouseCode: String, + val inQty: BigDecimal, + val outQty: BigDecimal, + val issueQty: BigDecimal, + val status: String, + val availableQty: BigDecimal, +) + +data class ItemLotTraceOrigin( + val stockInLineId: Long, + val type: String, + val refCode: String, + val refId: Long?, + val supplierCode: String, + val supplierName: String, + val dnNo: String, + val receiptDate: String?, + val acceptedQty: BigDecimal, + val status: String, +) + +/** Purchase order line context before goods receipt (採購). */ +data class ItemLotTracePurchaseEvent( + val purchaseOrderLineId: Long, + val purchaseOrderId: Long?, + val purchaseOrderCode: String, + val itemCode: String, + val itemName: String, + val orderQty: BigDecimal, + val putAwayQty: BigDecimal, + val purchaseUnit: String, + val supplierCode: String, + val supplierName: String, + val orderDate: String?, + val status: String, +) + +data class ItemLotTraceQcResult( + val stockInLineId: Long, + val qcPassed: Boolean, + val failQty: BigDecimal, + val acceptedQty: BigDecimal, + val remarks: String, + val handledBy: String, + val created: String?, + val qcItemCode: String = "", + val qcItemName: String = "", + val qcItemDescription: String = "", + val qcType: String = "", +) + +data class ItemLotTraceMovement( + val direction: String, + val qty: BigDecimal, + val movementType: String, + val refType: String, + val refCode: String, + val refId: Long?, + val warehouseCode: String, + val handledBy: String, + val timestamp: String?, + val remarks: String, + /** Populated for DO refType — pick_order.consoCode / ticket metadata for outbound chips. */ + val pickConsoCode: String = "", + val outboundTicketNo: String = "", + val deliveryOrderPickOrderId: Long? = null, + val relationshipId: Long? = null, + @get:JsonProperty("doOutboundIsExtra") + val doOutboundIsExtra: Boolean = false, + @get:JsonProperty("doOutboundIsReplenish") + val doOutboundIsReplenish: Boolean = false, + @get:JsonProperty("doOutboundQtyChanged") + val doOutboundQtyChanged: Boolean = false, + val processingStatus: String = "", + val matchStatus: String = "", +) + +/** Warehouse putaway: inventory_lot_line created when stock is shelved after receipt/QC. */ +data class ItemLotTracePutawayEvent( + val inventoryLotLineId: Long, + val stockInLineId: Long?, + val warehouseCode: String, + val qty: BigDecimal, + val handledBy: String, + val timestamp: String?, + val refType: String, + val refCode: String, + val refId: Long?, + /** stock_in_line.status — received = pending putaway, completed = put away done. */ + val status: String = "", +) + +data class ItemLotTraceOutboundUsage( + val stockOutLineId: Long, + val qty: BigDecimal, + val pickOrderCode: String, + val pickOrderId: Long?, + val consoCode: String, + val jobOrderCode: String, + val jobOrderId: Long?, + val deliveryOrderCode: String, + val handler: String, + val timestamp: String?, + val usageType: String, +) + +data class ItemLotTraceStockTakeRecordDetail( + val stockTakeLineId: Long, + val bookQty: BigDecimal, + val pickerFirstQty: BigDecimal? = null, + val pickerFirstBadQty: BigDecimal? = null, + val pickerSecondQty: BigDecimal? = null, + val pickerSecondBadQty: BigDecimal? = null, + val approverQty: BigDecimal? = null, + val approverBadQty: BigDecimal? = null, + val stockTakerName: String? = null, + val approverName: String? = null, + val stockTakeStartTime: String? = null, + val stockTakeEndTime: String? = null, + val approverTime: String? = null, + val recordStatus: String? = null, + val lastSelect: Int? = null, + val varianceQty: BigDecimal? = null, +) + +data class ItemLotTraceStockTakeEvent( + val stockTakeCode: String, + val lotNo: String = "", + val itemCode: String = "", + val itemName: String = "", + val warehouseCode: String = "", + val stockTakeSection: String = "", + val stockTakeRoundId: Long? = null, + val stockTakeRoundName: String = "", + val varianceQty: BigDecimal, + val beforeQty: BigDecimal, + val afterQty: BigDecimal? = null, + val approver: String, + val timestamp: String?, + val recordDetail: ItemLotTraceStockTakeRecordDetail? = null, +) + +data class ItemLotTraceAdjustment( + val adjustmentType: String, + val direction: String, + val qty: BigDecimal, + val reason: String, + val handledBy: String, + val timestamp: String?, + val refCode: String, + val warehouseCode: String = "", +) + +data class ItemLotTraceTransfer( + val fromWarehouse: String, + val toWarehouse: String, + val qty: BigDecimal, + val transferCode: String, + val timestamp: String?, +) + +data class ItemLotTraceBomDownstream( + val jobOrderCode: String, + val jobOrderId: Long?, + val finishedItemCode: String, + val finishedLotNo: String, + val finishedStockInLineId: Long?, + val fgQty: BigDecimal, + val fgUom: String = "", + val materialQtyUsed: BigDecimal, +) + +data class ItemLotTraceBomUpstream( + val jobOrderCode: String, + val jobOrderId: Long?, + val materialItemCode: String, + val materialLotNo: String, + val materialStockInLineId: Long?, + val materialQty: BigDecimal, + val bomQtyPerUnit: BigDecimal?, +) + +data class ItemLotTraceBomRecipeLine( + val materialItemCode: String, + val materialItemName: String, + val qtyPerUnit: BigDecimal, + val uom: String, + val bomProcessId: Long? = null, + val bomProcessSeqNo: Long? = null, + val assignedStepName: String = "", +) + +data class ItemLotTraceStepMaterialRef( + val materialItemCode: String, + val materialItemName: String, + val qtyPerUnit: BigDecimal, + val uom: String, +) + +data class ItemLotTraceBomTrace( + val direction: String, + val downstream: List, + val upstream: List, + val bomRecipe: List, +) + +data class ItemLotTraceJoContext( + val jobOrderId: Long, + val jobOrderCode: String, + val planStart: String?, + /** Earliest jo_pick_order.created for this JO (actual work-order creation time). */ + val createdAt: String?, + val reqQty: BigDecimal, + val status: String, +) + +data class ItemLotTraceJoPickLine( + val pickOrderLineId: Long, + val itemCode: String, + val itemName: String, + val requiredQty: BigDecimal, + val pickedQty: BigDecimal, + val status: String, +) + +data class ItemLotTraceJoPickOrder( + val pickOrderId: Long, + val pickOrderCode: String, + val consoCode: String, + val status: String, + val targetDate: String?, + val completeDate: String?, + val releasedDate: String?, + val lines: List, +) + +data class ItemLotTraceMaterialInput( + val jobOrderCode: String, + val jobOrderId: Long?, + val materialItemCode: String, + val materialItemName: String, + val materialLotNo: String, + val materialStockInLineId: Long?, + val materialInventoryLotId: Long?, + val materialQty: BigDecimal, + val materialUom: String = "", + val bomQtyPerUnit: BigDecimal?, + val bomProcessId: Long? = null, + val bomProcessSeqNo: Long? = null, + val assignedStepName: String = "", + val pickOrderCode: String, + val pickOrderId: Long?, + val consoCode: String, + val pickedAt: String?, + val processingStatus: String = "", + val matchStatus: String = "", + val stockInOrigin: ItemLotTraceOrigin?, + val purchaseEvents: List = emptyList(), + val qcResults: List, + val putawayEvents: List = emptyList(), + /** When this material was produced by another JO, its upstream pick/prelude chain. */ + val nestedJoPrelude: ItemLotTraceJoPrelude? = null, + /** Production steps from the JO that produced this material lot. */ + val productionSteps: List = emptyList(), +) + +data class ItemLotTraceJoPrelude( + val jobOrder: ItemLotTraceJoContext, + val pickOrders: List, + val materialInputs: List, +) + +data class ItemLotTraceProductionStep( + val processLineId: Long, + val stepName: String, + val description: String, + val equipmentCode: String, + val equipmentName: String, + val operatorName: String, + val startTime: String?, + val endTime: String?, + val outputQty: BigDecimal, + val scrapQty: BigDecimal, + val defectQty: BigDecimal, + val status: String, + val seqNo: Long?, + val bomProcessId: Long? = null, + val stepMaterials: List = emptyList(), +) + +/** Sibling byproduct lot from the same job order (independent item/lot, traceable). */ +data class ItemLotTraceByproductLot( + val inventoryLotId: Long?, + val stockInLineId: Long?, + val lotNo: String, + val itemCode: String, + val itemName: String, + val qty: BigDecimal, + val uom: String, + val processStepName: String, + val producedAt: String?, + val jobOrderCode: String, + val jobOrderId: Long?, +) + +/** Opening-inventory receipt (type OPEN), distinct from ADJ adjustments. */ +data class ItemLotTraceOpenMovement( + val stockInLineId: Long, + val qty: BigDecimal, + val warehouseCode: String, + val refCode: String, + val handledBy: String, + val timestamp: String?, + val remarks: String, +) + +/** Pick/warehouse failure (missing, broken, etc.) from fail_inventory_lot_line. */ +data class ItemLotTraceFailEvent( + val failId: Long, + val failType: String, + val category: String, + val qty: BigDecimal, + val handlerName: String, + val recordDate: String?, + val pickOrderCode: String, + val pickOrderId: Long?, + val warehouseCode: String = "", +) + +/** Finished-good delivery via DO pick path. */ +data class ItemLotTraceDoDelivery( + val stockOutLineId: Long, + val qty: BigDecimal, + val pickOrderCode: String, + val pickOrderId: Long?, + val consoCode: String, + /** Resolved outbound ticket (dop.ticketNo, legacy ticket_no, or TI-* conso). */ + val ticketNo: String = "", + val deliveryOrderCode: String, + val deliveryOrderId: Long?, + val shopCode: String, + val shopName: String, + val handler: String, + val timestamp: String?, + /** delivery_order_pick_order.releaseType when linked (else legacy release_type). */ + val releaseType: String? = null, + val deliveryOrderPickOrderId: Long? = null, + /** Merge lineage on delivery_order_pick_order (equals own id until retired into TI-M). */ + val relationshipId: Long? = null, + /** DN from workbench dop or legacy do_pick_order_record. */ + val deliveryNoteCode: String = "", + /** Present when outbound came from completed legacy GoodPick record. */ + val doPickOrderRecordId: Long? = null, + /** Matches delivery note sequence suffix (加單). */ + @get:JsonProperty("isExtra") + val isExtra: Boolean = false, + /** Matches delivery note sequence suffix (補貨). */ + @get:JsonProperty("isReplenish") + val isReplenish: Boolean = false, + /** + * True when pick_order_line.qty differs from SUM(stock_out_line.qty) for that line + * (改數). Multi-lot picks are aggregated so splitting across lots alone is not 改數. + */ + @get:JsonProperty("qtyChanged") + val qtyChanged: Boolean = false, +) + +/** DO replenishment created for a source delivery order line (建立補貨). */ +data class ItemLotTraceReplenishmentEvent( + val replenishmentId: Long, + val replenishmentCode: String, + val sourceDoCode: String, + val sourceDoId: Long?, + val shopCode: String, + val shopName: String, + val itemNo: String, + val itemName: String, + val reason: String, + val replenishQty: BigDecimal, + val handler: String, + val timestamp: String?, + val pickOrderLineId: Long?, + /** Stock-out line that fulfilled this replenishment pick (links to DO_OUT node). */ + val stockOutLineId: Long?, +) + +/** Supplier / stock return (outbound not tied to JO pick or DO). */ +data class ItemLotTraceReturnEvent( + val stockOutLineId: Long, + val qty: BigDecimal, + val refCode: String, + val movementType: String, + val warehouseCode: String, + val handler: String, + val timestamp: String?, + val remarks: String, +) + +/** Repack / split: sibling lot linked by transfer, same JO, or non-PO lineage (not independent PO receipts). */ +data class ItemLotTraceLotRelation( + val relationType: String, + val inventoryLotId: Long?, + val lotNo: String, + val itemCode: String, + val itemName: String, + val qty: BigDecimal, + val productLotNo: String, + val timestamp: String?, +) + +/** Same lotNo+itemId may exist on multiple inventory_lot rows (e.g. after transfer / repack). */ +data class ItemLotTraceAlternateLocation( + val inventoryLotId: Long, + val stockInLineId: Long?, + val inventoryLotLineId: Long, + val warehouseCode: String, + val availableQty: BigDecimal, +) + +/** + * Per-location lifecycle trace block for one alternate inventory_lot sharing the same lotNo+itemId. + * + * Contains only location-scoped dimensions (warehouse lines, origins, purchase events, QC results, + * putaway events, movements, outbound usage, stock take events, adjustments, transfers, open + * movements, fail events, DO deliveries, return events). + * + * Does NOT duplicate lot-level dimensions (bomTrace, joPrelude, productionSteps, byproductLots, + * lotRelations, traceGraph) — those are already at the top level of [ItemLotTraceResponse]. + * Does NOT contain [alternateLocations] or nested [locationBlocks] to avoid infinite recursion. + */ +data class ItemLotTraceLocationBlock( + val inventoryLotId: Long, + val lotNo: String, + val itemCode: String, + val itemName: String = "", + val uom: String = "", + val expiryDate: String? = null, + val productionDate: String? = null, + val stockInDate: String? = null, + val stockInLineId: Long?, + val warehouseLines: List, + val origins: List, + val purchaseEvents: List = emptyList(), + val qcResults: List = emptyList(), + val putawayEvents: List = emptyList(), + val movements: List = emptyList(), + val outboundUsage: List = emptyList(), + val stockTakeEvents: List = emptyList(), + val adjustments: List = emptyList(), + val transfers: List = emptyList(), + val openMovements: List = emptyList(), + val failEvents: List = emptyList(), + val doDeliveries: List = emptyList(), + val replenishmentEvents: List = emptyList(), + val returnEvents: List = emptyList(), +) + +data class ItemLotTraceGraphEdge( + val fromKey: String, + val toKey: String, + val kind: String = "FLOW", +) + +data class ItemLotTraceGraphNodeRef( + val key: String, + val kind: String, + val scopeKey: String? = null, + val refCode: String? = null, + val traceLotNo: String? = null, + val traceItemCode: String? = null, + /** Warehouse code for multi-location visual differentiation. Non-null when this node + * originates from an alternate location's trace block. */ + val warehouseCode: String? = null, +) + +data class ItemLotTraceGraph( + val nodes: List, + val edges: List, +) + +/** + * Lot genealogy / operational lifecycle trace response. + * + * v1 commercial scope (intentional): qty, warehouse, QC, document refs, handlers, + * expiry/production dates, multi-location blocks, and compiled flow graph. + * Cost / unit price / currency / inventory valuation and external customer / + * sales-order / invoice fields are out of scope until a finance-module decision. + */ +data class ItemLotTraceResponse( + val lot: ItemLotTraceLotInfo, + val warehouseLines: List, + val origins: List, + val purchaseEvents: List = emptyList(), + val qcResults: List, + val putawayEvents: List, + val movements: List, + val outboundUsage: List, + val stockTakeEvents: List, + val adjustments: List, + val transfers: List, + val bomTrace: ItemLotTraceBomTrace, + val joPrelude: ItemLotTraceJoPrelude?, + val productionSteps: List, + val byproductLots: List, + val openMovements: List, + val failEvents: List, + val doDeliveries: List, + val replenishmentEvents: List = emptyList(), + val returnEvents: List, + val lotRelations: List, + val alternateLocations: List = emptyList(), + /** Per-location full trace blocks for each alternate [inventoryLotId] sharing the same lotNo+itemId. + * Excludes the primary lot (whose data is already at the top level). + * Lot-level dimensions (BOM, JO prelude, production, byproducts, lot relations, traceGraph) + * are NOT duplicated here — see [ItemLotTraceLocationBlock] for what IS included. */ + val locationBlocks: List = emptyList(), + val traceGraph: ItemLotTraceGraph? = null, +) diff --git a/src/main/java/com/ffii/fpsms/modules/stock/web/model/LotLineInfo.kt b/src/main/java/com/ffii/fpsms/modules/stock/web/model/LotLineInfo.kt index 0d5d450..b3cf7ae 100644 --- a/src/main/java/com/ffii/fpsms/modules/stock/web/model/LotLineInfo.kt +++ b/src/main/java/com/ffii/fpsms/modules/stock/web/model/LotLineInfo.kt @@ -2,6 +2,7 @@ package com.ffii.fpsms.modules.stock.web.model import java.math.BigDecimal + data class LotLineInfo( val inventoryLotLineId: Long, val itemId: Long, @@ -9,7 +10,9 @@ data class LotLineInfo( val itemName: String, val lotNo: String, val remainingQty: BigDecimal, - val uom: String + val uom: String, + /** UomConversion id from inventory_lot_line.stockItemUom → item_uom.uom */ + val uomId: Long? = null, ) data class UpdateInventoryLotLineStatusRequest( val inventoryLotLineId: Long, @@ -18,6 +21,8 @@ data class UpdateInventoryLotLineStatusRequest( data class QrCodeAnalysisRequest( val itemId: Long, val stockInLineId: Long, + /** When set, sameItemLots only include lots with this UomConversion id. */ + val uomId: Long? = null, ) data class ScannedLotInfo( @@ -25,7 +30,9 @@ data class ScannedLotInfo( val lotNo: String, val inventoryLotLineId: Long, val warehouseCode: String? = null, - val warehouseName: String? = null + val warehouseName: String? = null, + val uom: String? = null, + val uomId: Long? = null, ) data class SameItemLotInfo( @@ -34,6 +41,7 @@ data class SameItemLotInfo( val stockInLineId: Long? = null, val availableQty: BigDecimal, val uom: String, + val uomId: Long? = null, val warehouseCode: String? = null, val warehouseName: String? = null ) @@ -51,4 +59,4 @@ data class WorkbenchItemLotsResponse( val itemCode: String, val itemName: String, val sameItemLots: List -) \ No newline at end of file +) diff --git a/src/main/java/com/ffii/fpsms/modules/user/service/GroupService.java b/src/main/java/com/ffii/fpsms/modules/user/service/GroupService.java index 79a4f5c..d78acaf 100644 --- a/src/main/java/com/ffii/fpsms/modules/user/service/GroupService.java +++ b/src/main/java/com/ffii/fpsms/modules/user/service/GroupService.java @@ -187,6 +187,7 @@ public class GroupService extends AbstractBaseEntityService> listAuth(Map args) { @@ -200,7 +201,11 @@ public class GroupService extends AbstractBaseEntityService