# Conflicts: # src/main/java/com/ffii/fpsms/modules/jobOrder/service/JoPickOrderService.kt # 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/productProcess/service/ProductProcessService.ktbomUpdateTest
| @@ -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`. | |||
| @@ -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 <range>` → `docs/deploy/commit-plans/`. | |||
| - Commit messages should still be clear; “no message” is not acceptable when we author the commit. | |||
| @@ -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 `<sha>..HEAD`. | |||
| 2. Run from repo root: | |||
| ```bash | |||
| python scripts/generate_commit_test_plans_docx.py <range> | |||
| ``` | |||
| 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` | |||
| @@ -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. = <n> → Page sheet Ref <n> (<page name>)`. | |||
| 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 `本頁(<page>)`. 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: <resolved path> | |||
| Excel: <resolved main path> | |||
| Archive: <resolved archive path> | |||
| 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: <old 3rd version & date> | |||
| G: <old 3rd major changes> | |||
| H: <old 3rd developed by> | |||
| 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: <developer> | |||
| K: <developer> | |||
| 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: <new latest> | |||
| I/J/K: <new latest> | |||
| history slots: empty | |||
| Row NEW (… | Page: 工單提料 / Job Order Pick Execution | Function: CompleteJobOrderRecord — …) | |||
| C: 工單提料 / Job Order Pick Execution | |||
| E: 工單提料執行頁面(/jodetail)。 | |||
| Row NEW (… | Page Ref <n> → 工單提料 / Job Order Pick Execution | Function: CompleteJobOrderRecord — …) | |||
| B: <page Ref. No.> | |||
| 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 | |||
| @@ -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_<sha>_<slug>.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) | |||
| @@ -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` | | |||
| @@ -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 | |||
| @@ -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_<shortsha>_<slug>.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 <last_deployed_sha>..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 <deploy-branch> | |||
| pip install python-docx | |||
| python scripts/generate_commit_test_plans_docx.py <previous_release_sha>..<this_release_sha> | |||
| explorer docs\deploy\commit-plans | |||
| ``` | |||
| (On Mac/Linux, open `docs/deploy/commit-plans` in Finder/files instead of `explorer`.) | |||
| @@ -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 — <short title> | |||
| Date: YYYY-MM-DD | |||
| Branch / build: <branch or tag> | |||
| Author: <name> | |||
| ## 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/<date>_<sha>_<slug>.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. | |||
| @@ -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. | |||
| @@ -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. | |||
| @@ -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. | |||
| @@ -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. | |||
| @@ -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. | |||
| @@ -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. | |||
| @@ -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. | |||
| @@ -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) | | |||
| @@ -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 | |||
| @@ -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,以實機為準並回報補譯 | |||
| @@ -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 | |||
| @@ -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) | |||
| @@ -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 | |||
| @@ -0,0 +1,60 @@ | |||
| <!-- AUTO-GENERATED by scripts/generate_m18_mapping_docs.py — do not edit by hand --> | |||
| # 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 | |||
| ``` | |||
| @@ -0,0 +1,17 @@ | |||
| <!-- AUTO-GENERATED by scripts/generate_m18_mapping_docs.py — do not edit by hand --> | |||
| # 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`) | | |||
| @@ -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 | | |||
| @@ -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` | | |||
| @@ -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` | |||
| @@ -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,以實機為準並回報補譯 | | |||
| @@ -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("<!--"): | |||
| i += 1 | |||
| continue | |||
| if line.strip().startswith("```"): | |||
| if not in_code: | |||
| in_code = True | |||
| code_buf = [] | |||
| else: | |||
| in_code = False | |||
| p = doc.add_paragraph() | |||
| run = p.add_run("\n".join(code_buf)) | |||
| run.font.name = "Consolas" | |||
| run.font.size = Pt(9) | |||
| i += 1 | |||
| continue | |||
| if in_code: | |||
| code_buf.append(line) | |||
| i += 1 | |||
| continue | |||
| if line.startswith("#"): | |||
| level = len(re.match(r"^#+", line).group(0)) | |||
| text = strip_md_inline(re.sub(r"^#+\s*", "", line)) | |||
| if level == 1: | |||
| doc.add_heading(text, level=0) | |||
| else: | |||
| doc.add_heading(text, level=min(level, 3)) | |||
| 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("|"): | |||
| rows.append( | |||
| [strip_md_inline(c) for c in lines[i].strip().strip("|").split("|")] | |||
| ) | |||
| i += 1 | |||
| table = doc.add_table(rows=1 + len(rows), cols=len(header)) | |||
| table.style = "Table Grid" | |||
| for c, h in enumerate(header): | |||
| cell = table.rows[0].cells[c] | |||
| cell.text = h | |||
| for p in cell.paragraphs: | |||
| for r in p.runs: | |||
| r.bold = True | |||
| for r_idx, row in enumerate(rows): | |||
| for c, val in enumerate(row): | |||
| if c < len(header): | |||
| table.rows[r_idx + 1].cells[c].text = val | |||
| doc.add_paragraph() | |||
| continue | |||
| if line.strip().startswith("> "): | |||
| p = doc.add_paragraph() | |||
| p.paragraph_format.left_indent = Cm(0.5) | |||
| add_runs_with_code(p, strip_md_inline(line.strip()[2:])) | |||
| for r in p.runs: | |||
| r.italic = True | |||
| i += 1 | |||
| continue | |||
| if re.match(r"^[-*]\s+", line.strip()): | |||
| text = strip_md_inline(re.sub(r"^[-*]\s+", "", line.strip())) | |||
| p = doc.add_paragraph(style="List Bullet") | |||
| add_runs_with_code(p, text) | |||
| i += 1 | |||
| continue | |||
| if re.match(r"^\d+\.\s+", line.strip()): | |||
| text = strip_md_inline(re.sub(r"^\d+\.\s+", "", line.strip())) | |||
| p = doc.add_paragraph(style="List Number") | |||
| add_runs_with_code(p, text) | |||
| i += 1 | |||
| continue | |||
| if line.strip() == "" or line.strip() == "---": | |||
| i += 1 | |||
| continue | |||
| p = doc.add_paragraph() | |||
| add_runs_with_code(p, strip_md_inline(line)) | |||
| i += 1 | |||
| def write_xlsx(out_path: Path) -> None: | |||
| wb = Workbook() | |||
| # remove default later if we create named sheets first | |||
| header_fill = PatternFill("solid", fgColor="D9D9D9") | |||
| header_font = Font(bold=True, name="Calibri", size=11) | |||
| thin = Border( | |||
| left=Side(style="thin", color="B0B0B0"), | |||
| right=Side(style="thin", color="B0B0B0"), | |||
| top=Side(style="thin", color="B0B0B0"), | |||
| bottom=Side(style="thin", color="B0B0B0"), | |||
| ) | |||
| wrap = Alignment(wrap_text=True, vertical="center") | |||
| def style_sheet(ws, headers: list[str], rows: list[list[str]], title: str) -> None: | |||
| ws["A1"] = title | |||
| ws["A1"].font = Font(bold=True, size=14, name="Calibri") | |||
| ws.merge_cells(start_row=1, start_column=1, end_row=1, end_column=max(len(headers), 1)) | |||
| ws["A2"] = f"Exported {datetime.now(timezone.utc).strftime('%Y-%m-%d %H:%M UTC')}" | |||
| ws["A2"].font = Font(italic=True, color="666666", size=9) | |||
| start = 4 | |||
| for c, h in enumerate(headers, 1): | |||
| cell = ws.cell(start, c, h) | |||
| cell.fill = header_fill | |||
| cell.font = header_font | |||
| cell.border = thin | |||
| cell.alignment = Alignment(wrap_text=True, vertical="center", horizontal="center") | |||
| for r_idx, row in enumerate(rows, start + 1): | |||
| for c, val in enumerate(row, 1): | |||
| cell = ws.cell(r_idx, c, val) | |||
| cell.border = thin | |||
| cell.alignment = wrap | |||
| for c in range(1, len(headers) + 1): | |||
| maxlen = len(headers[c - 1]) | |||
| for row in rows: | |||
| if c - 1 < len(row): | |||
| maxlen = max(maxlen, len(row[c - 1])) | |||
| ws.column_dimensions[get_column_letter(c)].width = min(max(12, maxlen + 2), 48) | |||
| # Collect tables from generated + handbook | |||
| sheets_spec: list[tuple[str, Path]] = [ | |||
| ("ItemType_Sync", ITEM_TYPE_MD), | |||
| ("StSearch", STSEARCH_MD), | |||
| ("Handbook_Tables", HANDBOOK), | |||
| ] | |||
| first = True | |||
| for sheet_name, md_path in sheets_spec: | |||
| if not md_path.is_file(): | |||
| continue | |||
| tables = parse_md_tables(md_path.read_text(encoding="utf-8")) | |||
| if sheet_name == "Handbook_Tables": | |||
| # one sheet per handbook table (limited name length) | |||
| for idx, (sec, headers, rows) in enumerate(tables, 1): | |||
| name = f"H{idx}_{sec[:20]}" if sec else f"H{idx}" | |||
| name = re.sub(r"[\\/*?:\[\]]", "_", name)[:31] | |||
| ws = wb.active if first else wb.create_sheet(name) | |||
| if first: | |||
| ws.title = name | |||
| first = False | |||
| style_sheet(ws, headers, rows, f"{sec or 'Table'} (from handbook)") | |||
| continue | |||
| # For generated files: put Sync mapping as main sheet; other tables as extra sheets | |||
| if not tables: | |||
| continue | |||
| if first: | |||
| ws = wb.active | |||
| ws.title = sheet_name[:31] | |||
| first = False | |||
| else: | |||
| ws = wb.create_sheet(sheet_name[:31]) | |||
| # Prefer table titled Sync mapping / first table | |||
| main = next((t for t in tables if "Sync" in t[0] or "mapping" in t[0].lower()), tables[0]) | |||
| style_sheet(ws, main[1], main[2], main[0] or sheet_name) | |||
| for sec, headers, rows in tables: | |||
| if (sec, headers, rows) == main: | |||
| continue | |||
| extra_name = re.sub(r"[\\/*?:\[\]]", "_", f"{sheet_name[:8]}_{sec}")[:31] | |||
| ws2 = wb.create_sheet(extra_name) | |||
| style_sheet(ws2, headers, rows, sec or extra_name) | |||
| # Readme sheet | |||
| ws = wb.create_sheet("README", 0) | |||
| ws["A1"] = "MTMS (FPSMS) ↔ M18 資料對照 — Excel 匯出" | |||
| ws["A1"].font = Font(bold=True, size=14) | |||
| ws["A3"] = "來源" | |||
| ws["B3"] = "docs/MTMS_M18_DATA_MAPPING.md + docs/generated/*.md" | |||
| ws["A4"] = "重新產生 Markdown 表" | |||
| ws["B4"] = "python scripts/generate_m18_mapping_docs.py" | |||
| ws["A5"] = "重新匯出 Word/Excel" | |||
| ws["B5"] = "python scripts/export_m18_mapping_office.py" | |||
| ws["A7"] = "說明" | |||
| ws["B7"] = ( | |||
| "對照表以 sheet 分開;完整敘述請看 Word 檔 MTMS_M18_DATA_MAPPING.docx。" | |||
| "已知陷阱:M18 udfProducttype=CMB 會落到 items.type=mat(原料)。" | |||
| ) | |||
| ws.column_dimensions["A"].width = 28 | |||
| ws.column_dimensions["B"].width = 80 | |||
| for r in range(3, 8): | |||
| ws.cell(r, 2).alignment = wrap | |||
| out_path.parent.mkdir(parents=True, exist_ok=True) | |||
| wb.save(out_path) | |||
| print(f"Wrote {out_path.relative_to(ROOT)}") | |||
| def main() -> None: | |||
| OUT.mkdir(parents=True, exist_ok=True) | |||
| md_to_docx( | |||
| HANDBOOK, | |||
| OUT / "MTMS_M18_DATA_MAPPING.docx", | |||
| extra_md_files=[ITEM_TYPE_MD, STSEARCH_MD], | |||
| ) | |||
| write_xlsx(OUT / "MTMS_M18_DATA_MAPPING.xlsx") | |||
| if __name__ == "__main__": | |||
| main() | |||
| @@ -0,0 +1,193 @@ | |||
| #!/usr/bin/env python3 | |||
| # -*- coding: utf-8 -*- | |||
| """ | |||
| Export user-facing guides (Markdown) to Word (.docx). | |||
| Usage (from repo root): | |||
| pip install python-docx | |||
| python scripts/export_user_guide_office.py | |||
| Outputs under docs/exports/ | |||
| """ | |||
| 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 | |||
| ROOT = Path(__file__).resolve().parents[1] | |||
| GUIDE_DIR = ROOT / "docs" / "user-guides" | |||
| OUT_DIR = ROOT / "docs" / "exports" | |||
| GUIDES = [ | |||
| # (markdown path, output docx filename — English names avoid Windows garbling) | |||
| ( | |||
| GUIDE_DIR / "MTMS_排程與工單_使用說明.md", | |||
| "MTMS_Schedule_JobOrder_UserGuide.docx", | |||
| ), | |||
| ( | |||
| GUIDE_DIR / "MTMS_BOM_使用說明.md", | |||
| "MTMS_BOM_UserGuide.docx", | |||
| ), | |||
| ( | |||
| GUIDE_DIR / "MTMS_工單提料報工上架_使用說明.md", | |||
| "MTMS_JO_Pick_Production_PutAway_UserGuide.docx", | |||
| ), | |||
| ( | |||
| GUIDE_DIR / "MTMS_送貨訂單與出貨_使用說明.md", | |||
| "MTMS_DO_Shipping_UserGuide.docx", | |||
| ), | |||
| ] | |||
| def strip_md_inline(s: str) -> str: | |||
| s = s.strip() | |||
| s = re.sub(r"\[([^\]]+)\]\([^)]+\)", r"\1", s) | |||
| s = s.replace("**", "").replace("`", "").replace("*", "") | |||
| return s.strip() | |||
| def add_runs(paragraph, text: str) -> None: | |||
| parts = re.split(r"(「[^」]+」)", text) | |||
| for part in 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 part.startswith("「") and part.endswith("」"): | |||
| run.bold = True | |||
| run.font.color.rgb = None | |||
| def md_to_docx(md_path: Path, out_path: Path) -> 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") | |||
| lines = md_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.strip().startswith("```"): | |||
| if not in_code: | |||
| in_code = True | |||
| code_buf = [] | |||
| else: | |||
| in_code = False | |||
| p = doc.add_paragraph() | |||
| run = p.add_run("\n".join(code_buf)) | |||
| run.font.name = "Consolas" | |||
| run.font.size = Pt(9) | |||
| i += 1 | |||
| continue | |||
| if in_code: | |||
| code_buf.append(line) | |||
| i += 1 | |||
| continue | |||
| if line.startswith("#"): | |||
| level = len(re.match(r"^#+", line).group(0)) | |||
| text = strip_md_inline(re.sub(r"^#+\s*", "", line)) | |||
| doc.add_heading(text, level=0 if level == 1 else min(level, 3)) | |||
| 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("|"): | |||
| rows.append( | |||
| [strip_md_inline(c) for c in lines[i].strip().strip("|").split("|")] | |||
| ) | |||
| i += 1 | |||
| table = doc.add_table(rows=1 + len(rows), cols=len(header)) | |||
| table.style = "Table Grid" | |||
| for c, h in enumerate(header): | |||
| cell = table.rows[0].cells[c] | |||
| cell.text = h | |||
| for p in cell.paragraphs: | |||
| for r in p.runs: | |||
| r.bold = True | |||
| for r_idx, row in enumerate(rows): | |||
| for c, val in enumerate(row): | |||
| if c < len(header): | |||
| table.rows[r_idx + 1].cells[c].text = val | |||
| doc.add_paragraph() | |||
| continue | |||
| if line.strip().startswith("> "): | |||
| p = doc.add_paragraph() | |||
| p.paragraph_format.left_indent = Cm(0.4) | |||
| add_runs(p, strip_md_inline(line.strip()[2:])) | |||
| for r in p.runs: | |||
| r.italic = True | |||
| i += 1 | |||
| continue | |||
| if re.match(r"^[-*]\s+", line.strip()): | |||
| p = doc.add_paragraph(style="List Bullet") | |||
| add_runs(p, strip_md_inline(re.sub(r"^[-*]\s+", "", line.strip()))) | |||
| i += 1 | |||
| continue | |||
| if re.match(r"^\d+\.\s+", line.strip()): | |||
| p = doc.add_paragraph(style="List Number") | |||
| add_runs(p, strip_md_inline(re.sub(r"^\d+\.\s+", "", line.strip()))) | |||
| i += 1 | |||
| continue | |||
| if line.strip() in ("", "---"): | |||
| i += 1 | |||
| continue | |||
| p = doc.add_paragraph() | |||
| add_runs(p, strip_md_inline(line)) | |||
| i += 1 | |||
| footer = doc.sections[0].footer.paragraphs[0] | |||
| footer.text = ( | |||
| f"MTMS 使用說明 · {md_path.name} · " | |||
| f"{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 main() -> None: | |||
| OUT_DIR.mkdir(parents=True, exist_ok=True) | |||
| for guide, out_name in GUIDES: | |||
| if not guide.is_file(): | |||
| print(f"Skip missing: {guide}") | |||
| continue | |||
| out = OUT_DIR / out_name | |||
| md_to_docx(guide, out) | |||
| if __name__ == "__main__": | |||
| main() | |||
| @@ -0,0 +1,505 @@ | |||
| #!/usr/bin/env python3 | |||
| # -*- coding: utf-8 -*- | |||
| """ | |||
| Auto-generate a Word (.docx) test plan for each commit in a git range. | |||
| Usage (from repo root): | |||
| pip install python-docx | |||
| python scripts/generate_commit_test_plans_docx.py HEAD~10..HEAD | |||
| python scripts/generate_commit_test_plans_docx.py abc123..def456 --out-dir docs/deploy/commit-plans | |||
| python scripts/generate_commit_test_plans_docx.py HEAD~5..HEAD --also-md | |||
| Output folder (default): docs/deploy/commit-plans/ | |||
| <date>_<shortsha>_<slug>.docx — one file per commit | |||
| _index.md — list of generated plans | |||
| Notes: | |||
| - Plans are heuristic from commit message + touched paths (good starting point for QA). | |||
| - For critical releases, ask the agent to refine steps against the actual diff. | |||
| """ | |||
| from __future__ import annotations | |||
| import argparse | |||
| import re | |||
| import subprocess | |||
| import sys | |||
| from dataclasses import dataclass, field | |||
| 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 | |||
| ROOT = Path(__file__).resolve().parents[1] | |||
| DEFAULT_OUT = ROOT / "docs" / "deploy" / "commit-plans" | |||
| # (path substring, area label, suggested steps, expected results) | |||
| AREA_RULES: list[tuple[str, str, list[tuple[str, str]]]] = [ | |||
| ( | |||
| "deliveryOrder", | |||
| "送貨訂單 / 成品出倉", | |||
| [ | |||
| ("「送貨訂單」依預計送貨日搜索相關單,必要時「批量放單」或詳情「放單」。", "放單成功;產生提料票,無未預期 500。"), | |||
| ("「成品出倉」`/doworkbench`:撳單 → 掃碼提料 → 填箱數列印。", "票可撳、可提、可完成;狀態「待撳單」→「提貨中」→「已完成」。"), | |||
| ("若涉及加單/車線-X:開「加單」分頁與「車線-X」對照。", "票出現在正確樓層/車線;指派篩選與顯示一致。"), | |||
| ], | |||
| ), | |||
| ( | |||
| "pickOrder", | |||
| "提料單 / Workbench", | |||
| [ | |||
| ("「成品出倉」撳單並完成一張提料票(測試庫)。", "掃碼/提交正常;完成後記錄頁可見。"), | |||
| ("「查看提貨情況」核對該票狀態。", "狀態與負責人符合操作。"), | |||
| ], | |||
| ), | |||
| ( | |||
| "SuggestedPickLot", | |||
| "建議批號 / UOM", | |||
| [ | |||
| ("出倉或提料時掃建議批號;刻意掃 UOM 不符批號。", "不符 UOM 被擋並有明確提示;相符批號可提交。"), | |||
| ("標籤列印/批號列表只應出現同 UOM 選項(若本次改動涵蓋)。", "列表無錯誤 UOM 批號。"), | |||
| ], | |||
| ), | |||
| ( | |||
| "job_order", | |||
| "工單", | |||
| [ | |||
| ("「搜索工單/ 建立工單」建立或搜索受影響工單。", "列表/詳情資料正確。"), | |||
| ("依狀態走「放單」→「工單提料」或「工單生產流程」。", "狀態轉換符合預期,無阻塞錯誤。"), | |||
| ], | |||
| ), | |||
| ( | |||
| "JobOrder", | |||
| "工單", | |||
| [ | |||
| ("「搜索工單/ 建立工單」驗證建立/搜索/放單。", "成功提示;狀態正確。"), | |||
| ], | |||
| ), | |||
| ( | |||
| "modules/bom", | |||
| "BOM", | |||
| [ | |||
| ("「設定」→「匯入 BOM」/「BOM 明細」搜索受影響成品。", "可載入明細;啟用狀態正確。"), | |||
| ("「建立工單」下拉是否仍能選該 BOM。", "啟用可選、停用不可選。"), | |||
| ], | |||
| ), | |||
| ( | |||
| "production_schedule", | |||
| "排程", | |||
| [ | |||
| ("「排程」→「生產排程」搜索相關生產日 →「詳細」。", "明細數量合理。"), | |||
| ("(測試庫)「自動生成工單」。", "產生工單或明確錯誤訊息。"), | |||
| ], | |||
| ), | |||
| ( | |||
| "ProductionSchedule", | |||
| "排程", | |||
| [ | |||
| ("「排程」頁驗證預測/搜索/詳細。", "畫面與 API 正常。"), | |||
| ], | |||
| ), | |||
| ( | |||
| "stock", | |||
| "庫存 / 上架 / 出入倉", | |||
| [ | |||
| ("依改動點進「上架掃碼」或相關庫存查詢頁。", "掃碼/查詢結果與庫存數量合理。"), | |||
| ("做一筆小量入/出/調撥(測試庫)。", "成功;庫存異動可查。"), | |||
| ], | |||
| ), | |||
| ( | |||
| "StockIn", | |||
| "來貨 / 品檢", | |||
| [ | |||
| ("「工單生產流程」→「品檢」或待 QC 列表。", "可開品檢;確定後狀態更新。"), | |||
| ], | |||
| ), | |||
| ( | |||
| "putAway", | |||
| "上架", | |||
| [ | |||
| ("「上架掃碼」:貨品 QR → 倉庫 QR →「確定及上架貨物」。", "上架成功;待上架數量減少。"), | |||
| ], | |||
| ), | |||
| ( | |||
| "m18", | |||
| "M18 同步", | |||
| [ | |||
| ("對受影響主檔執行同步或查看最近同步結果(測試環境)。", "對應欄位寫入 MTMS;錯誤有日誌。"), | |||
| ], | |||
| ), | |||
| ( | |||
| "onpack2030", | |||
| "標籤 / OnPack", | |||
| [ | |||
| ("對新增/修改的貨品編號列印標籤(測試機)。", "圖檔/job 正確;可印出。"), | |||
| ], | |||
| ), | |||
| ( | |||
| "db/changelog", | |||
| "資料庫變更", | |||
| [ | |||
| ("部署後確認 Liquibase/changelog 已套用(或啟動 log 無 changeset 失敗)。", "DB schema/資料符合 changeset。"), | |||
| ("用相關畫面或 SQL 抽樣驗證新欄位/約束。", "讀寫正常,無缺欄錯誤。"), | |||
| ], | |||
| ), | |||
| ( | |||
| "modules/master", | |||
| "主檔 (Item/Shop 等)", | |||
| [ | |||
| ("主檔搜索受影響編號,核對顯示欄位。", "名稱/單位/類型等與預期一致。"), | |||
| ], | |||
| ), | |||
| ( | |||
| "Inventory", | |||
| "庫存查詢", | |||
| [ | |||
| ("庫存搜索頁用受影響貨品/倉位查詢。", "批號、數量、單位正確。"), | |||
| ], | |||
| ), | |||
| ] | |||
| KEYWORD_HINTS: list[tuple[re.Pattern[str], tuple[str, str]]] = [ | |||
| ( | |||
| re.compile(r"uom|單位", re.I), | |||
| ("掃一筆 UOM 不符的批號/物料。", "系統拒絕或明確提示;相符者可過。"), | |||
| ), | |||
| ( | |||
| re.compile(r"isextra|etra|加單", re.I), | |||
| ("「成品出倉」→「加單」檢視該日票。", "加單票可見且可撳單。"), | |||
| ), | |||
| ( | |||
| re.compile(r"truck\s*x|車線-?x|車線x", re.I), | |||
| ("核對「車線-X」票在 2/F/4/F 顯示。", "出現在正確樓層區塊,可指派。"), | |||
| ), | |||
| ( | |||
| re.compile(r"label|print|列印|標籤|onpack", re.I), | |||
| ("選打印機後列印標籤/送貨單標籤。", "成功列印;內容正確。"), | |||
| ), | |||
| ( | |||
| re.compile(r"fix|bug|repair|修正|修復", re.I), | |||
| ("重現原問題步驟一次。", "問題不再出現;無新副作用。"), | |||
| ), | |||
| ] | |||
| @dataclass | |||
| class CommitPlan: | |||
| sha: str | |||
| short: str | |||
| subject: str | |||
| body: str | |||
| author: str | |||
| date: str | |||
| files: list[str] = field(default_factory=list) | |||
| areas: list[str] = field(default_factory=list) | |||
| tests: list[tuple[str, str]] = field(default_factory=list) | |||
| def run(cmd: list[str]) -> str: | |||
| r = subprocess.run( | |||
| cmd, | |||
| cwd=ROOT, | |||
| capture_output=True, | |||
| text=True, | |||
| encoding="utf-8", | |||
| errors="replace", | |||
| ) | |||
| if r.returncode != 0: | |||
| raise SystemExit((r.stderr or r.stdout or f"failed: {cmd}").strip()) | |||
| return r.stdout.strip() | |||
| def list_commits(rev_range: str) -> list[str]: | |||
| out = run(["git", "log", "--reverse", "--format=%H", rev_range]) | |||
| return [ln.strip() for ln in out.splitlines() if ln.strip()] | |||
| def load_commit(sha: str) -> CommitPlan: | |||
| subject = run(["git", "log", "-1", "--format=%s", sha]) | |||
| body = run(["git", "log", "-1", "--format=%b", sha]) | |||
| author = run(["git", "log", "-1", "--format=%an", sha]) | |||
| date = run(["git", "log", "-1", "--format=%cs", sha]) | |||
| files_raw = run(["git", "diff-tree", "--no-commit-id", "--name-only", "-r", sha]) | |||
| files = [f for f in files_raw.splitlines() if f.strip()] | |||
| plan = CommitPlan( | |||
| sha=sha, | |||
| short=sha[:8], | |||
| subject=subject or "(no message)", | |||
| body=(body or "").strip(), | |||
| author=author, | |||
| date=date, | |||
| files=files, | |||
| ) | |||
| build_tests(plan) | |||
| return plan | |||
| def build_tests(plan: CommitPlan) -> None: | |||
| seen_areas: set[str] = set() | |||
| seen_steps: set[str] = set() | |||
| tests: list[tuple[str, str]] = [] | |||
| def add(step: str, expected: str) -> None: | |||
| key = step.strip() | |||
| if key in seen_steps: | |||
| return | |||
| seen_steps.add(key) | |||
| tests.append((step, expected)) | |||
| blob = " ".join(plan.files) + "\n" + plan.subject + "\n" + plan.body | |||
| for path_key, area, pairs in AREA_RULES: | |||
| if any(path_key in f.replace("\\", "/") for f in plan.files) or path_key.lower() in blob.lower(): | |||
| if area not in seen_areas: | |||
| seen_areas.add(area) | |||
| plan.areas.append(area) | |||
| for step, exp in pairs: | |||
| add(step, exp) | |||
| for pat, pair in KEYWORD_HINTS: | |||
| if pat.search(blob): | |||
| add(pair[0], pair[1]) | |||
| # Generic fallbacks | |||
| if not tests: | |||
| if plan.files: | |||
| add( | |||
| f"依 commit 變更檔抽樣驗證(共 {len(plan.files)} 個檔)。主要檔:{plan.files[0]}", | |||
| "相關 API/畫面無 500;行為符合 commit 說明。", | |||
| ) | |||
| else: | |||
| add("確認此 commit 無業務檔變更(empty / merge)。", "無需功能測試或僅煙霧測試。") | |||
| add( | |||
| "部署後煙霧:登入系統,開側欄主要入口一次(排程/工單/送貨訂單/成品出倉)。", | |||
| "頁面可開、無全域錯誤橫幅。", | |||
| ) | |||
| add( | |||
| "(回歸)與本改動相鄰但未改的主流程走一輪 Happy path。", | |||
| "無明顯回退。", | |||
| ) | |||
| if not plan.subject or plan.subject.strip().lower() in ("no message", "(no message)"): | |||
| add( | |||
| "向作者確認此 commit 的實際意圖(訊息為 empty/no message)。", | |||
| "補上說明後再簽核上線。", | |||
| ) | |||
| plan.tests = tests | |||
| def slugify(text: str, max_len: int = 40) -> str: | |||
| """ASCII-only filename slug (avoids Windows console / zip garbling).""" | |||
| tokens = re.findall(r"[a-z0-9]+", text.lower()) | |||
| seen: set[str] = set() | |||
| ordered: list[str] = [] | |||
| for t in tokens: | |||
| if t in seen or len(t) < 2: | |||
| continue | |||
| seen.add(t) | |||
| ordered.append(t) | |||
| s = "_".join(ordered)[:max_len].strip("_") | |||
| return s or "commit" | |||
| def set_run_font(run, *, east_asia: str = "Microsoft JhengHei", ascii_font: str = "Calibri", size: Pt | None = None) -> None: | |||
| run.font.name = ascii_font | |||
| run._element.rPr.rFonts.set(qn("w:eastAsia"), east_asia) | |||
| if size is not None: | |||
| run.font.size = size | |||
| def add_para(doc: Document, text: str, *, bold: bool = False, italic: bool = False, size: Pt | None = None) -> None: | |||
| p = doc.add_paragraph() | |||
| run = p.add_run(text) | |||
| set_run_font(run, size=size or Pt(11)) | |||
| run.bold = bold | |||
| run.italic = italic | |||
| def write_docx(plan: CommitPlan, out_path: Path) -> None: | |||
| doc = Document() | |||
| section = doc.sections[0] | |||
| section.top_margin = Cm(1.8) | |||
| section.bottom_margin = Cm(1.8) | |||
| section.left_margin = Cm(2) | |||
| section.right_margin = Cm(2) | |||
| style = doc.styles["Normal"] | |||
| style.font.name = "Calibri" | |||
| style.font.size = Pt(11) | |||
| style._element.rPr.rFonts.set(qn("w:eastAsia"), "Microsoft JhengHei") | |||
| title = doc.add_heading(level=0) | |||
| tr = title.add_run(f"Test plan — {plan.short}") | |||
| set_run_font(tr, size=Pt(18)) | |||
| add_para(doc, plan.subject, bold=True, size=Pt(12)) | |||
| meta = ( | |||
| f"Commit: {plan.sha}\n" | |||
| f"Date: {plan.date} | Author: {plan.author}\n" | |||
| f"Generated: {datetime.now(timezone.utc).strftime('%Y-%m-%d %H:%M UTC')}" | |||
| ) | |||
| add_para(doc, meta, italic=True, size=Pt(9)) | |||
| doc.add_heading("Summary", level=1) | |||
| add_para(doc, plan.subject) | |||
| if plan.body: | |||
| add_para(doc, plan.body) | |||
| doc.add_heading("Areas (auto-detected)", level=1) | |||
| if plan.areas: | |||
| for a in plan.areas: | |||
| doc.add_paragraph(a, style="List Bullet") | |||
| else: | |||
| add_para(doc, "(No area rule matched — generic steps only)") | |||
| doc.add_heading("Files touched", level=1) | |||
| for f in plan.files[:40]: | |||
| doc.add_paragraph(f, style="List Bullet") | |||
| if len(plan.files) > 40: | |||
| add_para(doc, f"… and {len(plan.files) - 40} more files") | |||
| doc.add_heading("Test plan", level=1) | |||
| note = doc.add_paragraph() | |||
| nr = note.add_run( | |||
| "Auto-generated from paths + commit message. Refine before sign-off on critical deploys." | |||
| ) | |||
| set_run_font(nr, size=Pt(9)) | |||
| nr.italic = True | |||
| nr.font.color.rgb = RGBColor(0x66, 0x66, 0x66) | |||
| table = doc.add_table(rows=1 + len(plan.tests), cols=3) | |||
| table.style = "Table Grid" | |||
| headers = ["#", "Steps", "Expected result"] | |||
| for i, h in enumerate(headers): | |||
| cell = table.rows[0].cells[i] | |||
| cell.text = h | |||
| for p in cell.paragraphs: | |||
| for r in p.runs: | |||
| r.bold = True | |||
| set_run_font(r) | |||
| for idx, (step, exp) in enumerate(plan.tests, start=1): | |||
| row = table.rows[idx] | |||
| row.cells[0].text = str(idx) | |||
| row.cells[1].text = step | |||
| row.cells[2].text = exp | |||
| for c in row.cells: | |||
| for p in c.paragraphs: | |||
| for r in p.runs: | |||
| set_run_font(r, size=Pt(10)) | |||
| doc.add_heading("Out of scope / notes", level=1) | |||
| add_para( | |||
| doc, | |||
| "Frontend-only changes may live in FPSMS-frontend — verify paired repo if UI behavior is expected.", | |||
| ) | |||
| add_para(doc, "Rollback: revert this commit or redeploy previous backend build.") | |||
| footer = doc.sections[0].footer.paragraphs[0] | |||
| footer.text = f"MTMS commit test plan · {plan.short} · {plan.date}" | |||
| footer.alignment = WD_ALIGN_PARAGRAPH.CENTER | |||
| out_path.parent.mkdir(parents=True, exist_ok=True) | |||
| doc.save(out_path) | |||
| def write_md(plan: CommitPlan, out_path: Path) -> None: | |||
| lines = [ | |||
| f"# Test plan — {plan.short}", | |||
| "", | |||
| f"**{plan.subject}**", | |||
| "", | |||
| f"- Commit: `{plan.sha}`", | |||
| f"- Date: {plan.date}", | |||
| f"- Author: {plan.author}", | |||
| "", | |||
| "## Areas", | |||
| "", | |||
| ] | |||
| for a in plan.areas or ["(generic)"]: | |||
| lines.append(f"- {a}") | |||
| lines += ["", "## Files", ""] | |||
| for f in plan.files: | |||
| lines.append(f"- `{f}`") | |||
| lines += ["", "## Test plan", "", "| # | Steps | Expected result |", "|---|--------|-----------------|"] | |||
| for i, (s, e) in enumerate(plan.tests, 1): | |||
| lines.append(f"| {i} | {s.replace('|', '/')} | {e.replace('|', '/')} |") | |||
| lines += ["", "## Rollback", "", f"- Revert `{plan.short}` / previous build", ""] | |||
| out_path.write_text("\n".join(lines), encoding="utf-8") | |||
| def write_index(out_dir: Path, plans: list[CommitPlan], files: list[str]) -> None: | |||
| lines = [ | |||
| "# Commit test plans (auto-generated)", | |||
| "", | |||
| f"Generated: {datetime.now(timezone.utc).strftime('%Y-%m-%d %H:%M UTC')}", | |||
| "", | |||
| "| Date | SHA | Subject | Word |", | |||
| "|------|-----|---------|------|", | |||
| ] | |||
| for plan, name in zip(plans, files): | |||
| subj = plan.subject.replace("|", "/") | |||
| lines.append(f"| {plan.date} | `{plan.short}` | {subj} | [{name}]({name}) |") | |||
| lines.append("") | |||
| (out_dir / "_index.md").write_text("\n".join(lines), encoding="utf-8") | |||
| def main() -> None: | |||
| ap = argparse.ArgumentParser(description="Generate Word test plans per commit") | |||
| ap.add_argument( | |||
| "range", | |||
| help="Git revision range, e.g. HEAD~10..HEAD (use A..B exclusive-start semantics)", | |||
| ) | |||
| ap.add_argument( | |||
| "--out-dir", | |||
| type=Path, | |||
| default=DEFAULT_OUT, | |||
| help=f"Output folder (default: {DEFAULT_OUT.relative_to(ROOT)})", | |||
| ) | |||
| ap.add_argument("--also-md", action="store_true", help="Also write .md next to each .docx") | |||
| ap.add_argument( | |||
| "--limit", | |||
| type=int, | |||
| default=0, | |||
| help="Max commits to process (0 = all)", | |||
| ) | |||
| args = ap.parse_args() | |||
| out_dir = args.out_dir if args.out_dir.is_absolute() else ROOT / args.out_dir | |||
| out_dir.mkdir(parents=True, exist_ok=True) | |||
| shas = list_commits(args.range) | |||
| if args.limit and args.limit > 0: | |||
| shas = shas[-args.limit :] | |||
| if not shas: | |||
| print("No commits in range.", file=sys.stderr) | |||
| sys.exit(1) | |||
| plans: list[CommitPlan] = [] | |||
| names: list[str] = [] | |||
| for sha in shas: | |||
| plan = load_commit(sha) | |||
| plans.append(plan) | |||
| # Stable name per commit (re-run overwrites same SHA) | |||
| fname = f"{plan.date}_{plan.short}_{slugify(plan.subject)}.docx" | |||
| target = out_dir / fname | |||
| write_docx(plan, target) | |||
| names.append(target.name) | |||
| print(f"Wrote {target.relative_to(ROOT)}") | |||
| if args.also_md: | |||
| md_path = target.with_suffix(".md") | |||
| write_md(plan, md_path) | |||
| print(f"Wrote {md_path.relative_to(ROOT)}") | |||
| write_index(out_dir, plans, names) | |||
| print(f"Wrote { (out_dir / '_index.md').relative_to(ROOT) }") | |||
| print(f"Done: {len(plans)} plan(s) → {out_dir.relative_to(ROOT)}") | |||
| if __name__ == "__main__": | |||
| main() | |||
| @@ -0,0 +1,120 @@ | |||
| #!/usr/bin/env python3 | |||
| # -*- coding: utf-8 -*- | |||
| """ | |||
| Scaffold a deploy / QA note from a git commit range. | |||
| Usage (from repo root): | |||
| python scripts/generate_deploy_test_plan.py HEAD~5..HEAD | |||
| python scripts/generate_deploy_test_plan.py abc1234..def5678 --out docs/deploy/20260801_example.md | |||
| The script lists commits and touched files. You (or the agent) still fill | |||
| test steps and expected results after reading the diffs. | |||
| """ | |||
| from __future__ import annotations | |||
| import argparse | |||
| import subprocess | |||
| import sys | |||
| from datetime import date | |||
| from pathlib import Path | |||
| ROOT = Path(__file__).resolve().parents[1] | |||
| def run(args: list[str]) -> str: | |||
| r = subprocess.run( | |||
| args, | |||
| cwd=ROOT, | |||
| capture_output=True, | |||
| text=True, | |||
| encoding="utf-8", | |||
| errors="replace", | |||
| ) | |||
| if r.returncode != 0: | |||
| raise SystemExit(r.stderr or r.stdout or f"command failed: {args}") | |||
| return r.stdout.strip() | |||
| def main() -> None: | |||
| p = argparse.ArgumentParser(description="Scaffold deploy test plan from git range") | |||
| p.add_argument( | |||
| "range", | |||
| help="Git revision range, e.g. HEAD~5..HEAD or origin/production..HEAD", | |||
| ) | |||
| p.add_argument( | |||
| "--out", | |||
| type=Path, | |||
| default=None, | |||
| help="Optional output path under docs/deploy/", | |||
| ) | |||
| p.add_argument("--title", default="Deploy note (draft)", help="Document title") | |||
| args = p.parse_args() | |||
| log = run(["git", "log", "--oneline", args.range]) | |||
| if not log: | |||
| print("No commits in range.", file=sys.stderr) | |||
| sys.exit(1) | |||
| stat = run(["git", "diff", "--stat", args.range]) | |||
| name_status = run(["git", "diff", "--name-status", args.range]) | |||
| commits = [ln for ln in log.splitlines() if ln.strip()] | |||
| commit_bullets = "\n".join(f"- `{c[:7]}` — {c[8:]}" for c in commits) | |||
| body = f"""# {args.title} | |||
| Date: {date.today().isoformat()} | |||
| Branch / build: (fill) | |||
| Range: `{args.range}` | |||
| Author: (fill) | |||
| > Auto-scaffolded from git. **Replace the Test plan with real steps** after reviewing the diff. | |||
| ## Summary | |||
| - (TODO: 1–3 bullets — user-facing impact) | |||
| ## Scope | |||
| - Backend: (see files below) | |||
| - Frontend: (check paired repo if UI) | |||
| - DB / Liquibase: (none / list changelog files) | |||
| - Config / ops: (none / list) | |||
| ## Commits | |||
| {commit_bullets} | |||
| ## Files touched | |||
| ``` | |||
| {name_status} | |||
| ``` | |||
| ### Diffstat | |||
| ``` | |||
| {stat} | |||
| ``` | |||
| ## Test plan | |||
| | # | Steps (who / where / data) | Expected result | | |||
| |---|----------------------------|-----------------| | |||
| | 1 | TODO — happy path | TODO | | |||
| | 2 | TODO — edge / failure case | TODO | | |||
| | 3 | TODO — regression on related screen | TODO | | |||
| ## Out of scope / not tested | |||
| - (TODO) | |||
| ## Rollback | |||
| - Revert range `{args.range}` / redeploy previous build | |||
| """ | |||
| if args.out: | |||
| out = args.out if args.out.is_absolute() else ROOT / args.out | |||
| out.parent.mkdir(parents=True, exist_ok=True) | |||
| out.write_text(body, encoding="utf-8") | |||
| print(f"Wrote {out.relative_to(ROOT)}") | |||
| else: | |||
| sys.stdout.reconfigure(encoding="utf-8") | |||
| print(body) | |||
| if __name__ == "__main__": | |||
| main() | |||
| @@ -0,0 +1,239 @@ | |||
| #!/usr/bin/env python3 | |||
| # -*- coding: utf-8 -*- | |||
| """ | |||
| Generate MTMS ↔ M18 mapping snippets from Kotlin source of truth. | |||
| Usage (from repo root): | |||
| python scripts/generate_m18_mapping_docs.py | |||
| Outputs: | |||
| docs/generated/m18-item-type-mapping.md | |||
| docs/generated/m18-stsearch-types.md | |||
| Re-run after changing: | |||
| - modules/master/web/models/NewItemRequest.kt (ItemType / M18ItemType) | |||
| - m18/model/M18MasterDataRequest.kt (StSearchType) | |||
| - m18/service/M18MasterDataService.kt (udfProducttype when-branches) | |||
| """ | |||
| from __future__ import annotations | |||
| import re | |||
| from datetime import datetime, timezone | |||
| from pathlib import Path | |||
| ROOT = Path(__file__).resolve().parents[1] | |||
| OUT_DIR = ROOT / "docs" / "generated" | |||
| NEW_ITEM_REQUEST = ( | |||
| ROOT | |||
| / "src/main/java/com/ffii/fpsms/modules/master/web/models/NewItemRequest.kt" | |||
| ) | |||
| MASTER_DATA_REQUEST = ( | |||
| ROOT / "src/main/java/com/ffii/fpsms/m18/model/M18MasterDataRequest.kt" | |||
| ) | |||
| MASTER_DATA_SERVICE = ( | |||
| ROOT / "src/main/java/com/ffii/fpsms/m18/service/M18MasterDataService.kt" | |||
| ) | |||
| INVENTORY_I18N = ( | |||
| ROOT.parent / "FPSMS-frontend" / "src" / "i18n" / "zh" / "inventory.json" | |||
| ) | |||
| # UI labels when inventory.json is unavailable (fallback) | |||
| FALLBACK_UI = { | |||
| "mat": "原料", | |||
| "consumables": "消耗品", | |||
| "non-consumables": "非消耗品", | |||
| "fg": "成品", | |||
| "sfg": "半成品", | |||
| "item": "貨品", | |||
| "cmb": "消耗品", | |||
| "wip": "半成品", | |||
| "nm": "雜項及非消耗品", | |||
| } | |||
| # Known M18 udfProducttype values seen in the wild that are NOT in M18ItemType | |||
| # (documented as gaps so ops/dev notice). | |||
| KNOWN_UNMAPPED_M18_VALUES = [ | |||
| ("CMB", "Seen on M18 pro.udfProducttype (e.g. MG1852). Falls through to mat."), | |||
| ] | |||
| def parse_kotlin_string_enum(text: str, enum_name: str) -> list[tuple[str, str]]: | |||
| """Parse active (non-commented) `enum class Foo(...) { NAME("x"), ... }`.""" | |||
| # Only match enum declarations that start a line (optional indent), not //enum | |||
| m = re.search( | |||
| rf"(?m)^[ \t]*enum class {re.escape(enum_name)}\([^)]*\)\s*\{{(.*?)^[ \t]*\}}", | |||
| text, | |||
| re.DOTALL, | |||
| ) | |||
| if not m: | |||
| raise SystemExit(f"Could not find enum class {enum_name}") | |||
| body = m.group(1) | |||
| return re.findall(r"(\w+)\s*\(\s*\"([^\"]+)\"\s*\)", body) | |||
| def parse_producttype_when_branches(service_text: str) -> list[tuple[str, str]]: | |||
| """ | |||
| Extract first `when (pro.udfProducttype) { M18ItemType.X.type -> ItemType.Y.type ... }` | |||
| Returns list of (M18ItemTypeConst, ItemTypeConst). | |||
| """ | |||
| m = re.search( | |||
| r"when\s*\(\s*pro\.udfProducttype\s*\)\s*\{(.*?)else\s*->\s*ItemType\.(\w+)\.type", | |||
| service_text, | |||
| re.DOTALL, | |||
| ) | |||
| if not m: | |||
| raise SystemExit("Could not find udfProducttype when-branch in M18MasterDataService") | |||
| body, else_item = m.group(1), m.group(2) | |||
| pairs = re.findall( | |||
| r"M18ItemType\.(\w+)\.type\s*->\s*ItemType\.(\w+)\.type", | |||
| body, | |||
| ) | |||
| return pairs + [("__else__", else_item)] | |||
| def load_ui_labels() -> dict[str, str]: | |||
| labels = dict(FALLBACK_UI) | |||
| if not INVENTORY_I18N.is_file(): | |||
| return labels | |||
| # Minimal JSON-ish extract of "key": "value" string pairs | |||
| text = INVENTORY_I18N.read_text(encoding="utf-8") | |||
| for k, v in re.findall(r'"([^"]+)"\s*:\s*"([^"]*)"', text): | |||
| labels[k] = v | |||
| return labels | |||
| def write_item_type_doc( | |||
| item_types: list[tuple[str, str]], | |||
| m18_types: list[tuple[str, str]], | |||
| when_pairs: list[tuple[str, str]], | |||
| ui: dict[str, str], | |||
| ) -> None: | |||
| item_by_const = {c: v for c, v in item_types} | |||
| m18_by_const = {c: v for c, v in m18_types} | |||
| mapped_m18_consts = {a for a, b in when_pairs if a != "__else__"} | |||
| lines: list[str] = [] | |||
| lines.append("<!-- AUTO-GENERATED by scripts/generate_m18_mapping_docs.py — do not edit by hand -->") | |||
| 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("<!-- AUTO-GENERATED by scripts/generate_m18_mapping_docs.py — do not edit by hand -->") | |||
| 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() | |||
| @@ -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"))) | |||
| @@ -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?>): 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, | |||
| @@ -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) { | |||
| @@ -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) | |||
| } | |||
| } | |||
| @@ -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<WorkbenchEtraShopLaneGroup> { | |||
| 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<Map<String, Any?>> = 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<EtraAgg> { 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<ReleasedDoPickOrderListItem> = | |||
| 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<ReleasedDoPickOrderListItem> = | |||
| 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<String, Any?>, dopoId: Long): Map<String, Any?> { | |||
| 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<ReleasedDoPickOrderListItem> { | |||
| val today = LocalDate.now() | |||
| val params = mutableMapOf<String, Any>() | |||
| @@ -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<Map<String, Any?>> = 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<Any>()) | |||
| } | |||
| // 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, | |||
| @@ -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<String, Any>, | |||
| ) { | |||
| 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<String, Any?>): MergeableWorkbenchTicket? { | |||
| fun cell(name: String): String? { | |||
| val key = row.keys.find { it.equals(name, true) } ?: return null | |||
| @@ -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<String> = 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 | |||
| @@ -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<ReleasedDoPickOrderListItem> { | |||
| 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<ReleasedDoPickOrderListItem> { | |||
| return doWorkbenchMainService.findWorkbenchReleasedDeliveryOrderPickOrdersForSelectionToday( | |||
| shopName, | |||
| @@ -138,6 +143,7 @@ class DoWorkbenchController( | |||
| truck, | |||
| requiredDeliveryDate = requiredDate, | |||
| releaseTypeFilter = releaseType, | |||
| floor = floor, | |||
| ) | |||
| } | |||
| @@ -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?, | |||
| @@ -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, | |||
| ) | |||
| @@ -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<String, Any?> { | |||
| 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<Map<St | |||
| JOIN fpsmsdb.items i ON i.id = pol.itemId | |||
| LEFT JOIN fpsmsdb.uom_conversion uc ON uc.id = pol.uomId | |||
| LEFT JOIN fpsmsdb.suggested_pick_lot spl ON pol.id = spl.pickOrderLineId | |||
| LEFT JOIN fpsmsdb.inventory_lot_line ill ON spl.suggestedLotLineId = ill.id | |||
| LEFT JOIN fpsmsdb.inventory_lot_line ill ON ill.id = COALESCE(spl.actualPickLotLineId, spl.suggestedLotLineId) | |||
| LEFT JOIN fpsmsdb.inventory_lot il ON il.id = ill.inventoryLotId | |||
| LEFT JOIN fpsmsdb.warehouse w ON w.id = ill.warehouseId | |||
| @@ -1932,7 +1944,7 @@ open fun getCompletedJobOrderPickOrders(completedDate: LocalDate?): List<Map<Str | |||
| JOIN fpsmsdb.items i ON i.id = pol.itemId | |||
| LEFT JOIN fpsmsdb.uom_conversion uc ON uc.id = pol.uomId | |||
| LEFT JOIN fpsmsdb.suggested_pick_lot spl ON pol.id = spl.pickOrderLineId | |||
| LEFT JOIN fpsmsdb.inventory_lot_line ill ON spl.suggestedLotLineId = ill.id | |||
| LEFT JOIN fpsmsdb.inventory_lot_line ill ON ill.id = COALESCE(spl.actualPickLotLineId, spl.suggestedLotLineId) | |||
| LEFT JOIN fpsmsdb.inventory_lot il ON il.id = ill.inventoryLotId | |||
| LEFT JOIN fpsmsdb.warehouse w ON w.id = ill.warehouseId | |||
| -- no-lot:ill.id 為 null 時,匹配 sol.inventoryLotLineId IS NULL | |||
| @@ -2088,7 +2100,7 @@ open fun getCompletedJobOrderPickOrders(completedDate: LocalDate?): List<Map<Str | |||
| JOIN fpsmsdb.items i ON i.id = pol.itemId | |||
| LEFT JOIN fpsmsdb.uom_conversion uc ON uc.id = pol.uomId | |||
| LEFT JOIN fpsmsdb.suggested_pick_lot spl ON pol.id = spl.pickOrderLineId | |||
| LEFT JOIN fpsmsdb.inventory_lot_line ill ON spl.suggestedLotLineId = ill.id | |||
| LEFT JOIN fpsmsdb.inventory_lot_line ill ON ill.id = COALESCE(spl.actualPickLotLineId, spl.suggestedLotLineId) | |||
| LEFT JOIN fpsmsdb.inventory_lot il ON il.id = ill.inventoryLotId | |||
| LEFT JOIN fpsmsdb.warehouse w ON w.id = ill.warehouseId | |||
| @@ -2187,7 +2199,7 @@ open fun getCompletedJobOrderPickOrders(completedDate: LocalDate?): List<Map<Str | |||
| FROM fpsmsdb.pick_order po | |||
| JOIN fpsmsdb.pick_order_line pol ON pol.poId = po.id AND pol.deleted = false | |||
| LEFT JOIN fpsmsdb.suggested_pick_lot spl ON pol.id = spl.pickOrderLineId AND spl.deleted = false | |||
| LEFT JOIN fpsmsdb.inventory_lot_line ill ON spl.suggestedLotLineId = ill.id AND ill.deleted = false | |||
| LEFT JOIN fpsmsdb.inventory_lot_line ill ON ill.id = COALESCE(spl.actualPickLotLineId, spl.suggestedLotLineId) AND ill.deleted = false | |||
| LEFT JOIN fpsmsdb.warehouse w ON w.id = ill.warehouseId | |||
| LEFT JOIN fpsmsdb.stock_out_line sol ON sol.pickOrderLineId = pol.id AND sol.deleted = false | |||
| WHERE po.deleted = false | |||
| @@ -2382,7 +2394,9 @@ open fun getCompletedJobOrderPickOrders(completedDate: LocalDate?): List<Map<Str | |||
| jobOrderType = jobOrderType?.name, | |||
| itemId = item.id ?: 0L, | |||
| itemName = item.name ?: "", | |||
| itemCode = item.code, | |||
| bomDescription = bom?.bomKind, | |||
| bomType = bom?.type, | |||
| reqQty = jobOrder.reqQty ?: BigDecimal.ZERO, | |||
| //uomId = bom.outputQtyUom?.id : 0L, | |||
| uomId = 0, | |||
| @@ -2438,6 +2452,7 @@ open fun getCompletedJobOrderPickOrders(completedDate: LocalDate?): List<Map<Str | |||
| } | |||
| } | |||
| /** FP-MTMS Version Checklist | Functions Ref. No. 44 | v1.0.0 | 2026-08-03 */ | |||
| open fun getJobOrderLotsHierarchicalByPickOrderId(pickOrderId: Long): JobOrderLotsHierarchicalResponse { | |||
| println("=== getJobOrderLotsHierarchicalByPickOrderId ===") | |||
| println("pickOrderId: $pickOrderId") | |||
| @@ -2518,7 +2533,9 @@ open fun getCompletedJobOrderPickOrders(completedDate: LocalDate?): List<Map<Str | |||
| } | |||
| // 获取所有 inventory lot line IDs | |||
| val inventoryLotLineIds = suggestedPickLots.mapNotNull { it.suggestedLotLine?.id } | |||
| val inventoryLotLineIds = suggestedPickLots | |||
| .mapNotNull { it.actualPickLotLine?.id ?: it.suggestedLotLine?.id } | |||
| .distinct() | |||
| // 获取 inventory lot lines | |||
| val inventoryLotLines = if (inventoryLotLineIds.isNotEmpty()) { | |||
| @@ -2571,18 +2588,13 @@ open fun getCompletedJobOrderPickOrders(completedDate: LocalDate?): List<Map<Str | |||
| .associateBy { it.id!! } | |||
| // 获取 stock in lines 通过 inventoryLotLineId(用于填充 stockInLineId) | |||
| val stockInLinesByInventoryLotLineId = if (inventoryLotLineIds.isNotEmpty()) { | |||
| // ✅ 修复:直接使用已加载的 inventoryLotLines 实体获取 stockInLineId | |||
| inventoryLotLines.associateBy { it.id!! } | |||
| .mapNotNull { (illId, ill) -> | |||
| // 通过关系链: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<Map<Str | |||
| userService.find(uid).orElse(null)?.name | |||
| } | |||
| // 构建 lots 数据 | |||
| // COALESCE(actualPickLotLine, suggestedLotLine) for display after switch | |||
| val lots = lineSuggestedLots.mapNotNull { spl -> | |||
| 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<Map<Str | |||
| else -> "pending" | |||
| } | |||
| // 获取 stockInLineId | |||
| val stockInLineId = ill.id?.let { illId -> | |||
| stockInLinesByInventoryLotLineId[illId] | |||
| } | |||
| @@ -2685,11 +2696,11 @@ open fun getCompletedJobOrderPickOrders(completedDate: LocalDate?): List<Map<Str | |||
| routerArea = warehouse?.code, | |||
| routerRoute = warehouse?.code, | |||
| uomShortDesc = uom?.udfShortDesc, | |||
| matchStatus = jpo?.matchStatus?.value, | |||
| matchBy = jpo?.matchBy, | |||
| matchQty = jpo?.matchQty?.toDouble() | |||
| matchStatus = jpoInner?.matchStatus?.value, | |||
| matchBy = jpoInner?.matchBy, | |||
| matchQty = jpoInner?.matchQty?.toDouble() | |||
| ) | |||
| } | |||
| }.distinctBy { it.lotId } | |||
| // 构建 stockouts 数据:用于无 suggested lot / noLot 场景也能显示并闭环(submit 0) | |||
| val stockouts = (stockOutLinesByPickOrderLine[lineId] ?: emptyList()).map { sol -> | |||
| @@ -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( | |||
| @@ -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<PickOrder>, | |||
| @@ -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 | |||
| } | |||
| } | |||
| @@ -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, | |||
| @@ -266,7 +266,7 @@ open class ItemsService( | |||
| return jdbcDao.queryForList(sql.toString(), args); | |||
| } | |||
| open fun listBagItemsCombo(): List<Map<String, String>> { | |||
| open fun listBagItemsCombo(): List<Map<String, String>> { | |||
| 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<String, Any>): List<Map<String, Any>> { | |||
| 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<String, Any>): List<Map<String, Any>> { | |||
| 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<Map<String, Any>> { | |||
| @@ -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<String, Any?> { | |||
| 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<Long, Map<String, Any?>>() | |||
| fun putUom(row: Map<String, Any?>?) { | |||
| 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(), | |||
| ) | |||
| } | |||
| } | |||
| @@ -60,6 +60,15 @@ class ItemsController( | |||
| fun itemsExistsByCodes(@RequestBody codes: List<String>): List<Map<String, Any?>> { | |||
| 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<String, Any?> { | |||
| return itemsService.getAvailablePickUoms(itemId) | |||
| } | |||
| // @GetMapping("/getRecordByPage") | |||
| // fun getAllItemsByPage(@RequestBody filterRequest: HttpServletRequest): RecordsRes<Map<String, Any>> { | |||
| // val pageSize = filterRequest.getParameter("pageSize").toString().toInt(); // Default to 10 if not provided | |||
| @@ -100,6 +100,7 @@ fun findAllByJobOrder_Id(jobOrderId: Long): List<PickOrder> | |||
| fun findAllByJobOrder_IdInAndDeletedFalseOrderByJobOrder_IdAscCreatedDesc(jobOrderIds: Collection<Long>): List<PickOrder> | |||
| fun findTopByJobOrder_IdOrderByCreatedDesc(jobOrderId: Long): PickOrder? | |||
| fun findAllByStatusIn(statuses: List<PickOrderStatus>): List<PickOrder> | |||
| 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<Long>): List<Long> | |||
| /** 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<Long> | |||
| } | |||
| @@ -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"], | |||
| @@ -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) | |||
| @@ -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<LifecycleStage> { | |||
| 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<String, String?> { | |||
| val map = mutableMapOf<String, String?>() | |||
| 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<LifecycleStage> { | |||
| 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<LifecycleStage> { | |||
| 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, | |||
| ) | |||
| } | |||
| } | |||
| } | |||
| @@ -3804,8 +3804,10 @@ open fun getAllPickOrderLotsWithDetailsHierarchical(userId: Long): Map<String, A | |||
| LEFT JOIN fpsmsdb.uom_conversion uc ON uc.id = pol.uomId | |||
| LEFT JOIN ( | |||
| SELECT spl.pickOrderLineId, spl.suggestedLotLineId AS lotLineId | |||
| SELECT spl.pickOrderLineId, | |||
| COALESCE(spl.actualPickLotLineId, spl.suggestedLotLineId) AS lotLineId | |||
| FROM fpsmsdb.suggested_pick_lot spl | |||
| WHERE spl.deleted = false | |||
| UNION | |||
| SELECT sol.pickOrderLineId, sol.inventoryLotLineId | |||
| FROM fpsmsdb.stock_out_line sol | |||
| @@ -3813,7 +3815,9 @@ open fun getAllPickOrderLotsWithDetailsHierarchical(userId: Long): Map<String, A | |||
| ) ll ON ll.pickOrderLineId = pol.id | |||
| LEFT JOIN fpsmsdb.suggested_pick_lot spl | |||
| ON spl.pickOrderLineId = pol.id AND spl.suggestedLotLineId = ll.lotLineId | |||
| ON spl.pickOrderLineId = pol.id | |||
| AND spl.deleted = false | |||
| AND COALESCE(spl.actualPickLotLineId, spl.suggestedLotLineId) <=> 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<String, Any?> { | |||
| val user = userService.find(userId).orElse(null) | |||
| if (user == null) { | |||
| @@ -4162,15 +4167,19 @@ open fun getAllPickOrderLotsWithDetailsHierarchical(userId: Long): Map<String, A | |||
| JOIN fpsmsdb.items i ON i.id = pol.itemId | |||
| LEFT JOIN fpsmsdb.uom_conversion uc ON uc.id = pol.uomId | |||
| LEFT JOIN ( | |||
| SELECT spl.pickOrderLineId, spl.suggestedLotLineId AS lotLineId | |||
| SELECT spl.pickOrderLineId, | |||
| COALESCE(spl.actualPickLotLineId, spl.suggestedLotLineId) AS lotLineId | |||
| FROM fpsmsdb.suggested_pick_lot spl | |||
| WHERE spl.deleted = false | |||
| UNION | |||
| SELECT sol.pickOrderLineId, sol.inventoryLotLineId | |||
| FROM fpsmsdb.stock_out_line sol | |||
| WHERE sol.deleted = false | |||
| ) ll ON ll.pickOrderLineId = pol.id | |||
| LEFT JOIN fpsmsdb.suggested_pick_lot spl | |||
| ON spl.pickOrderLineId = pol.id AND spl.suggestedLotLineId = ll.lotLineId | |||
| ON spl.pickOrderLineId = pol.id | |||
| AND spl.deleted = false | |||
| AND COALESCE(spl.actualPickLotLineId, spl.suggestedLotLineId) <=> ll.lotLineId | |||
| LEFT JOIN fpsmsdb.stock_out_line sol | |||
| ON sol.pickOrderLineId = pol.id | |||
| AND ( (sol.inventoryLotLineId = ll.lotLineId) | |||
| @@ -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 | |||
| @@ -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<PickOrderLifecycleResponse> { | |||
| 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<PickOrderLifecycleResponse> { | |||
| val result = lifecycleService.getLifecycleByCode(code) | |||
| return if (result != null) ResponseEntity.ok(result) | |||
| else ResponseEntity.notFound().build() | |||
| } | |||
| } | |||
| @@ -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<String, String?> = 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<LifecycleStage>, | |||
| ) | |||
| data class JoLifecycleInfo( | |||
| val joPickOrderId: Long?, | |||
| val jobOrderCode: String?, | |||
| val matchStatus: String?, | |||
| val matchQty: Int?, | |||
| val matchBy: String?, | |||
| val stages: List<LifecycleStage>, | |||
| ) | |||
| 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<LifecycleStage>, | |||
| val doLifecycle: DoLifecycleInfo?, | |||
| val joLifecycle: JoLifecycleInfo?, | |||
| ) | |||
| @@ -55,4 +55,72 @@ interface ProductProcessRepository : JpaRepository<ProductProcess, Long>, JpaSpe | |||
| @Param("useBomIds") useBomIds: Boolean, | |||
| @Param("bomIds") bomIds: Collection<Long>, | |||
| ): List<JobOrderProcessAggregate> | |||
| /** | |||
| * 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<Long>, | |||
| @Param("excludeCompletedProcess") excludeCompletedProcess: Boolean, | |||
| ): List<JobOrderProcessAggregate> | |||
| @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<Long>, | |||
| @Param("itemCode") itemCode: String?, | |||
| @Param("jobOrderCode") jobOrderCode: String?, | |||
| @Param("bomType") bomType: String?, | |||
| @Param("useBomIds") useBomIds: Boolean, | |||
| @Param("bomIds") bomIds: Collection<Long>, | |||
| ): List<JobOrderProcessAggregate> | |||
| } | |||
| @@ -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<Long, JobOrderProcessAggregate>() | |||
| (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<JobOrderQcKey> { 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<Pair<JobOrderQcKey, String?>> { 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<DrinkProductionQtyResponse> { | |||
| 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<Long, com.ffii.fpsms.modules.master.entity.ItemUom>() | |||
| 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<DrinkProductionQtyResponse> { it.itemCode ?: "" } | |||
| .thenBy { it.itemName ?: "" } | |||
| ) | |||
| } | |||
| private fun resolveDrinkProductionUom( | |||
| bom: Bom?, | |||
| itemId: Long?, | |||
| uomDescByItemId: Map<Long, String?>, | |||
| ): 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<ProductProcessLine>, | |||
| 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<OperatorKpiResponse> { | |||
| val targetDate = date ?: LocalDate.now() | |||
| @@ -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<EquipmentStatusByTypeResponse> { | |||
| 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<DrinkProductionQtyResponse> { | |||
| val parsedDate = date?.takeIf { it.isNotBlank() }?.let { | |||
| LocalDate.parse(it, DateTimeFormatter.ISO_DATE) | |||
| } | |||
| return productProcessService.getDrinkProductionQty(parsedDate) | |||
| } | |||
| } | |||
| @@ -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<ProductProcessInfoResponse> | |||
| val lines: List<ProductProcessInfoResponse>, | |||
| /** 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<AllJoborderProductProcessInfoResponse>, | |||
| 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<EquipmentStatusPerDetail>, | |||
| ) | |||
| // ===== 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<DrinkProductionQtyJobOrderDetail> = emptyList(), | |||
| ) | |||
| @@ -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<Map<String, Any?>> { | |||
| val args = mutableMapOf<String, Any>() | |||
| // 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<Map<String, Any?>> | |||
| } | |||
| /** FP-MTMS Version Checklist | Functions Ref. No. 39 | v1.0.2 | 2026-08-05 */ | |||
| fun getDistinctHandlers(): List<String> { | |||
| 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<String, Any>()) | |||
| .map { (it["handler"]?.toString() ?: "").trim() } | |||
| .filter { it.isNotBlank() } | |||
| } | |||
| } | |||
| @@ -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<String, Any> { | |||
| val args = mutableMapOf<String, Any>() | |||
| 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<Map<String, Any?>> | |||
| if (baseRows.isEmpty()) { | |||
| return mapOf("rows" to emptyList<Map<String, Any?>>()) | |||
| } | |||
| 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<String, Any?>( | |||
| "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<Long>, | |||
| ): Map<Long, BigDecimal> { | |||
| 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<Map<String, Any?>> | |||
| 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<Long>, | |||
| itemIds: List<Long>, | |||
| excludePickOrderLineIds: List<Long>, | |||
| ): Map<Pair<Long, Long>, BigDecimal> { | |||
| if (sourceDoIds.isEmpty() || itemIds.isEmpty()) return emptyMap() | |||
| val args = mutableMapOf<String, Any>( | |||
| "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<Map<String, Any?>> | |||
| 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, Any>, | |||
| ): 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, Any>, | |||
| ): 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 | |||
| } | |||
| } | |||
| @@ -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<String> = 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<ByteArray> { | |||
| 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<Map<String, Any?>>): 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() | |||
| } | |||
| } | |||
| @@ -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<String, Any> = | |||
| 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") | |||
| @@ -196,19 +196,23 @@ WHERE ill.id = :id | |||
| fun findAllByWarehouseCodeAndDeletedIsFalse(@Param("warehouseCode") warehouseCode: String): List<InventoryLotLine> | |||
| /** 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>): 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>): Long | |||
| @@ -21,7 +21,10 @@ interface InventoryLotRepository: AbstractRepository<InventoryLot, Long> { | |||
| 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<InventoryLot> | |||
| fun findFirstByStockInLineIdAndDeletedFalseOrderByIdAsc(stockInLineId: Serializable): InventoryLot? | |||
| fun findByIdAndDeletedFalse(id: Serializable): InventoryLot? | |||
| } | |||
| @@ -46,6 +46,58 @@ interface InventoryRepository: AbstractRepository<Inventory, Long> { | |||
| pageable: Pageable | |||
| ): Page<InventoryInfo> | |||
| /** | |||
| * 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<InventoryInfo> | |||
| /** 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<Long>, | |||
| pageable: Pageable, | |||
| ): Page<InventoryInfo> | |||
| fun findInventoryInfoByItemIdInAndDeletedIsFalse(itemIds: List<Serializable>): List<InventoryInfo> | |||
| fun findInventoryInfoByItemInAndDeletedIsFalse(items: List<Items>): List<InventoryInfo> | |||
| @@ -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<Long>() { | |||
| @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 | |||
| } | |||
| @@ -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<ItemLotTraceLog, Long> | |||
| @@ -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<Long>() { | |||
| @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<Long>() { | |||
| @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 | |||
| @@ -41,6 +41,13 @@ open class StockOutLine: BaseEntity<Long>() { | |||
| @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") | |||
| @@ -29,6 +29,18 @@ open class SuggestedPickLot: BaseEntity<Long>() { | |||
| @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") | |||
| @@ -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 | |||
| } | |||
| @@ -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, | |||
| ) | |||
| @@ -88,6 +88,44 @@ open class InventoryService( | |||
| return RecordsRes<InventoryInfo>(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<InventoryInfo> { | |||
| 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<Items>): List<InventoryInfo>{ | |||
| return inventoryRepository.findInventoryInfoByItemInAndDeletedIsFalse(items); | |||
| } | |||
| @@ -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) | |||
| } | |||
| } | |||
| } | |||
| @@ -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) | |||
| } | |||
| @@ -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) | |||
| @@ -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 } | |||
| } | |||
| } | |||
| @@ -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) | |||
| @@ -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) | |||
| } | |||
| @@ -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) | |||
| @@ -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 | |||
| @@ -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 | |||
| }, | |||
| @@ -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<Long, List<com.ffii.fpsms.modules.stock.web.model.ItemLotTraceOrigin>> = | |||
| 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<Long, List<com.ffii.fpsms.modules.stock.web.model.ItemLotTracePurchaseEvent>> = | |||
| if (allAltStockInLineIds.isNotEmpty()) { | |||
| inboundLoader.loadPurchaseEvents(allAltStockInLineIds) | |||
| .groupBy { it.purchaseOrderLineId } | |||
| } else emptyMap() | |||
| val altQcResultsBySilId: Map<Long, List<com.ffii.fpsms.modules.stock.web.model.ItemLotTraceQcResult>> = | |||
| 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<Long>, | |||
| movements: List<com.ffii.fpsms.modules.stock.web.model.ItemLotTraceMovement>, | |||
| ): List<com.ffii.fpsms.modules.stock.web.model.ItemLotTraceDoDelivery> { | |||
| 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<com.ffii.fpsms.modules.stock.web.model.ItemLotTraceMovement>, | |||
| doDeliveries: List<com.ffii.fpsms.modules.stock.web.model.ItemLotTraceDoDelivery>, | |||
| ): List<com.ffii.fpsms.modules.stock.web.model.ItemLotTraceMovement> { | |||
| 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<Long>? = null, | |||
| /** Pre-loaded from batch query — avoids per-location lotLineIds query. */ | |||
| preloadedLotLineIds: List<Long>? = null, | |||
| /** Pre-loaded from batch query — avoids per-location putaway query. */ | |||
| preloadedPutawayEvents: List<com.ffii.fpsms.modules.stock.web.model.ItemLotTracePutawayEvent>? = null, | |||
| /** Pre-loaded from batch query — avoids per-location origins query. */ | |||
| preloadedOrigins: List<com.ffii.fpsms.modules.stock.web.model.ItemLotTraceOrigin>? = null, | |||
| /** Pre-loaded from batch query — avoids per-location purchaseEvents query. */ | |||
| preloadedPurchaseEvents: List<com.ffii.fpsms.modules.stock.web.model.ItemLotTracePurchaseEvent>? = null, | |||
| /** Pre-loaded from batch query — avoids per-location qcResults query. */ | |||
| preloadedQcResults: List<com.ffii.fpsms.modules.stock.web.model.ItemLotTraceQcResult>? = 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, | |||
| ) | |||
| } | |||
| } | |||
| @@ -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<Long>, | |||
| ): 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, List<Long>>, | |||
| ): 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<Long>): 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<Long>): List<ItemLotTraceBomDownstream> { | |||
| 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<ItemLotTraceBomUpstream> { | |||
| 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<ItemLotTraceBomRecipeLine> { | |||
| val joId = getJobOrderIdForFgStockIn(primaryStockInLineId) | |||
| return if (joId != null) loadBomRecipeForJobOrder(joId) else emptyList() | |||
| } | |||
| open fun loadBomRecipeForJobOrder(jobOrderId: Long): List<ItemLotTraceBomRecipeLine> { | |||
| 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<String, BomStepAssignment> { | |||
| 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<String, BomStepAssignment>() | |||
| 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<Long, List<ItemLotTraceStepMaterialRef>> { | |||
| 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<Long, MutableList<ItemLotTraceStepMaterialRef>>() | |||
| 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"]) } | |||
| } | |||
| } | |||
| @@ -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<ItemLotTraceFailEvent> { | |||
| 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<ItemLotTraceLotRelation> { | |||
| 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}" } | |||
| } | |||
| } | |||
| @@ -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<TraceGraphNode> { | |||
| 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<TraceGraphNode> { it.sortKey }.thenBy { it.id }, | |||
| ) | |||
| } | |||
| internal fun materialInputsHaveProduction(inputs: List<ItemLotTraceMaterialInput>): Boolean = | |||
| inputs.any { m -> | |||
| m.productionSteps.isNotEmpty() || | |||
| (m.nestedJoPrelude?.materialInputs?.let { materialInputsHaveProduction(it) } == true) | |||
| } | |||
| private fun buildTraceGraphNodes(data: ItemLotTraceResponse): List<TraceGraphNode> { | |||
| val nodes = mutableListOf<TraceGraphNode>() | |||
| 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<ItemLotTraceQcResult>, | |||
| seqStart: Int, | |||
| ): Pair<List<TraceGraphNode>, Int> { | |||
| val nodes = mutableListOf<TraceGraphNode>() | |||
| 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<com.ffii.fpsms.modules.stock.web.model.ItemLotTraceProductionStep>, | |||
| byproductLots: List<com.ffii.fpsms.modules.stock.web.model.ItemLotTraceByproductLot>, | |||
| lotUom: String, | |||
| ): List<TraceGraphNode> { | |||
| val nodes = mutableListOf<TraceGraphNode>() | |||
| 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<TraceGraphNode> { | |||
| val nodes = mutableListOf<TraceGraphNode>() | |||
| 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<Long> = mutableSetOf(), | |||
| val seenInboundLots: MutableSet<Long> = mutableSetOf(), | |||
| val seenReceiptLots: MutableSet<Long> = mutableSetOf(), | |||
| val seenQcKeys: MutableSet<String> = mutableSetOf(), | |||
| val seenPutawayKeys: MutableSet<String> = mutableSetOf(), | |||
| var seq: Int = 0, | |||
| ) | |||
| private data class LotPickRef( | |||
| val pickOrderCode: String, | |||
| val pickOrderId: Long?, | |||
| val consoCode: String, | |||
| ) | |||
| private fun buildJoPreludeGraphNodes(joPrelude: ItemLotTraceJoPrelude): List<TraceGraphNode> { | |||
| 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<TraceGraphNode> { it.sortKey }.thenBy { it.id }) | |||
| } | |||
| private fun buildLotPickOrderMap( | |||
| materialInputs: List<ItemLotTraceMaterialInput>, | |||
| ): Map<String, List<LotPickRef>> { | |||
| val map = mutableMapOf<String, MutableList<LotPickRef>>() | |||
| 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<String, MutableList<LotPickRef>>, | |||
| source: Map<String, List<LotPickRef>>, | |||
| ) { | |||
| 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<ItemLotTraceMaterialInput>, | |||
| lotPicks: Map<String, List<LotPickRef>>, | |||
| ctx: PreludeBuildContext, | |||
| ): List<TraceGraphNode> { | |||
| val nodes = mutableListOf<TraceGraphNode>() | |||
| 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<TraceGraphNode> { | |||
| 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<String, List<LotPickRef>>, | |||
| ctx: PreludeBuildContext, | |||
| ): List<TraceGraphNode> { | |||
| val nodes = mutableListOf<TraceGraphNode>() | |||
| 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<ItemLotTraceQcResult>): List<List<ItemLotTraceQcResult>> { | |||
| val map = linkedMapOf<String, MutableList<ItemLotTraceQcResult>>() | |||
| 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<com.ffii.fpsms.modules.stock.web.model.ItemLotTraceLocationBlock>, | |||
| ): List<TraceGraphNode> { | |||
| if (locationBlocks.isEmpty()) return emptyList() | |||
| val nodes = mutableListOf<TraceGraphNode>() | |||
| 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() } | |||
| } | |||
| } | |||
| @@ -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<ItemLotTraceOrigin> = | |||
| loadOriginsForLots(listOf(inventoryLotId)) | |||
| open fun loadOriginsForLots(inventoryLotIds: List<Long>): List<ItemLotTraceOrigin> { | |||
| 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<Long>): List<ItemLotTracePurchaseEvent> { | |||
| 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<String, Any?>): 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<Long>): List<ItemLotTraceQcResult> { | |||
| 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<ItemLotTracePutawayEvent> = | |||
| 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<Long>): Map<Long, List<ItemLotTracePutawayEvent>> { | |||
| 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<Long, MutableList<ItemLotTracePutawayEvent>>() | |||
| val seen = mutableSetOf<String>() | |||
| fun addRow(row: Map<String, Any?>) { | |||
| 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<ItemLotTracePutawayEvent> { 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<Long, List<ItemLotTracePutawayEvent>>, | |||
| ): List<ItemLotTracePutawayEvent> { | |||
| 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, | |||
| ), | |||
| ) | |||
| } | |||
| } | |||
| @@ -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<ItemLotTraceJoPickOrder> { | |||
| 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<Long>, | |||
| ): List<ItemLotTraceMaterialInput> { | |||
| 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<ItemLotTraceOrigin> { 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<ItemLotTraceOrigin> { it.receiptDate ?: "9999-12-31" } | |||
| .thenBy { it.stockInLineId }, | |||
| ) | |||
| ?: origins.minWithOrNull( | |||
| compareBy<ItemLotTraceOrigin> { it.receiptDate ?: "9999-12-31" } | |||
| .thenBy { it.stockInLineId }, | |||
| ) | |||
| } | |||
| fun qcForLot(lotId: Long?): List<ItemLotTraceQcResult> { | |||
| if (lotId == null) return emptyList() | |||
| val silIds = silIdsByLot[lotId]?.toSet() ?: emptySet() | |||
| return allQc.filter { it.stockInLineId in silIds } | |||
| } | |||
| fun purchaseForLot(lotId: Long?): List<ItemLotTracePurchaseEvent> { | |||
| 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, | |||
| ) | |||
| } | |||
| } | |||
| } | |||
| @@ -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<ItemLotTraceAlternateLocation> { | |||
| 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<Long> { | |||
| 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<Long> { | |||
| 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<ItemLotTraceWarehouseLine> { | |||
| 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<Long>): Map<Long, List<Long>> { | |||
| 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<Long, MutableList<Long>>() | |||
| 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<Long>): Map<Long, List<Long>> { | |||
| 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<Long, MutableList<Long>>() | |||
| 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<Long>): Map<Long, ItemLotTraceLotInfo> { | |||
| 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"]) } | |||
| } | |||
| } | |||
| @@ -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<Long>): List<ItemLotTraceMovement> { | |||
| 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<Long>): List<ItemLotTraceStockTakeEvent> { | |||
| 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<Long>): List<ItemLotTraceAdjustment> { | |||
| 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<ItemLotTraceTransfer> { | |||
| 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<ItemLotTraceOpenMovement> { | |||
| 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"]), | |||
| ) | |||
| } | |||
| } | |||
| } | |||
| @@ -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<ItemLotTraceOutboundUsage> { | |||
| 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<String, Any?>): 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<ItemLotTraceDoDelivery> { | |||
| 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<String>): List<ItemLotTraceDoDelivery> { | |||
| 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<Map<String, Any?>>): List<ItemLotTraceDoDelivery> { | |||
| 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<String, Any?>, | |||
| 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<ItemLotTraceReplenishmentEvent> { | |||
| 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<String, Any?>): 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<Long>): List<ItemLotTraceReturnEvent> { | |||
| 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"]), | |||
| ) | |||
| } | |||
| } | |||
| } | |||
| @@ -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<ItemLotTraceProductionStep> { | |||
| 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<ItemLotTraceByproductLot> { | |||
| 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}" } | |||
| } | |||
| } | |||
| @@ -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() } | |||
| } | |||
| } | |||
| @@ -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<Long>, | |||
| ) | |||
| /** | |||
| * 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<Long> { | |||
| 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<Long> { | |||
| val found = linkedSetOf<Long>() | |||
| 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<Long> { | |||
| 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<Long> { | |||
| 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<Long> { | |||
| 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<Long> { | |||
| val ids = linkedSetOf<Long>() | |||
| 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<Long>() | |||
| var current = tracedLotId | |||
| val visited = mutableSetOf<Long>() | |||
| 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 | |||
| } | |||
| } | |||