| @@ -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,33 @@ | |||||
| --- | |||||
| description: Prevent double-click / duplicate API calls from frontend UI | |||||
| alwaysApply: true | |||||
| --- | |||||
| # Prevent duplicated API calls (frontend) | |||||
| When wiring UI actions (buttons, row actions, dialogs) to backend APIs, **always prevent double submission**. Relying on `setState` + `disabled` alone is not sufficient because rapid double-click can fire twice before React re-renders. | |||||
| - **Must**: add an **in-flight lock** (e.g. `useRef(false)`) and early-return if already running. | |||||
| - **Must**: keep the UI disabled/loading (`disabled={isLoading}`) for user feedback. | |||||
| - **Must**: clear the lock in `finally` so it always releases. | |||||
| - **Should**: if the same endpoint can be triggered from multiple places, consider a shared “single-flight” helper (dedupe by `method+url+body` key). | |||||
| Example pattern: | |||||
| ```tsx | |||||
| const inFlightRef = useRef(false); | |||||
| const [isSaving, setIsSaving] = useState(false); | |||||
| const onSave = async () => { | |||||
| if (inFlightRef.current) return; | |||||
| inFlightRef.current = true; | |||||
| setIsSaving(true); | |||||
| try { | |||||
| await doRequest(); | |||||
| } finally { | |||||
| setIsSaving(false); | |||||
| inFlightRef.current = false; | |||||
| } | |||||
| }; | |||||
| ``` | |||||
| @@ -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` | |||||
| @@ -0,0 +1,785 @@ | |||||
| --- | |||||
| name: fp-mtms-version-checklist | |||||
| description: >- | |||||
| Updates the FP-MTMS Version Programs and Functions Checklist Excel on each | |||||
| 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 | |||||
| --- | |||||
| # FP-MTMS Version Checklist Excel | |||||
| ## Purpose | |||||
| Maintain the team **FP-MTMS System Version Control Checklist**: page/program | |||||
| level and function-level version history in a shared SharePoint Excel file. | |||||
| ## Language | |||||
| Keep Excel **column headers** exactly as in the file (English). | |||||
| ### 中英對照 (required for cell content) | |||||
| All written cell values must be **Traditional Chinese + English** | |||||
| (中英對照), using i18n labels where they exist. | |||||
| **Exception — do NOT bilingualize:** | |||||
| - `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) | |||||
| ### Format | |||||
| **Short labels** (subsystem / page names): | |||||
| ```text | |||||
| 批號追溯 / Item Tracing | |||||
| 倉庫管理 / Store Management | |||||
| ``` | |||||
| **Longer prose** (purpose, highlights, major changes): Chinese first, | |||||
| then English on the **next line**. For usage/呼叫來源, keep it on its | |||||
| own line(s) as well: | |||||
| ```text | |||||
| 工單提料執行頁面(/jodetail)。 | |||||
| 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 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. | |||||
| ## 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) | |||||
| Path varies by Windows user / OneDrive sync root. Resolve in this order: | |||||
| 1. If the user provides a full path, use it. | |||||
| 2. Else try under the user profile home: | |||||
| `{USERPROFILE}/2Fi Business Solutions Limited/2Fi Business Solutions Limited - FP-MTMS VERSION CONTROL/FP-MTMS Version Programs and Functions Checklist v0.1.xlsx` | |||||
| 3. If missing, search under `{USERPROFILE}` for | |||||
| `FP-MTMS Version Programs and Functions Checklist v0.1.xlsx` (real `.xlsx` only). | |||||
| 4. **Always confirm the resolved path with the user before writing.** | |||||
| ### Archive Excel path | |||||
| 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 | |||||
| **Never** ask the user one question per cell / column. | |||||
| 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 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. | |||||
| 4. Only after explicit approval, write with `openpyxl` and save. | |||||
| If the preview is wrong, adjust and show a **revised preview**; do not write | |||||
| until approved. | |||||
| If critical facts are truly unknown (e.g. version number or developer name | |||||
| cannot be inferred), ask at most a short batch of missing items — never walk | |||||
| the sheet column by column. | |||||
| ## Pre-check: matching program / feature must exist | |||||
| Before drafting or writing any row: | |||||
| 1. Open the workbook and search both sheets for the target | |||||
| **System Page / Program Name** (and for Functions sheet, also | |||||
| **Name of Function within Program**). | |||||
| 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. | |||||
| 6. Different **功能** (distinct functions) → **different Ref. No. rows**, even | |||||
| under the same System Page / Program Name. | |||||
| ## History shift (required on every update) | |||||
| Each page or function keeps **Latest** plus up to **3 previous** history slots | |||||
| (1st, 2nd, 3rd). On every new change to an existing row, **push history | |||||
| rightward** before writing the new Latest: | |||||
| | Before write | After shift | | |||||
| |--------------|-------------| | |||||
| | Latest | → 1st previous | | |||||
| | 1st previous | → 2nd previous | | |||||
| | 2nd previous | → 3rd previous | | |||||
| | 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: | |||||
| - **Latest**: D (Version & Date), F (Major Changes), G (Developed By) | |||||
| - **1st**: H, I, J | |||||
| - **2nd**: K, L, M | |||||
| - **3rd**: N, O, P | |||||
| 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**: 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. | |||||
| ### Sheet: `Functions in Page&Program` (archive) | |||||
| | 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 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 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 **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 | |||||
| only commits that changed that function; leave unused 1st/2nd/3rd slots empty. | |||||
| Different function rows under the same page may have **different** Latest | |||||
| 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 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 J and history M/P/S), | |||||
| include concrete code locations from the commit / diff — not only a summary | |||||
| sentence. | |||||
| **Required content (中英對照 for prose; paths/lines stay as-is):** | |||||
| 1. **Summary** — short ZH then EN (what the release did). | |||||
| 2. **Per changed file** — repo-relative path. | |||||
| 3. **Line range(s)** — current file line numbers after the change (prefer | |||||
| `start–end`; single line OK). Re-resolve with `git show` / Read / Grep; | |||||
| do not guess. | |||||
| 4. **Short explanation by line/range** — what that hunk does (ZH then EN, or | |||||
| one ZH+EN pair per bullet). | |||||
| **Cell layout example:** | |||||
| ```text | |||||
| 轉倉出庫批次 ledger 餘額鏈結修正。 | |||||
| TRF stock-out batch ledger balance chaining fix. | |||||
| StockOutLineService.kt | |||||
| L1518: ledger 查詢改為 findFirstByItemIdAndDeletedFalseOrderByDateDescIdDesc。 | |||||
| L1518: ledger lookup → findFirstByItemIdAndDeletedFalseOrderByDateDescIdDesc. | |||||
| L2318–2347 (createStockOutBatch): 同 batch 多行依 runningLedgerBalance 扣帳,不再每次用 onHandQty。 | |||||
| L2318–2347 (createStockOutBatch): chain per-item running ledger balance within a batch instead of onHandQty each line. | |||||
| StockInLineService.kt | |||||
| L269 / L312: assignLotNo / assignLotNoForJo 改為 open fun(無邏輯變更)。 | |||||
| L269 / L312: assignLotNo / assignLotNoForJo marked open (no logic change). | |||||
| ``` | |||||
| **Rules:** | |||||
| - Fact-check path + line numbers against the repo at write time (lines drift; | |||||
| re-check before save). | |||||
| - Group by file; under each file, one bullet per meaningful hunk / line range. | |||||
| - 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 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 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) | |||||
| (e.g. `InventoryLotLineTable.tsx L508–518: success message by API code`). | |||||
| ## Batch updates | |||||
| Many rows may change in one invocation (multiple pages and/or functions). | |||||
| - Build **one combined preview** covering all affected rows. | |||||
| - One approval covers the whole batch. | |||||
| - Still one Ref. No. row per distinct 功能 / function. | |||||
| ## Sheets and columns | |||||
| Data starts at **row 5** (rows 1–4 are title/headers). | |||||
| ### Sheet: `Page&Program Name` | |||||
| | 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 | Major Changes Highlights | | |||||
| | G | Developed By | | |||||
| | H | 1st Previous Page Version & Date | | |||||
| | I | 1st Major Changes Highlights | | |||||
| | J | Developed By | | |||||
| | K | 2nd Previous Page Version & Date | | |||||
| | L | 2nd Major Changes Highlights | | |||||
| | M | Developed By | | |||||
| | N | 3rd Previous Page Version & Date | | |||||
| | O | 3rd Major Changes Highlights | | |||||
| | P | Developed By | | |||||
| ### Sheet: `Functions in Page&Program` | |||||
| | Col | Header | | |||||
| |-----|--------| | |||||
| | A | Ref. No. | | |||||
| | 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 G. | |||||
| Format: | |||||
| ```text | |||||
| <EnglishSymbol> — <repo-relative path> | |||||
| ``` | |||||
| Examples: | |||||
| - `ItemTracingScanBar — src/components/ItemTracing/ItemTracingScanBar.tsx` | |||||
| - `GoodPickExecutionWorkbenchRecord — src/components/DoWorkbench/GoodPickExecutionWorkbenchRecord.tsx` | |||||
| If one checklist function spans multiple primary files, put **each** | |||||
| symbol + path on its **own new line** (do not join with `; ` on one line): | |||||
| ```text | |||||
| ItemTracingSummary — src/components/ItemTracing/ItemTracingSummary.tsx | |||||
| 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), 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`, | |||||
| wrap text). Do not squeeze multiple files onto one line with `; `. | |||||
| Format (one file per line): | |||||
| ```text | |||||
| <ClassName>.<fun1> / .<fun2> — <path-to-that-file> | |||||
| ``` | |||||
| Multi-file example: | |||||
| ```text | |||||
| ItemLotTraceService.trace / .traceLocation — src/main/java/com/ffii/fpsms/modules/stock/service/ItemLotTraceService.kt | |||||
| InventoryLotLineController.traceLot / .traceLocation — src/main/java/com/ffii/fpsms/modules/stock/web/InventoryLotLineController.kt | |||||
| ``` | |||||
| ```text | |||||
| PickOrderLifecycleController.getLifecycle / .getLifecycleByCode — src/main/java/.../PickOrderLifecycleController.kt | |||||
| PickOrderLifecycleService.getLifecycle / .getLifecycleByCode — src/main/java/.../PickOrderLifecycleService.kt | |||||
| ``` | |||||
| For multi-file **component** rows, also one path per line: | |||||
| ```text | |||||
| ItemTracingSummary — src/components/ItemTracing/ItemTracingSummary.tsx | |||||
| ItemTracingLocations — src/components/ItemTracing/ItemTracingLocations.tsx | |||||
| ``` | |||||
| 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 (H)** and **Major Changes (J)** must be 中英對照 | |||||
| (Chinese then English on the next line). Prefer i18n terms. Page / | |||||
| subsystem names (C/D) use `中文 / English`. | |||||
| #### Column F — Page Purpose + where the function is used (required) | |||||
| On **Functions in Page&Program**, **Page Purpose Highlights (F)** must | |||||
| include: | |||||
| 1. The owning page purpose (what page C is for), **and** | |||||
| 2. **Where this function is used / called from** — especially when the | |||||
| caller is a **different** page than column C. | |||||
| Owning page (C) = where the code/feature primarily lives. | |||||
| Usage location = which page(s) invoke, deep-link into, or depend on it. | |||||
| Examples: | |||||
| - Function lives on `工單提料` but is opened from Item Tracing doc links: | |||||
| ```text | |||||
| 工單提料執行頁面(/jodetail)。 | |||||
| Job Order Pick Execution page (/jodetail). | |||||
| 使用/呼叫來源:批號追溯(單據深連結開啟已完成提料紀錄)。 | |||||
| Used / called from: Item Tracing (doc deep-link to open completed pick record). | |||||
| ``` | |||||
| - Function lives on `提料單` (lifecycle API) but is consumed for tracing: | |||||
| ```text | |||||
| 提料單管理。 | |||||
| Pick Order management. | |||||
| 使用/呼叫來源:批號追溯(追溯流程/單據生命週期查詢)。 | |||||
| Used / called from: Item Tracing (trace flow / document lifecycle query). | |||||
| ``` | |||||
| - Function lives on `成品出倉` but is deep-linked from tracing: | |||||
| ```text | |||||
| 成品出倉(DO Workbench)揀貨與紀錄作業。 | |||||
| DO Workbench pick execution and records. | |||||
| 使用/呼叫來源:批號追溯(單據深連結開啟成品出倉紀錄)。 | |||||
| Used / called from: Item Tracing (doc deep-link to open DO Workbench record). | |||||
| ``` | |||||
| - Function lives on and is only used by the same page (e.g. 批號追溯): | |||||
| ```text | |||||
| …頁面目的(中文)… | |||||
| …page purpose (English)… | |||||
| 使用/呼叫來源:本頁(批號追溯 / Item Tracing)。 | |||||
| Used / called from: this page (批號追溯 / Item Tracing). | |||||
| ``` | |||||
| Put **使用/呼叫來源** (and its English line) on **new lines** after the | |||||
| page-purpose ZH/EN pair. Do not run usage on the same line as purpose. | |||||
| 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 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 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: | |||||
| 1. **Path exists** — each repo-relative path resolves under the correct | |||||
| workspace (`FPSMS-frontend` or `FPSMS-backend`). Prefer `Glob` / `Read` | |||||
| / `Grep`; do not guess. | |||||
| 2. **Symbol is in that file** — the English symbol must actually be defined | |||||
| or exported in the cited file (e.g. `const ItemTracingScanBar`, | |||||
| `export default ItemTracingScanBar`, `open class ItemLotTraceService`, | |||||
| `fun traceLot`, `class PickOrderLifecycleController`). Match casing | |||||
| exactly as in code (`CompleteJobOrderRecord`, not `completeJobOrderRecord`). | |||||
| For service/controller rows, also verify each listed **member function** | |||||
| (e.g. `trace`, `traceLocation`, `getLifecycle`) exists in that file. | |||||
| 3. **Multi-path rows** — each symbol + path must be on its **own line**: | |||||
| - Every line’s symbol must be defined in that line’s file. | |||||
| - Example OK: | |||||
| ```text | |||||
| ItemTracingSummary — …/ItemTracingSummary.tsx | |||||
| ItemTracingLocations — …/ItemTracingLocations.tsx | |||||
| ``` | |||||
| - Do not join multiple files with `; ` on one line. | |||||
| 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 G. Omit the row or ask the user; never write an unverified G. | |||||
| In the preview, mark verified rows (optional short note), e.g. | |||||
| `G verified: symbol+path OK`. If any G failed fact-check, fix before asking | |||||
| for confirmation. | |||||
| ## Preview format (required before save) | |||||
| Show a clear preview, for example: | |||||
| ```text | |||||
| Excel: <resolved main path> | |||||
| Archive: <resolved archive path> | |||||
| Sheet: Functions in Page&Program | |||||
| Row 12 (UPDATE existing | Ref 7 | Page Ref 3 → 批號追溯 / Item Tracing | Function: ItemTracingFlowGraphSearch — src/components/ItemTracing/ItemTracingFlowGraphSearch.tsx) | |||||
| History shift: | |||||
| old Latest (I/J/K) → 1st | |||||
| old 1st → 2nd | |||||
| old 2nd → 3rd | |||||
| 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: | |||||
| I: v1.2.0 2026-07-14 | |||||
| J: 新增流程圖節點搜尋。 | |||||
| Add flow-graph node search. | |||||
| ItemTracingFlowGraphSearch.tsx | |||||
| L42–88: 搜尋框與節點高亮。 | |||||
| L42–88: search box and node highlight. | |||||
| K: <developer> | |||||
| Row NEW (append | next Ref 15 | Page Ref 3 → 批號追溯 / Item Tracing | Function: ItemTracingScanBar — src/components/ItemTracing/ItemTracingScanBar.tsx) | |||||
| A: 15 | |||||
| B: 3 | |||||
| C: 倉庫管理 / Store Management | |||||
| D: 批號追溯 / Item Tracing | |||||
| F: …頁面目的(中文)… | |||||
| …page purpose (English)… | |||||
| 使用/呼叫來源:本頁(批號追溯 / Item Tracing)。 | |||||
| Used / called from: this page (批號追溯 / Item Tracing). | |||||
| G: ItemTracingScanBar — src/components/ItemTracing/ItemTracingScanBar.tsx | |||||
| H: 相機掃碼/手動查詢 | |||||
| Camera scan / manual search | |||||
| ... | |||||
| I/J/K: <new latest> | |||||
| history slots: empty | |||||
| 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). | |||||
| G: CompleteJobOrderRecord — src/components/Jodetail/completeJobOrderRecord.tsx | |||||
| JodetailSearch — src/components/Jodetail/JodetailSearch.tsx | |||||
| … | |||||
| Confirm: Is this preview correct? [Yes / No — tell me what to change] | |||||
| ``` | |||||
| For page-sheet rows, use the same style with D/F/G and H–P history. | |||||
| ## Workflow | |||||
| 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 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 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 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 | |||||
| preview (list of files/symbols that will get comments). | |||||
| 12. Note that OneDrive may take a few seconds to sync; refresh Excel Online if open. | |||||
| ## Code comments on every updated function (required) | |||||
| 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 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 I), e.g. `v1.0.0` | |||||
| 3. **Update date** — the date from column I, e.g. `2026-07-14` | |||||
| ### Canonical format | |||||
| Parse column I `vX.Y.Z YYYY-MM-DD` into version + date. | |||||
| **TypeScript / TSX** (JSDoc immediately above the component / export / | |||||
| function): | |||||
| ```ts | |||||
| /** FP-MTMS Version Checklist | Functions Ref. No. 1 | v1.0.0 | 2026-07-14 */ | |||||
| const ItemTracingScanBar: React.FC<ScanBarProps> = (...) => { | |||||
| ``` | |||||
| **Kotlin** (KDoc immediately above the `fun` / class member): | |||||
| ```kotlin | |||||
| /** FP-MTMS Version Checklist | Functions Ref. No. 7 | v1.0.0 | 2026-07-14 */ | |||||
| open fun trace(...): ItemLotTraceResponse = ... | |||||
| ``` | |||||
| If a KDoc/JSDoc already exists, **prepend or merge** this checklist line | |||||
| into it (do not delete useful existing documentation). Prefer keeping the | |||||
| checklist line as the first line of the block: | |||||
| ```kotlin | |||||
| /** | |||||
| * FP-MTMS Version Checklist | Functions Ref. No. 7 | v1.0.0 | 2026-07-14 | |||||
| * Lazy-load: returns the full location-scoped trace block... | |||||
| */ | |||||
| ``` | |||||
| ### Placement rules | |||||
| - **React component**: above the primary `const ComponentName` / `export | |||||
| default` that matches column G. | |||||
| - **Service / controller**: above **each** listed member function | |||||
| (e.g. both `trace` and `traceLocation`). | |||||
| - 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). | |||||
| ### Preview | |||||
| The Excel preview must also list planned comment updates, e.g.: | |||||
| ```text | |||||
| Code comments to add/update: | |||||
| Ref 7 → ItemLotTraceService.trace / .traceLocation (v1.0.0 | 2026-07-14) | |||||
| Ref 1 → ItemTracingScanBar (v1.0.0 | 2026-07-14) | |||||
| ``` | |||||
| Only edit comments after preview approval (same gate as Excel write), unless | |||||
| the user explicitly asks to sync comments only. | |||||
| ## Inference hints (for filling the preview) | |||||
| - **Version & Date**: next patch/semver from conversation/git if known; else | |||||
| `vX.Y.Z YYYY-MM-DD` with today’s date. | |||||
| - **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 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 G)**: English code symbol + source file path only | |||||
| (no 中英對照). **Fact-check** symbol+path before preview. | |||||
| - **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. | |||||
| ## Do not | |||||
| - Ask the user to fill cells one column at a time | |||||
| - Save without an approved preview | |||||
| - Skip history shift when updating an existing Latest | |||||
| - 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 J and matching history cols) | |||||
| - Write Chinese-only or English-only prose in bilingual-required columns | |||||
| (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 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 G without its relevant member | |||||
| function names (e.g. write `ItemLotTraceService.trace / .traceLocation`, | |||||
| not only `ItemLotTraceService`) | |||||
| - 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 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 | |||||
| - Edit cloud URLs / `.url` files as if they were workbooks | |||||
| @@ -35,6 +35,10 @@ out/ | |||||
| ### VS Code ### | ### VS Code ### | ||||
| .vscode/ | .vscode/ | ||||
| ### Cursor (local-only rules) ### | |||||
| .cursor/rules/local/ | |||||
| package-lock.json | package-lock.json | ||||
| python/Bag3.spec | python/Bag3.spec | ||||
| python/dist/Bag3.exe | |||||
| python/dist | |||||
| @@ -10,3 +10,35 @@ | |||||
| - If a task is about UI behavior (charts, clicks, page rendering, dialogs), check `../FPSMS-frontend`. | - 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. | - 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. | - 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,以實機為準並回報補譯 | | |||||
| @@ -16,6 +16,7 @@ Bag2 is kept as a separate legacy v2.x line; do not assume Bag2 matches Bag3. | |||||
| Run: python Bag3.py | Run: python Bag3.py | ||||
| """ | """ | ||||
| import errno | |||||
| import json | import json | ||||
| import os | import os | ||||
| import select | import select | ||||
| @@ -25,6 +26,7 @@ import tempfile | |||||
| import threading | import threading | ||||
| import time | import time | ||||
| import tkinter as tk | import tkinter as tk | ||||
| from dataclasses import dataclass | |||||
| from datetime import date, datetime, timedelta | from datetime import date, datetime, timedelta | ||||
| from tkinter import messagebox, ttk | from tkinter import messagebox, ttk | ||||
| from typing import Callable, Optional, Tuple | from typing import Callable, Optional, Tuple | ||||
| @@ -344,6 +346,25 @@ DATAFLEX_UI_PROGRESS_EVERY = max( | |||||
| DATAFLEX_SINGLE_TCP_JOB = _dataflex_bool_env( | DATAFLEX_SINGLE_TCP_JOB = _dataflex_bool_env( | ||||
| "FPSMS_DATAFLEX_SINGLE_TCP_JOB", False | "FPSMS_DATAFLEX_SINGLE_TCP_JOB", False | ||||
| ) | ) | ||||
| # Link-OS SGD: raw-ZPL job shows this host id instead of a generic name (e.g. "ZPL. EMULATION"). | |||||
| # Same id when the same job order is printed again. Disable: FPSMS_DATAFLEX_HOST_IDENTIFICATION_SGD=0 | |||||
| DATAFLEX_HOST_IDENTIFICATION_SGD = _dataflex_bool_env( | |||||
| "FPSMS_DATAFLEX_HOST_IDENTIFICATION_SGD", True | |||||
| ) | |||||
| # Bag ZPL size (dots). ^PW700 matched little content (mostly vertical ^A@R), so previews showed a wide strip with empty right margin. | |||||
| DATAFLEX_LABEL_PW = max( | |||||
| 280, | |||||
| _dataflex_int_env("FPSMS_DATAFLEX_LABEL_PW", 400), | |||||
| ) | |||||
| DATAFLEX_LABEL_LL = max( | |||||
| 200, | |||||
| _dataflex_int_env("FPSMS_DATAFLEX_LABEL_LL", 500), | |||||
| ) | |||||
| # Some Zebra/DataFlex units RST the socket on host half-close; Windows surfaces WinError 10054. | |||||
| # Set FPSMS_DATAFLEX_SKIP_SHUTDOWN_WR=1 to omit shutdown(SHUT_WR) and only close() (often avoids RST). | |||||
| DATAFLEX_SKIP_SHUTDOWN_WR = _dataflex_bool_env( | |||||
| "FPSMS_DATAFLEX_SKIP_SHUTDOWN_WR", False | |||||
| ) | |||||
| # Full recovery (~JR soft reset) — used by「打袋重設」only; longer delay for firmware | # Full recovery (~JR soft reset) — used by「打袋重設」only; longer delay for firmware | ||||
| DATAFLEX_POST_FULL_RECOVERY_DELAY_SEC = 1.2 | DATAFLEX_POST_FULL_RECOVERY_DELAY_SEC = 1.2 | ||||
| # Zebra ~RO only (used when FPSMS_DATAFLEX_NO_JR is set for full recovery) | # Zebra ~RO only (used when FPSMS_DATAFLEX_NO_JR is set for full recovery) | ||||
| @@ -364,12 +385,56 @@ def _zpl_escape(s: str) -> str: | |||||
| return s.replace("\\", "\\\\").replace("^", "\\^") | return s.replace("\\", "\\\\").replace("^", "\\^") | ||||
| def _dataflex_host_identification_sgd_prefix(job_order_id: Optional[int]) -> str: | |||||
| """ | |||||
| Optional ASCII prefix before ^XA: set zpl.host_identification so the printer lists the job | |||||
| under the job order id instead of a generic raw-ZPL label. | |||||
| """ | |||||
| if not DATAFLEX_HOST_IDENTIFICATION_SGD or job_order_id is None: | |||||
| return "" | |||||
| try: | |||||
| jid = str(int(job_order_id)) | |||||
| except (TypeError, ValueError): | |||||
| return "" | |||||
| if not jid.isdigit(): | |||||
| return "" | |||||
| return f'! U1 setvar "zpl.host_identification" "{jid}"\r\n' | |||||
| def _dataflex_zpl_bytes(zpl: str) -> bytes: | def _dataflex_zpl_bytes(zpl: str) -> bytes: | ||||
| """UTF-8 ZPL with one trailing CRLF so the printer sees a clear job boundary.""" | """UTF-8 ZPL with one trailing CRLF so the printer sees a clear job boundary.""" | ||||
| s = (zpl or "").rstrip("\r\n") | s = (zpl or "").rstrip("\r\n") | ||||
| return (s + "\r\n").encode("utf-8") | return (s + "\r\n").encode("utf-8") | ||||
| def _dataflex_is_benign_tcp_reset(err: BaseException) -> bool: | |||||
| """True when peer closed with RST/FIN in a way that is normal for raw printer TCP (Windows 10054).""" | |||||
| if isinstance(err, (BrokenPipeError, ConnectionResetError, ConnectionAbortedError)): | |||||
| return True | |||||
| if isinstance(err, OSError): | |||||
| if getattr(err, "winerror", None) == 10054: # WSAECONNRESET | |||||
| return True | |||||
| if err.errno in ( | |||||
| errno.ECONNRESET, | |||||
| errno.EPIPE, | |||||
| errno.ECONNABORTED, | |||||
| ): | |||||
| return True | |||||
| return False | |||||
| def _dataflex_shutdown_write_maybe(sock: socket.socket) -> None: | |||||
| """Half-close write side; ignore printer RST (common after ZPL on port 9100-style links).""" | |||||
| if DATAFLEX_SKIP_SHUTDOWN_WR: | |||||
| return | |||||
| try: | |||||
| sock.shutdown(socket.SHUT_WR) | |||||
| except OSError as e: | |||||
| if _dataflex_is_benign_tcp_reset(e): | |||||
| return | |||||
| raise | |||||
| def generate_zpl_dataflex( | def generate_zpl_dataflex( | ||||
| batch_no: str, | batch_no: str, | ||||
| item_code: str, | item_code: str, | ||||
| @@ -377,6 +442,7 @@ def generate_zpl_dataflex( | |||||
| item_id: Optional[int] = None, | item_id: Optional[int] = None, | ||||
| stock_in_line_id: Optional[int] = None, | stock_in_line_id: Optional[int] = None, | ||||
| lot_no: Optional[str] = None, | lot_no: Optional[str] = None, | ||||
| job_order_id: Optional[int] = None, | |||||
| font_regular: str = "E:STXihei.ttf", | font_regular: str = "E:STXihei.ttf", | ||||
| font_bold: str = "E:STXihei.ttf", | font_bold: str = "E:STXihei.ttf", | ||||
| ) -> str: | ) -> str: | ||||
| @@ -398,11 +464,12 @@ def generate_zpl_dataflex( | |||||
| qr_value = _zpl_escape(qr_payload) | qr_value = _zpl_escape(qr_payload) | ||||
| # Explicit ^PQ1: each ^XA…^XZ is exactly one bag. Avoids E1005 "over quantity" on some Zebra/DataFlex | # Explicit ^PQ1: each ^XA…^XZ is exactly one bag. Avoids E1005 "over quantity" on some Zebra/DataFlex | ||||
| # firmware when many labels are sent on one TCP session without a per-job quantity. | # firmware when many labels are sent on one TCP session without a per-job quantity. | ||||
| return f"""^XA | |||||
| host_id = _dataflex_host_identification_sgd_prefix(job_order_id) | |||||
| return host_id + f"""^XA | |||||
| ^PQ1,0,1,N | ^PQ1,0,1,N | ||||
| ^CI28 | ^CI28 | ||||
| ^PW700 | |||||
| ^LL500 | |||||
| ^PW{DATAFLEX_LABEL_PW} | |||||
| ^LL{DATAFLEX_LABEL_LL} | |||||
| ^PO N | ^PO N | ||||
| ^FO10,20 | ^FO10,20 | ||||
| ^BQN,2,4^FDQA,{qr_value}^FS | ^BQN,2,4^FDQA,{qr_value}^FS | ||||
| @@ -447,10 +514,7 @@ def send_dataflex_preprint_reset(ip: str, port: int, *, force: bool = False) -> | |||||
| sock.connect((ip, port)) | sock.connect((ip, port)) | ||||
| sock.sendall(DATAFLEX_PREPRINT_BYTES) | sock.sendall(DATAFLEX_PREPRINT_BYTES) | ||||
| time.sleep(DATAFLEX_POST_PREPRINT_DELAY_SEC) | time.sleep(DATAFLEX_POST_PREPRINT_DELAY_SEC) | ||||
| try: | |||||
| sock.shutdown(socket.SHUT_WR) | |||||
| except OSError: | |||||
| pass | |||||
| _dataflex_shutdown_write_maybe(sock) | |||||
| finally: | finally: | ||||
| sock.close() | sock.close() | ||||
| @@ -472,10 +536,7 @@ def send_dataflex_job_counter_reset(ip: str, port: int, *, force: bool = False) | |||||
| sock.connect((ip, port)) | sock.connect((ip, port)) | ||||
| sock.sendall(_dataflex_full_recovery_payload()) | sock.sendall(_dataflex_full_recovery_payload()) | ||||
| time.sleep(DATAFLEX_POST_FULL_RECOVERY_DELAY_SEC) | time.sleep(DATAFLEX_POST_FULL_RECOVERY_DELAY_SEC) | ||||
| try: | |||||
| sock.shutdown(socket.SHUT_WR) | |||||
| except OSError: | |||||
| pass | |||||
| _dataflex_shutdown_write_maybe(sock) | |||||
| finally: | finally: | ||||
| sock.close() | sock.close() | ||||
| @@ -527,10 +588,7 @@ def send_dataflex_reset_and_labels( | |||||
| time.sleep(DATAFLEX_POST_LABEL_SETTLE_SEC) | time.sleep(DATAFLEX_POST_LABEL_SETTLE_SEC) | ||||
| if i < copies - 1: | if i < copies - 1: | ||||
| time.sleep(delay_sec) | time.sleep(delay_sec) | ||||
| try: | |||||
| sock.shutdown(socket.SHUT_WR) | |||||
| except OSError: | |||||
| pass | |||||
| _dataflex_shutdown_write_maybe(sock) | |||||
| finally: | finally: | ||||
| sock.close() | sock.close() | ||||
| @@ -879,10 +937,7 @@ def send_zpl_to_dataflex(ip: str, port: int, zpl: str) -> None: | |||||
| sock.connect((ip, port)) | sock.connect((ip, port)) | ||||
| sock.sendall(_dataflex_zpl_bytes(zpl)) | sock.sendall(_dataflex_zpl_bytes(zpl)) | ||||
| time.sleep(DATAFLEX_POST_LABEL_SETTLE_SEC) | time.sleep(DATAFLEX_POST_LABEL_SETTLE_SEC) | ||||
| try: | |||||
| sock.shutdown(socket.SHUT_WR) | |||||
| except OSError: | |||||
| pass | |||||
| _dataflex_shutdown_write_maybe(sock) | |||||
| finally: | finally: | ||||
| sock.close() | sock.close() | ||||
| @@ -907,6 +962,10 @@ def query_dataflex_host_status(ip: str, port: int) -> str: | |||||
| data = sock.recv(4096) | data = sock.recv(4096) | ||||
| except socket.timeout: | except socket.timeout: | ||||
| break | break | ||||
| except OSError as ex: | |||||
| if _dataflex_is_benign_tcp_reset(ex): | |||||
| break | |||||
| raise | |||||
| if not data: | if not data: | ||||
| break | break | ||||
| chunks.append(data) | chunks.append(data) | ||||
| @@ -1829,6 +1888,204 @@ def ask_bag_count(parent: tk.Tk) -> Optional[Tuple[int, bool]]: | |||||
| return result[0] | return result[0] | ||||
| @dataclass(frozen=True) | |||||
| class DataflexPrintSession: | |||||
| """ | |||||
| Snapshot taken when the user starts DataFlex print (especially C 連續印). | |||||
| The worker must use only this object — not grid row index, scroll position, or selection. | |||||
| """ | |||||
| job_order_id: Optional[int] | |||||
| job_code: str | |||||
| item_code: str | |||||
| item_name: str | |||||
| label_text: str | |||||
| zpl: str | |||||
| printer_ip: str | |||||
| printer_port: int | |||||
| batch_display: str | |||||
| def build_dataflex_print_session( | |||||
| jo: dict, | |||||
| batch: str, | |||||
| zpl: str, | |||||
| label_text: str, | |||||
| printer_ip: str, | |||||
| printer_port: int, | |||||
| ) -> DataflexPrintSession: | |||||
| jo_id = jo.get("id") | |||||
| jo_code = (jo.get("code") or "").strip() | |||||
| if not jo_code and jo_id is not None: | |||||
| jo_code = f"#{jo_id}" | |||||
| elif not jo_code: | |||||
| jo_code = "—" | |||||
| return DataflexPrintSession( | |||||
| job_order_id=int(jo_id) if jo_id is not None else None, | |||||
| job_code=jo_code, | |||||
| item_code=(jo.get("itemCode") or "—").strip(), | |||||
| item_name=(jo.get("itemName") or "—").strip(), | |||||
| label_text=label_text, | |||||
| zpl=zpl, | |||||
| printer_ip=printer_ip, | |||||
| printer_port=printer_port, | |||||
| batch_display=(batch or "—").strip(), | |||||
| ) | |||||
| def run_dataflex_continuous_thread( | |||||
| root: tk.Tk, | |||||
| session: DataflexPrintSession, | |||||
| stop_event: threading.Event, | |||||
| stop_win: tk.Toplevel, | |||||
| dataflex_lock: threading.Lock, | |||||
| dataflex_busy_ref: list, | |||||
| dataflex_stop_win_ref: list, | |||||
| active_session_ref: list, | |||||
| base_url: str, | |||||
| set_status_message: Callable[[str, bool], None], | |||||
| on_recorded: Callable[[], None], | |||||
| ) -> None: | |||||
| """Send bags in a loop until stop_event; all payload comes from session (in-memory snapshot).""" | |||||
| def worker() -> None: | |||||
| with dataflex_lock: | |||||
| if dataflex_busy_ref[0]: | |||||
| active_session_ref[0] = None | |||||
| def _abort_start() -> None: | |||||
| messagebox.showwarning( | |||||
| "打袋機", | |||||
| "請等待目前列印完成或先停止連續列印。", | |||||
| ) | |||||
| dataflex_stop_win_ref[0] = None | |||||
| try: | |||||
| stop_win.destroy() | |||||
| except tk.TclError: | |||||
| pass | |||||
| root.after(0, _abort_start) | |||||
| return | |||||
| dataflex_busy_ref[0] = True | |||||
| ip = session.printer_ip | |||||
| port = session.printer_port | |||||
| zpl = session.zpl | |||||
| label_text = session.label_text | |||||
| printed = 0 | |||||
| error_shown = False | |||||
| try: | |||||
| send_dataflex_start_job_reset(ip, port, force=True) | |||||
| while not stop_event.is_set(): | |||||
| send_dataflex_label_with_recovery(ip, port, zpl) | |||||
| printed += 1 | |||||
| if DATAFLEX_UI_PROGRESS_EVERY > 0 and ( | |||||
| printed == 1 or printed % DATAFLEX_UI_PROGRESS_EVERY == 0 | |||||
| ): | |||||
| p = printed | |||||
| root.after( | |||||
| 0, | |||||
| lambda p=p, jc=session.job_code: set_status_message( | |||||
| f"連續打袋 · 工單 {jc}… 已印 {p} 張", | |||||
| is_error=False, | |||||
| ), | |||||
| ) | |||||
| if ( | |||||
| DATAFLEX_VERIFY_EVERY_LABELS > 0 | |||||
| and printed % DATAFLEX_VERIFY_EVERY_LABELS == 0 | |||||
| ): | |||||
| recover_dataflex_if_host_fault(ip, port) | |||||
| if ( | |||||
| DATAFLEX_COOLDOWN_EVERY_LABELS > 0 | |||||
| and printed % DATAFLEX_COOLDOWN_EVERY_LABELS == 0 | |||||
| ): | |||||
| _sleep_interruptible(stop_event, max(0.0, DATAFLEX_COOLDOWN_SEC)) | |||||
| if ( | |||||
| DATAFLEX_THERMAL_REST_EVERY_LABELS > 0 | |||||
| and printed % DATAFLEX_THERMAL_REST_EVERY_LABELS == 0 | |||||
| ): | |||||
| _sleep_interruptible(stop_event, max(0.0, DATAFLEX_THERMAL_REST_SEC)) | |||||
| _sleep_interruptible(stop_event, DATAFLEX_INTER_LABEL_DELAY_SEC) | |||||
| except ConnectionRefusedError: | |||||
| error_shown = True | |||||
| root.after( | |||||
| 0, | |||||
| lambda: set_status_message( | |||||
| f"無法連線至 {ip}:{port},請確認印表機已開機且 IP 正確。", | |||||
| is_error=True, | |||||
| ), | |||||
| ) | |||||
| except socket.timeout: | |||||
| error_shown = True | |||||
| root.after( | |||||
| 0, | |||||
| lambda: set_status_message( | |||||
| f"連線逾時 ({ip}:{port}),請檢查網路與連接埠。", | |||||
| is_error=True, | |||||
| ), | |||||
| ) | |||||
| except OSError as err: | |||||
| error_shown = True | |||||
| root.after( | |||||
| 0, | |||||
| lambda e=err: set_status_message(f"列印失敗:{e}", is_error=True), | |||||
| ) | |||||
| except RuntimeError as err: | |||||
| error_shown = True | |||||
| root.after( | |||||
| 0, | |||||
| lambda e=err: set_status_message(f"打袋機錯誤:{e}", is_error=True), | |||||
| ) | |||||
| except Exception as err: | |||||
| error_shown = True | |||||
| root.after( | |||||
| 0, | |||||
| lambda e=err: set_status_message(f"打袋機例外:{e}", is_error=True), | |||||
| ) | |||||
| finally: | |||||
| with dataflex_lock: | |||||
| dataflex_busy_ref[0] = False | |||||
| active_session_ref[0] = None | |||||
| def _done() -> None: | |||||
| dataflex_stop_win_ref[0] = None | |||||
| try: | |||||
| if os.name == "nt": | |||||
| stop_win.attributes("-topmost", False) | |||||
| except tk.TclError: | |||||
| pass | |||||
| try: | |||||
| stop_win.destroy() | |||||
| except tk.TclError: | |||||
| pass | |||||
| jc = session.job_code | |||||
| if printed > 0: | |||||
| set_status_message( | |||||
| f"連續列印結束:工單 {jc} · {label_text},已印 {printed} 張", | |||||
| is_error=False, | |||||
| ) | |||||
| if session.job_order_id is not None: | |||||
| try: | |||||
| submit_job_order_print_submit( | |||||
| base_url, | |||||
| session.job_order_id, | |||||
| printed, | |||||
| "DATAFLEX", | |||||
| ) | |||||
| on_recorded() | |||||
| except requests.RequestException as ex: | |||||
| messagebox.showwarning( | |||||
| "打袋機", | |||||
| f"列印可能已完成,但伺服器記錄失敗(可再試):{ex}", | |||||
| ) | |||||
| elif not error_shown: | |||||
| set_status_message("連續列印未印出或已取消", is_error=True) | |||||
| root.after(0, _done) | |||||
| threading.Thread(target=worker, daemon=True).start() | |||||
| def _sleep_interruptible(stop_event: threading.Event, total_sec: float) -> None: | def _sleep_interruptible(stop_event: threading.Event, total_sec: float) -> None: | ||||
| """Sleep up to total_sec but return early if stop_event is set.""" | """Sleep up to total_sec but return early if stop_event is set.""" | ||||
| end = time.perf_counter() + total_sec | end = time.perf_counter() + total_sec | ||||
| @@ -1845,16 +2102,18 @@ def open_dataflex_stop_window( | |||||
| parent: tk.Tk, | parent: tk.Tk, | ||||
| stop_event: threading.Event, | stop_event: threading.Event, | ||||
| stop_win_ref: list, | stop_win_ref: list, | ||||
| session: DataflexPrintSession, | |||||
| ) -> tk.Toplevel: | ) -> tk.Toplevel: | ||||
| """ | """ | ||||
| Small window with 停止列印 for DataFlex continuous mode (non-modal so stop stays usable). | Small window with 停止列印 for DataFlex continuous mode (non-modal so stop stays usable). | ||||
| Stays above other dialogs (e.g. 標籤機 quantity) via periodic lift + optional topmost on Windows, | Stays above other dialogs (e.g. 標籤機 quantity) via periodic lift + optional topmost on Windows, | ||||
| so switching printer and printing labels does not hide the stop control. Ref is cleared on destroy. | so switching printer and printing labels does not hide the stop control. Ref is cleared on destroy. | ||||
| Job details come from the in-memory session snapshot, not the grid selection. | |||||
| """ | """ | ||||
| win = tk.Toplevel(parent) | win = tk.Toplevel(parent) | ||||
| win.title("打袋機連續列印") | win.title("打袋機連續列印") | ||||
| win.geometry("420x170") | |||||
| win.geometry("480x240") | |||||
| # On Windows, transient(root) can hide this Toplevel when the menubutton / printer row | # On Windows, transient(root) can hide this Toplevel when the menubutton / printer row | ||||
| # updates (e.g. switching to 激光機); keep transient only on non-Windows. | # updates (e.g. switching to 激光機); keep transient only on non-Windows. | ||||
| if os.name != "nt": | if os.name != "nt": | ||||
| @@ -1869,11 +2128,28 @@ def open_dataflex_stop_window( | |||||
| tk.Label( | tk.Label( | ||||
| win, | win, | ||||
| text="連續列印進行中(與上方列印機選項無關),可隨時按下方停止。", | |||||
| text="連續列印進行中(內容以按下 C 時的工單為準,與列表捲動/日期無關)", | |||||
| font=get_font(FONT_SIZE_META), | |||||
| bg=BG_TOP, | |||||
| wraplength=440, | |||||
| justify=tk.CENTER, | |||||
| ).pack(pady=(12, 6)) | |||||
| detail = ( | |||||
| f"工單:{session.job_code}\n" | |||||
| f"品號:{session.item_code}\n" | |||||
| f"品名:{session.item_name}\n" | |||||
| f"批次/批號:{session.label_text}" | |||||
| ) | |||||
| tk.Label( | |||||
| win, | |||||
| text=detail, | |||||
| font=get_font(FONT_SIZE), | font=get_font(FONT_SIZE), | ||||
| bg=BG_TOP, | bg=BG_TOP, | ||||
| wraplength=400, | |||||
| ).pack(pady=(16, 8)) | |||||
| fg="#111111", | |||||
| wraplength=440, | |||||
| justify=tk.LEFT, | |||||
| anchor=tk.W, | |||||
| ).pack(padx=16, pady=(0, 8), fill=tk.X) | |||||
| def clear_topmost() -> None: | def clear_topmost() -> None: | ||||
| if os.name == "nt": | if os.name == "nt": | ||||
| @@ -1985,6 +2261,8 @@ def main() -> None: | |||||
| label_busy_ref: list = [False] | label_busy_ref: list = [False] | ||||
| # DataFlex continuous: stop Toplevel ref so we can lift it after other dialogs | # DataFlex continuous: stop Toplevel ref so we can lift it after other dialogs | ||||
| dataflex_stop_win_ref: list = [None] | dataflex_stop_win_ref: list = [None] | ||||
| # In-memory job snapshot for C 連續印 (not tied to grid row position after start) | |||||
| active_dataflex_session_ref: list[Optional[DataflexPrintSession]] = [None] | |||||
| def lift_dataflex_stop_if_running() -> None: | def lift_dataflex_stop_if_running() -> None: | ||||
| """After closing another dialog (e.g. 標籤印數), bring the stop panel forward again.""" | """After closing another dialog (e.g. 標籤印數), bring the stop panel forward again.""" | ||||
| @@ -2417,6 +2695,20 @@ def main() -> None: | |||||
| name_lbl.pack(anchor=tk.NW) | name_lbl.pack(anchor=tk.NW) | ||||
| def _on_click(e, j=jo, b=batch, r=row): | def _on_click(e, j=jo, b=batch, r=row): | ||||
| if ( | |||||
| printer_var.get() == "打袋機 DataFlex" | |||||
| and dataflex_busy_ref[0] | |||||
| and active_dataflex_session_ref[0] is not None | |||||
| ): | |||||
| s = active_dataflex_session_ref[0] | |||||
| messagebox.showwarning( | |||||
| "打袋機", | |||||
| f"連續列印進行中,請先按「停止列印」。\n\n" | |||||
| f"工單:{s.job_code}\n" | |||||
| f"品號:{s.item_code}\n" | |||||
| f"品名:{s.item_name}", | |||||
| ) | |||||
| return | |||||
| if selected_row_holder[0] is not None: | if selected_row_holder[0] is not None: | ||||
| set_row_highlight(selected_row_holder[0], False) | set_row_highlight(selected_row_holder[0], False) | ||||
| set_row_highlight(r, True) | set_row_highlight(r, True) | ||||
| @@ -2451,161 +2743,47 @@ def main() -> None: | |||||
| item_id=item_id, | item_id=item_id, | ||||
| stock_in_line_id=stock_in_line_id, | stock_in_line_id=stock_in_line_id, | ||||
| lot_no=lot_no, | lot_no=lot_no, | ||||
| job_order_id=j.get("id"), | |||||
| ) | ) | ||||
| label_text = (lot_no or b).strip() | label_text = (lot_no or b).strip() | ||||
| if continuous: | if continuous: | ||||
| if dataflex_busy_ref[0]: | |||||
| messagebox.showwarning( | |||||
| "打袋機", | |||||
| "請等待目前列印完成或先停止連續列印。", | |||||
| ) | |||||
| return | |||||
| session = build_dataflex_print_session( | |||||
| j, | |||||
| b, | |||||
| zpl, | |||||
| label_text, | |||||
| ip, | |||||
| port, | |||||
| ) | |||||
| active_dataflex_session_ref[0] = session | |||||
| stop_ev = threading.Event() | stop_ev = threading.Event() | ||||
| stop_win = open_dataflex_stop_window( | stop_win = open_dataflex_stop_window( | ||||
| root, stop_ev, dataflex_stop_win_ref | |||||
| root, | |||||
| stop_ev, | |||||
| dataflex_stop_win_ref, | |||||
| session, | |||||
| ) | |||||
| run_dataflex_continuous_thread( | |||||
| root=root, | |||||
| session=session, | |||||
| stop_event=stop_ev, | |||||
| stop_win=stop_win, | |||||
| dataflex_lock=dataflex_lock, | |||||
| dataflex_busy_ref=dataflex_busy_ref, | |||||
| dataflex_stop_win_ref=dataflex_stop_win_ref, | |||||
| active_session_ref=active_dataflex_session_ref, | |||||
| base_url=base_url_ref[0], | |||||
| set_status_message=set_status_message, | |||||
| on_recorded=lambda: load_job_orders( | |||||
| from_user_date_change=False | |||||
| ), | |||||
| ) | ) | ||||
| def dflex_worker() -> None: | |||||
| with dataflex_lock: | |||||
| if dataflex_busy_ref[0]: | |||||
| root.after( | |||||
| 0, | |||||
| lambda: messagebox.showwarning( | |||||
| "打袋機", | |||||
| "請等待目前列印完成或先停止連續列印。", | |||||
| ), | |||||
| ) | |||||
| return | |||||
| dataflex_busy_ref[0] = True | |||||
| printed = 0 | |||||
| error_shown = False | |||||
| try: | |||||
| # One TCP job per bag (not one endless stream). Persistent socket | |||||
| # caused E1005 over-qty on some DataFlex units after a few labels. | |||||
| send_dataflex_start_job_reset(ip, port, force=True) | |||||
| while not stop_ev.is_set(): | |||||
| send_dataflex_label_with_recovery(ip, port, zpl) | |||||
| printed += 1 | |||||
| if DATAFLEX_UI_PROGRESS_EVERY > 0 and ( | |||||
| printed == 1 | |||||
| or printed % DATAFLEX_UI_PROGRESS_EVERY == 0 | |||||
| ): | |||||
| p = printed | |||||
| root.after( | |||||
| 0, | |||||
| lambda p=p: set_status_message( | |||||
| f"連續打袋列印中… 已印 {p} 張", | |||||
| is_error=False, | |||||
| ), | |||||
| ) | |||||
| if ( | |||||
| DATAFLEX_VERIFY_EVERY_LABELS > 0 | |||||
| and printed % DATAFLEX_VERIFY_EVERY_LABELS == 0 | |||||
| ): | |||||
| recover_dataflex_if_host_fault(ip, port) | |||||
| if ( | |||||
| DATAFLEX_COOLDOWN_EVERY_LABELS > 0 | |||||
| and printed % DATAFLEX_COOLDOWN_EVERY_LABELS == 0 | |||||
| ): | |||||
| _sleep_interruptible( | |||||
| stop_ev, | |||||
| max(0.0, DATAFLEX_COOLDOWN_SEC), | |||||
| ) | |||||
| if ( | |||||
| DATAFLEX_THERMAL_REST_EVERY_LABELS > 0 | |||||
| and printed % DATAFLEX_THERMAL_REST_EVERY_LABELS == 0 | |||||
| ): | |||||
| _sleep_interruptible( | |||||
| stop_ev, | |||||
| max(0.0, DATAFLEX_THERMAL_REST_SEC), | |||||
| ) | |||||
| _sleep_interruptible( | |||||
| stop_ev, | |||||
| DATAFLEX_INTER_LABEL_DELAY_SEC, | |||||
| ) | |||||
| except ConnectionRefusedError: | |||||
| error_shown = True | |||||
| root.after( | |||||
| 0, | |||||
| lambda: set_status_message( | |||||
| f"無法連線至 {ip}:{port},請確認印表機已開機且 IP 正確。", | |||||
| is_error=True, | |||||
| ), | |||||
| ) | |||||
| except socket.timeout: | |||||
| error_shown = True | |||||
| root.after( | |||||
| 0, | |||||
| lambda: set_status_message( | |||||
| f"連線逾時 ({ip}:{port}),請檢查網路與連接埠。", | |||||
| is_error=True, | |||||
| ), | |||||
| ) | |||||
| except OSError as err: | |||||
| error_shown = True | |||||
| root.after( | |||||
| 0, | |||||
| lambda e=err: set_status_message( | |||||
| f"列印失敗:{e}", | |||||
| is_error=True, | |||||
| ), | |||||
| ) | |||||
| except RuntimeError as err: | |||||
| error_shown = True | |||||
| root.after( | |||||
| 0, | |||||
| lambda e=err: set_status_message( | |||||
| f"打袋機錯誤:{e}", | |||||
| is_error=True, | |||||
| ), | |||||
| ) | |||||
| except Exception as err: | |||||
| error_shown = True | |||||
| root.after( | |||||
| 0, | |||||
| lambda e=err: set_status_message( | |||||
| f"打袋機例外:{e}", | |||||
| is_error=True, | |||||
| ), | |||||
| ) | |||||
| finally: | |||||
| with dataflex_lock: | |||||
| dataflex_busy_ref[0] = False | |||||
| def _done() -> None: | |||||
| dataflex_stop_win_ref[0] = None | |||||
| try: | |||||
| if os.name == "nt": | |||||
| stop_win.attributes("-topmost", False) | |||||
| except tk.TclError: | |||||
| pass | |||||
| try: | |||||
| stop_win.destroy() | |||||
| except tk.TclError: | |||||
| pass | |||||
| if printed > 0: | |||||
| set_status_message( | |||||
| f"連續列印結束:批次 {label_text},已印 {printed} 張", | |||||
| is_error=False, | |||||
| ) | |||||
| jo_id = j.get("id") | |||||
| if jo_id is not None: | |||||
| try: | |||||
| submit_job_order_print_submit( | |||||
| base_url_ref[0], | |||||
| int(jo_id), | |||||
| printed, | |||||
| "DATAFLEX", | |||||
| ) | |||||
| load_job_orders(from_user_date_change=False) | |||||
| except requests.RequestException as ex: | |||||
| messagebox.showwarning( | |||||
| "打袋機", | |||||
| f"列印可能已完成,但伺服器記錄失敗(可再試):{ex}", | |||||
| ) | |||||
| elif not error_shown: | |||||
| set_status_message( | |||||
| "連續列印未印出或已取消", | |||||
| is_error=True, | |||||
| ) | |||||
| root.after(0, _done) | |||||
| threading.Thread(target=dflex_worker, daemon=True).start() | |||||
| else: | else: | ||||
| run_dataflex_fixed_qty_thread( | run_dataflex_fixed_qty_thread( | ||||
| root=root, | root=root, | ||||
| @@ -2803,5 +2981,41 @@ def main() -> None: | |||||
| root.mainloop() | root.mainloop() | ||||
| def _startup_error_log_path() -> str: | |||||
| if getattr(sys, "frozen", False): | |||||
| base = os.path.dirname(sys.executable) | |||||
| else: | |||||
| base = os.path.dirname(os.path.abspath(__file__)) | |||||
| return os.path.join(base, "bag3_startup_error.log") | |||||
| if __name__ == "__main__": | if __name__ == "__main__": | ||||
| main() | |||||
| try: | |||||
| main() | |||||
| except SystemExit: | |||||
| raise | |||||
| except Exception: | |||||
| import traceback | |||||
| log_path = _startup_error_log_path() | |||||
| try: | |||||
| with open(log_path, "w", encoding="utf-8") as f: | |||||
| traceback.print_exc(file=f) | |||||
| except OSError: | |||||
| log_path = "(could not write log file)" | |||||
| msg = f"Bag3 啟動失敗,詳情已寫入:\n{log_path}" | |||||
| print(msg, file=sys.stderr) | |||||
| traceback.print_exc() | |||||
| try: | |||||
| _err_root = tk.Tk() | |||||
| _err_root.withdraw() | |||||
| messagebox.showerror("Bag3", msg) | |||||
| _err_root.destroy() | |||||
| except Exception: | |||||
| pass | |||||
| if getattr(sys, "frozen", False): | |||||
| try: | |||||
| input("按 Enter 關閉…") | |||||
| except (EOFError, KeyboardInterrupt): | |||||
| pass | |||||
| sys.exit(1) | |||||
| @@ -1,5 +1,35 @@ | |||||
| # Bag3 Windows exe build (run all commands in this python/ folder) | |||||
| py -m pip install --upgrade pyinstaller | py -m pip install --upgrade pyinstaller | ||||
| py -m pip install --upgrade pywin32 | py -m pip install --upgrade pywin32 | ||||
| py -m pip install --upgrade Pillow "qrcode[pil]" | |||||
| py -m pip install --upgrade Pillow "qrcode[pil]" requests | |||||
| py -m PyInstaller --noconfirm --clean Bag3.spec | |||||
| # Output: dist\Bag3\Bag3.exe plus dist\Bag3\_internal\... | |||||
| # Copy the ENTIRE dist\Bag3\ folder to the client PC (not only Bag3.exe). | |||||
| # --- If the client exe flashes and closes --- | |||||
| 1) On the client PC, open cmd in the Bag3 folder and run: | |||||
| Bag3.exe | |||||
| You should see the error in the console, or open bag3_startup_error.log next to Bag3.exe. | |||||
| 2) Compare BUILD machines (both should match): | |||||
| py --version | |||||
| py -m PyInstaller --version | |||||
| py -m pip show pyinstaller pywin32 Pillow qrcode requests | |||||
| A broken build is often caused by: | |||||
| - Different Python major version (e.g. 3.13 vs 3.11) | |||||
| - Incomplete tkinter on that Python (Store Python / partial install) | |||||
| - Old PyInstaller missing Tcl/Tk files in the bundle | |||||
| 3) Rebuild on the machine that works, or reinstall Python from python.org (64-bit) | |||||
| and reinstall deps above, then rebuild. | |||||
| 4) Bag3.spec disables UPX (upx=False) for stability; do not re-enable unless you test on the client. | |||||
| 5) Client needs 64-bit Windows and Microsoft VC++ Redistributable (same as your Python installer). | |||||
| py -m PyInstaller --noconfirm --clean Bag3.spec | |||||
| 6) Antivirus may quarantine files under _internal\ — whitelist the Bag3 folder if the log mentions missing DLL. | |||||
| @@ -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(); | 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 | @Bean | ||||
| @Order(1) | @Order(1) | ||||
| public SecurityFilterChain filterChain(HttpSecurity http) throws Exception { | public SecurityFilterChain filterChain(HttpSecurity http) throws Exception { | ||||
| @@ -91,6 +96,38 @@ public class SecurityConfig { | |||||
| .hasAnyAuthority("TESTING", "ADMIN", "STOCK") | .hasAnyAuthority("TESTING", "ADMIN", "STOCK") | ||||
| .requestMatchers(HttpMethod.GET, "/product-process/Demo/Process/alerts/fg-qc-putaway") | .requestMatchers(HttpMethod.GET, "/product-process/Demo/Process/alerts/fg-qc-putaway") | ||||
| .hasAuthority("TESTING") | .hasAuthority("TESTING") | ||||
| .requestMatchers(HttpMethod.GET, "/device-presence/ping").authenticated() | |||||
| .requestMatchers(HttpMethod.POST, "/device-presence/heartbeat").authenticated() | |||||
| .requestMatchers(HttpMethod.GET, "/device-presence/active") | |||||
| .hasAnyAuthority("TESTING", "ADMIN") | |||||
| .requestMatchers(HttpMethod.GET, "/device-presence/history") | |||||
| .hasAnyAuthority("TESTING", "ADMIN") | |||||
| .requestMatchers(HttpMethod.GET, "/printer-monitor/status") | |||||
| .hasAnyAuthority("TESTING", "ADMIN") | |||||
| .requestMatchers(HttpMethod.GET, "/printer-monitor/history") | |||||
| .hasAnyAuthority("TESTING", "ADMIN") | |||||
| .requestMatchers(HttpMethod.POST, "/printer-monitor/check") | |||||
| .hasAnyAuthority("TESTING", "ADMIN") | |||||
| .requestMatchers(HttpMethod.GET, "/label-printer-monitor/status") | |||||
| .hasAnyAuthority("TESTING", "ADMIN") | |||||
| .requestMatchers(HttpMethod.POST, "/label-printer-monitor/check") | |||||
| .hasAnyAuthority("TESTING", "ADMIN") | |||||
| .requestMatchers(HttpMethod.GET, "/label-printer-monitor/label-stats") | |||||
| .hasAnyAuthority("TESTING", "ADMIN") | |||||
| .requestMatchers(HttpMethod.GET, "/label-printer-monitor/odometer-stats") | |||||
| .hasAnyAuthority("TESTING", "ADMIN") | |||||
| .requestMatchers(HttpMethod.POST, "/stockAdjustment/submit") | |||||
| .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()) | .anyRequest().authenticated()) | ||||
| .httpBasic(httpBasic -> httpBasic.authenticationEntryPoint( | .httpBasic(httpBasic -> httpBasic.authenticationEntryPoint( | ||||
| (request, response, authException) -> sendUnauthorizedJson(response, "Unauthorized", "UNAUTHORIZED"))) | (request, response, authException) -> sendUnauthorizedJson(response, "Unauthorized", "UNAUTHORIZED"))) | ||||
| @@ -0,0 +1,48 @@ | |||||
| package com.ffii.fpsms.m18.entity | |||||
| import com.ffii.core.entity.BaseEntity | |||||
| import jakarta.persistence.Column | |||||
| import jakarta.persistence.Entity | |||||
| import jakarta.persistence.Table | |||||
| import jakarta.validation.constraints.NotNull | |||||
| /** | |||||
| * Audit log for FPSMS → M18 udfBomForShop sync (request / response bodies). | |||||
| */ | |||||
| @Entity | |||||
| @Table(name = "m18_bom_shop_sync_log") | |||||
| open class M18BomShopSyncLog : BaseEntity<Long>() { | |||||
| @NotNull | |||||
| @Column(name = "bom_id", nullable = false) | |||||
| open var bomId: Long? = null | |||||
| @Column(name = "finished_item_code", length = 100) | |||||
| open var finishedItemCode: String? = null | |||||
| @Column(name = "m18_header_code", length = 200) | |||||
| open var m18HeaderCode: String? = null | |||||
| @Column(name = "request_fingerprint", length = 64) | |||||
| open var requestFingerprint: String? = null | |||||
| @Column(name = "m18_record_id") | |||||
| open var m18RecordId: Long? = null | |||||
| @NotNull | |||||
| @Column(name = "m18_api_status", nullable = false) | |||||
| open var m18ApiStatus: Boolean = false | |||||
| @NotNull | |||||
| @Column(name = "synced", nullable = false) | |||||
| open var synced: Boolean = false | |||||
| @Column(name = "message", length = 4000) | |||||
| open var message: String? = null | |||||
| @Column(name = "request_json", columnDefinition = "LONGTEXT") | |||||
| open var requestJson: String? = null | |||||
| @Column(name = "response_json", columnDefinition = "LONGTEXT") | |||||
| open var responseJson: String? = null | |||||
| } | |||||
| @@ -0,0 +1,49 @@ | |||||
| package com.ffii.fpsms.m18.entity | |||||
| import com.ffii.core.support.AbstractRepository | |||||
| import org.springframework.data.jpa.repository.Query | |||||
| import org.springframework.data.repository.query.Param | |||||
| interface M18BomShopSyncLogRepository : AbstractRepository<M18BomShopSyncLog, Long> { | |||||
| fun findFirstByBomIdOrderByIdDesc(bomId: Long): M18BomShopSyncLog? | |||||
| fun findTop100ByBomIdOrderByIdDesc(bomId: Long): List<M18BomShopSyncLog> | |||||
| /** Successful M18 udfBomForShop saves only — used for `BOM{item}Vnnn` version allocation. */ | |||||
| fun findTop100ByBomIdAndSyncedIsTrueOrderByIdDesc(bomId: Long): List<M18BomShopSyncLog> | |||||
| fun findFirstByBomIdAndSyncedIsTrueAndRequestFingerprintOrderByIdDesc( | |||||
| bomId: Long, | |||||
| requestFingerprint: String, | |||||
| ): M18BomShopSyncLog? | |||||
| fun findFirstByBomIdAndSyncedIsTrueAndM18HeaderCodeOrderByIdDesc( | |||||
| bomId: Long, | |||||
| m18HeaderCode: String, | |||||
| ): M18BomShopSyncLog? | |||||
| @Query( | |||||
| """ | |||||
| SELECT l FROM M18BomShopSyncLog l | |||||
| WHERE l.deleted = false | |||||
| AND (:syncDateStart IS NULL OR l.created >= :syncDateStart) | |||||
| AND (:syncDateEnd IS NULL OR l.created <= :syncDateEnd) | |||||
| AND ( | |||||
| :finishedItemCode IS NULL OR :finishedItemCode = '' | |||||
| OR LOWER(l.finishedItemCode) LIKE LOWER(CONCAT('%', :finishedItemCode, '%')) | |||||
| ) | |||||
| AND ( | |||||
| :syncStatus IS NULL OR :syncStatus = '' OR :syncStatus = 'all' | |||||
| OR (:syncStatus = 'success' AND l.synced = true) | |||||
| OR (:syncStatus = 'failed' AND l.synced = false) | |||||
| ) | |||||
| ORDER BY l.created DESC, l.id DESC | |||||
| """, | |||||
| ) | |||||
| fun searchForReport( | |||||
| @Param("syncDateStart") syncDateStart: java.time.LocalDateTime?, | |||||
| @Param("syncDateEnd") syncDateEnd: java.time.LocalDateTime?, | |||||
| @Param("finishedItemCode") finishedItemCode: String?, | |||||
| @Param("syncStatus") syncStatus: String?, | |||||
| ): List<M18BomShopSyncLog> | |||||
| } | |||||
| @@ -3,9 +3,16 @@ package com.ffii.fpsms.m18.entity | |||||
| import com.ffii.core.support.AbstractRepository | import com.ffii.core.support.AbstractRepository | ||||
| import com.ffii.fpsms.m18.enums.M18DataLogStatus | import com.ffii.fpsms.m18.enums.M18DataLogStatus | ||||
| import org.springframework.stereotype.Repository | import org.springframework.stereotype.Repository | ||||
| import java.time.LocalDateTime | |||||
| @Repository | @Repository | ||||
| interface M18DataLogRepository : AbstractRepository<M18DataLog, Long> { | interface M18DataLogRepository : AbstractRepository<M18DataLog, Long> { | ||||
| // find latest m18 data log by m18 id & ref type & status is true & deleted is false (order by id asc limit 1) | // find latest m18 data log by m18 id & ref type & status is true & deleted is false (order by id asc limit 1) | ||||
| fun findTopByM18IdAndRefTypeAndDeletedIsFalseAndStatusOrderByIdDesc(m18Id: Long, refType: String, status: M18DataLogStatus): M18DataLog? | fun findTopByM18IdAndRefTypeAndDeletedIsFalseAndStatusOrderByIdDesc(m18Id: Long, refType: String, status: M18DataLogStatus): M18DataLog? | ||||
| fun findAllByRefTypeAndStatusAndDeletedIsFalseAndCreatedGreaterThanEqualOrderByIdAsc( | |||||
| refType: String, | |||||
| status: M18DataLogStatus, | |||||
| created: LocalDateTime, | |||||
| ): List<M18DataLog> | |||||
| } | } | ||||
| @@ -1,10 +1,16 @@ | |||||
| package com.ffii.fpsms.m18.entity | package com.ffii.fpsms.m18.entity | ||||
| import com.ffii.core.support.AbstractRepository | import com.ffii.core.support.AbstractRepository | ||||
| import com.ffii.fpsms.m18.enums.M18DataLogStatus | |||||
| import org.springframework.stereotype.Repository | import org.springframework.stereotype.Repository | ||||
| import java.time.LocalDateTime | |||||
| @Repository | @Repository | ||||
| interface SchedulerSyncLogRepository : AbstractRepository<SchedulerSyncLog, Long> { | interface SchedulerSyncLogRepository : AbstractRepository<SchedulerSyncLog, Long> { | ||||
| fun findTop20ByOrderByEndTimeDesc(): List<SchedulerSyncLog> | fun findTop20ByOrderByEndTimeDesc(): List<SchedulerSyncLog> | ||||
| fun findFirstBySyncTypeAndStartTimeBetweenOrderByEndTimeDesc( | |||||
| syncType: String, | |||||
| startTime: LocalDateTime, | |||||
| endTime: LocalDateTime, | |||||
| ): SchedulerSyncLog? | |||||
| } | } | ||||
| @@ -0,0 +1,14 @@ | |||||
| package com.ffii.fpsms.m18.model | |||||
| /** | |||||
| * Outcome of [com.ffii.fpsms.m18.service.M18BomForShopService.saveBomForShopWithVersionRetry] | |||||
| * (may differ from the initial request when header version was bumped). | |||||
| */ | |||||
| data class M18BomForShopSaveAttemptResult( | |||||
| val request: M18BomForShopSaveRequest, | |||||
| val response: GoodsReceiptNoteResponse?, | |||||
| val callError: Throwable?, | |||||
| val versionBumps: Int = 0, | |||||
| /** True when M18 save was skipped because an identical payload was already synced successfully. */ | |||||
| val skippedUnchanged: Boolean = false, | |||||
| ) | |||||
| @@ -0,0 +1,96 @@ | |||||
| package com.ffii.fpsms.m18.model | |||||
| import com.fasterxml.jackson.annotation.JsonInclude | |||||
| import com.fasterxml.jackson.annotation.JsonProperty | |||||
| /** | |||||
| * M18 save payload for Shop BOM (udfBomForShop). | |||||
| * PUT /root/api/save/udfbomforshop?menuCode=udfbomforshop | |||||
| * | |||||
| * Same idea as GRN (`mainan` + `ant`): header and lines each wrapped as `{ "values": [ ... ] }`. | |||||
| * Root keys: **`udfbomforshop`** and **`udfproduct`** (same as M18 read [M18BomData]). | |||||
| * (Spelling is **udf**, not "uni".) | |||||
| */ | |||||
| @JsonInclude(JsonInclude.Include.NON_NULL) | |||||
| data class M18BomForShopSaveRequest( | |||||
| @JsonProperty("udfbomforshop") | |||||
| val udfbomforshop: M18MainUdfBomForShopWrapper, | |||||
| @JsonProperty("udfproduct") | |||||
| val udfproduct: M18UdfProductWrapper, | |||||
| ) | |||||
| @JsonInclude(JsonInclude.Include.NON_NULL) | |||||
| data class M18MainUdfBomForShopWrapper( | |||||
| val values: List<M18MainUdfBomForShopValue>, | |||||
| ) | |||||
| /** | |||||
| * Header row for udfBomForShop. Field names match M18 read/sample JSON. | |||||
| */ | |||||
| @JsonInclude(JsonInclude.Include.NON_NULL) | |||||
| data class M18MainUdfBomForShopValue( | |||||
| /** | |||||
| * Existing M18 udfBomForShop header id for **update** (same as FPSMS [Bom.m18Id] after first sync). | |||||
| * Omit or null for **create**. Sent as JSON string for M18 compatibility (like GRN mainan `id`). | |||||
| */ | |||||
| val id: String? = null, | |||||
| val code: String? = null, | |||||
| val beId: Int? = null, | |||||
| val desc: String? = null, | |||||
| @JsonProperty("desc_en") | |||||
| val descEn: String? = null, | |||||
| @JsonProperty("udfBOMCode") | |||||
| val udfBomCode: String? = null, | |||||
| val rev: String? = null, | |||||
| val udfUnit: Long? = null, | |||||
| /** Harvest qty: [Bom.outputQty] × pack multiple from header item stock UOM code (e.g. PACK2LB → ×2), else plain output qty. */ | |||||
| val udfHarvest: String? = null, | |||||
| /** Trailing unit letters from that code (e.g. LB); null if code not parsed. */ | |||||
| val udfHarvestUnit: String? = null, | |||||
| /** Epoch milliseconds (M18-style; same as read `lastModifyDate`). From FPSMS [com.ffii.core.entity.BaseEntity.created] in Asia/Hong_Kong. */ | |||||
| @JsonProperty("udfeffectivedate") | |||||
| val udfEffectiveDate: Long? = null, | |||||
| @JsonProperty("udfYieldratePP") | |||||
| val udfYieldratePP: Number? = null, | |||||
| val udftypeoffood: String? = null, | |||||
| @JsonProperty("udfconfirmed") | |||||
| val udfconfirmed: Boolean? = null, | |||||
| val staffId: Int? = null, | |||||
| val flowTypeId: Int? = null, | |||||
| val virDeptId: Int? = null, | |||||
| val status: String? = null, | |||||
| ) | |||||
| @JsonInclude(JsonInclude.Include.NON_NULL) | |||||
| data class M18UdfProductWrapper( | |||||
| val values: List<M18UdfProductSaveValue>, | |||||
| ) | |||||
| /** | |||||
| * Line payload for `udfproduct.values[]`. **`udfBaseUnit`** is the FPSMS UOM **code** for the line. | |||||
| * **`udfPackingUnit`** / **`udfPackingQty`** / **`udfproremark`** are not sent. | |||||
| */ | |||||
| @JsonInclude(JsonInclude.Include.NON_NULL) | |||||
| data class M18UdfProductSaveValue( | |||||
| /** Line id in M18 when updating */ | |||||
| val id: Long? = null, | |||||
| val udfqty: Number? = null, | |||||
| val udfProduct: Long? = null, | |||||
| val udfIngredients: String? = null, | |||||
| /** Line UOM: [com.ffii.fpsms.modules.master.entity.UomConversion.code] (same unit as [udfqty]). */ | |||||
| val udfBaseUnit: String? = null, | |||||
| /** M18 vendor id ([StSearchType.VENDOR]) for the BOM business entity: PP → [M18Config.BEID_PP], PF → [M18Config.BEID_PF]. */ | |||||
| val udfSupplier: Long? = null, | |||||
| /** | |||||
| * M18 UOM id for price/purchase unit: from an M18-linked PO line on the **same BE** as the BOM. | |||||
| * When PP/PF supplier is resolved via code from another BE's PO, unit is taken from a target-BE PO line | |||||
| * (same item + supplier code), not from the foreign BE line. | |||||
| */ | |||||
| @JsonProperty("udfpurchaseUnit") | |||||
| val udfpurchaseUnit: Long? = null, | |||||
| /** Line sequence, e.g. " 1" */ | |||||
| val itemNo: String? = null, | |||||
| val udfoptions: String? = null, | |||||
| val udfoption: Number? = null, | |||||
| val udfYieldRate: Number? = null, | |||||
| ) | |||||
| @@ -0,0 +1,18 @@ | |||||
| package com.ffii.fpsms.m18.model | |||||
| /** | |||||
| * Result of scheduling job [com.ffii.fpsms.modules.master.service.BomM18ShopBulkPushService.pushAllBomsToM18ShopIfAllowed]. | |||||
| */ | |||||
| data class M18BomShopBatchSyncSummary( | |||||
| /** BOM rows with deleted=false scanned. */ | |||||
| val totalProcessed: Int, | |||||
| val synced: Int, | |||||
| /** Pushed attempted but [M18BomShopSyncTriggerResult.synced] is false (includes build/API failures). */ | |||||
| val notSynced: Int, | |||||
| /** [SettingNames.M18_BOM_SHOP_SYNC_ENABLED] is off — no BOMs attempted. */ | |||||
| val skippedBecauseFeatureDisabled: Boolean = false, | |||||
| ) { | |||||
| /** One-line summary for logs / scheduler_sync_log.query */ | |||||
| fun toLogQuery(): String = | |||||
| "BOMShop batch: processed=$totalProcessed synced=$synced notSynced=$notSynced skippedFeatureDisabled=$skippedBecauseFeatureDisabled" | |||||
| } | |||||
| @@ -0,0 +1,14 @@ | |||||
| package com.ffii.fpsms.m18.model | |||||
| /** | |||||
| * Result of [com.ffii.fpsms.modules.master.service.BomService.pushBomToM18ShopIfAllowed] | |||||
| * (e.g. POST /m18/test/bom-shop-sync/{bomId}). | |||||
| */ | |||||
| data class M18BomShopSyncTriggerResult( | |||||
| val bomId: Long, | |||||
| val synced: Boolean, | |||||
| val skippedReason: String? = null, | |||||
| val recordId: Long? = null, | |||||
| val status: Boolean? = null, | |||||
| val messageSummary: String? = null, | |||||
| ) | |||||
| @@ -0,0 +1,714 @@ | |||||
| package com.ffii.fpsms.m18.service | |||||
| import com.fasterxml.jackson.core.JsonGenerator | |||||
| import com.fasterxml.jackson.databind.ObjectMapper | |||||
| import com.fasterxml.jackson.module.kotlin.jacksonObjectMapper | |||||
| import com.ffii.fpsms.api.service.ApiCallerService | |||||
| import com.ffii.fpsms.m18.M18Config | |||||
| import com.ffii.fpsms.m18.entity.M18BomShopSyncLog | |||||
| import com.ffii.fpsms.m18.entity.M18BomShopSyncLogRepository | |||||
| import com.ffii.fpsms.m18.model.GoodsReceiptNoteResponse | |||||
| import com.ffii.fpsms.m18.model.M18BomForShopSaveAttemptResult | |||||
| import com.ffii.fpsms.m18.model.M18BomForShopSaveRequest | |||||
| import com.ffii.fpsms.m18.model.M18MainUdfBomForShopValue | |||||
| import com.ffii.fpsms.m18.model.M18MainUdfBomForShopWrapper | |||||
| import com.ffii.fpsms.m18.model.M18UdfProductSaveValue | |||||
| import com.ffii.fpsms.m18.model.M18UdfProductWrapper | |||||
| import com.ffii.fpsms.modules.master.entity.Bom | |||||
| import com.ffii.fpsms.modules.master.entity.BomMaterial | |||||
| import com.ffii.fpsms.modules.master.service.ItemUomService | |||||
| import com.ffii.fpsms.modules.master.service.ShopService | |||||
| import com.ffii.fpsms.modules.purchaseOrder.entity.PurchaseOrderLine | |||||
| import com.ffii.fpsms.modules.purchaseOrder.entity.PurchaseOrderLineRepository | |||||
| import org.slf4j.Logger | |||||
| import org.slf4j.LoggerFactory | |||||
| import org.springframework.data.domain.PageRequest | |||||
| import org.springframework.stereotype.Service | |||||
| import org.springframework.util.LinkedMultiValueMap | |||||
| import reactor.core.publisher.Mono | |||||
| import java.math.BigDecimal | |||||
| import java.math.RoundingMode | |||||
| import java.nio.charset.StandardCharsets | |||||
| import java.security.MessageDigest | |||||
| import java.time.ZoneId | |||||
| /** | |||||
| * Push FPSMS BOM + materials to M18 udfBomForShop (similar to GRN save/an). | |||||
| * PUT /root/api/save/udfbomforshop?menuCode=udfbomforshop | |||||
| */ | |||||
| @Service | |||||
| open class M18BomForShopService( | |||||
| private val m18Config: M18Config, | |||||
| private val apiCallerService: ApiCallerService, | |||||
| private val itemUomService: ItemUomService, | |||||
| private val purchaseOrderLineRepository: PurchaseOrderLineRepository, | |||||
| private val m18BomShopSyncLogRepository: M18BomShopSyncLogRepository, | |||||
| private val shopService: ShopService, | |||||
| private val m18VendorLookupService: M18VendorLookupService, | |||||
| private val m18BomHeaderLookupService: M18BomHeaderLookupService, | |||||
| ) { | |||||
| private val logger: Logger = LoggerFactory.getLogger(M18BomForShopService::class.java) | |||||
| private val savePath = "/root/api/save/udfbomforshop" | |||||
| private val menuCode = "udfbomforshop" | |||||
| /** M18 business entity id for udfBomForShop header (`udfbomforshop.values[0].beId`). */ | |||||
| private val bomShopMainBeId: Int = 29 | |||||
| /** | |||||
| * Stock UOM `code` on the **BOM header item** (e.g. PACK2LB = prefix + pack multiple + unit suffix). | |||||
| * [udfHarvest] = [Bom.outputQty] × middle number; [udfHarvestUnit] = trailing unit (e.g. LB). | |||||
| */ | |||||
| private val bomItemStockUomPackCodeRegex = Regex("^([A-Za-z]+)(\\d+)([A-Za-z]+)$") | |||||
| companion object { | |||||
| private const val HARVEST_CALC_SCALE = 10 | |||||
| internal const val BOM_SHOP_HEADER_VERSION_DIGITS = 4 | |||||
| private val m18Tz: ZoneId = ZoneId.of("Asia/Hong_Kong") | |||||
| private fun formatBomShopHeaderCode(itemCode: String, version: Int): String = | |||||
| "BOM${itemCode}V${version.toString().padStart(BOM_SHOP_HEADER_VERSION_DIGITS, '0')}" | |||||
| internal fun normalizedHeaderRevision(versionDigits: String): String = | |||||
| versionDigits.padStart(BOM_SHOP_HEADER_VERSION_DIGITS, '0') | |||||
| } | |||||
| @Suppress("DEPRECATION") | |||||
| private val objectMapper: ObjectMapper = jacksonObjectMapper().apply { | |||||
| disable(JsonGenerator.Feature.ESCAPE_NON_ASCII) | |||||
| } | |||||
| /** | |||||
| * Stable hash of payload **excluding** M18 header `id`, `code`, and `rev` (so version bumps do not affect equality). | |||||
| * Used with [M18BomShopSyncLog] to decide V0000 vs V0001+. | |||||
| */ | |||||
| open fun contentFingerprint(request: M18BomForShopSaveRequest): String { | |||||
| val json = objectMapper.writeValueAsString(normalizedForFingerprint(request)) | |||||
| return sha256Hex(json) | |||||
| } | |||||
| private fun normalizedForFingerprint(request: M18BomForShopSaveRequest): M18BomForShopSaveRequest { | |||||
| val v = request.udfbomforshop.values.firstOrNull() | |||||
| ?: return request | |||||
| val headerNorm = v.copy(id = null, code = null, rev = null) | |||||
| val linesSorted = request.udfproduct.values.sortedWith( | |||||
| compareBy({ it.itemNo }, { it.udfProduct }, { it.udfIngredients }), | |||||
| ) | |||||
| return M18BomForShopSaveRequest( | |||||
| udfbomforshop = M18MainUdfBomForShopWrapper(values = listOf(headerNorm)), | |||||
| udfproduct = M18UdfProductWrapper(values = linesSorted), | |||||
| ) | |||||
| } | |||||
| private fun sha256Hex(text: String): String { | |||||
| val md = MessageDigest.getInstance("SHA-256") | |||||
| val bytes = md.digest(text.toByteArray(StandardCharsets.UTF_8)) | |||||
| return bytes.joinToString("") { "%02x".format(it) } | |||||
| } | |||||
| /** | |||||
| * Builds M18 save body from a persisted BOM (materials loaded). | |||||
| * [headerM18IdOverride] optional M18 header record id when forcing update; skips version/fingerprint logic for **id** only, | |||||
| * reuses latest logged [M18BomShopSyncLog.m18HeaderCode] when possible. | |||||
| * Otherwise uses [Bom.m18Id] when the normalized payload matches the latest log; on content change, bumps `BOM{item}Vnnnn`. | |||||
| */ | |||||
| open fun buildSaveRequest(bom: Bom, headerM18IdOverride: Long? = null): M18BomForShopSaveRequest? { | |||||
| val bomId = bom.id ?: return null | |||||
| val routingCode = bom.code ?: return null | |||||
| val itemCode = bom.item?.code?.trim().orEmpty().ifEmpty { | |||||
| logger.warn("[M18 BOM] bom.item.code missing; cannot build M18 BOM shop payload. bomId=$bomId") | |||||
| return null | |||||
| } | |||||
| val flowTypeId = resolveFlowTypeId(routingCode) | |||||
| val udfUnit = bom.uom?.m18Id?.takeIf { it > 0 } ?: return null | |||||
| val outputQty = bom.outputQty ?: BigDecimal.ZERO | |||||
| val (udfHarvest, udfHarvestUnit) = resolveUdfHarvestFields(bom, outputQty) | |||||
| val udfEffectiveDate = bom.created?.atZone(m18Tz)?.toInstant()?.toEpochMilli() | |||||
| val targetBeId = resolveTargetBeId(flowTypeId) | |||||
| val supplierCache = mutableMapOf<String, Long?>() | |||||
| val lines = bom.bomMaterials | |||||
| .filter { it.deleted != true } | |||||
| .sortedBy { it.id ?: 0L } | |||||
| .mapIndexedNotNull { idx, mat -> | |||||
| toProductLine(mat, idx + 1, flowTypeId, targetBeId, supplierCache) | |||||
| } | |||||
| if (lines.isEmpty()) { | |||||
| logger.warn("[M18 BOM] BOM id=$bomId code=$routingCode has no materials; skipping M18 save") | |||||
| return null | |||||
| } | |||||
| val (headerCode, rev, headerM18IdForRequest) = resolveHeaderCodeAndM18Id( | |||||
| bomId = bomId, | |||||
| itemCode = itemCode, | |||||
| lines = lines, | |||||
| udfUnit = udfUnit, | |||||
| udfHarvest = udfHarvest, | |||||
| udfHarvestUnit = udfHarvestUnit, | |||||
| udfEffectiveDate = udfEffectiveDate, | |||||
| bomYield = bom.yield, | |||||
| bomName = bom.name, | |||||
| bomDescription = bom.description, | |||||
| flowTypeId = flowTypeId, | |||||
| headerM18IdOverride = headerM18IdOverride, | |||||
| bomM18Id = bom.m18Id?.takeIf { it > 0 }, | |||||
| ) | |||||
| val header = M18MainUdfBomForShopValue( | |||||
| id = headerM18IdForRequest?.toString(), | |||||
| code = headerCode, | |||||
| beId = bomShopMainBeId, | |||||
| desc = bom.name ?: bom.description, | |||||
| descEn = bom.name ?: bom.description, | |||||
| udfBomCode = itemCode, | |||||
| rev = rev, | |||||
| udfUnit = udfUnit, | |||||
| udfHarvest = udfHarvest, | |||||
| udfHarvestUnit = udfHarvestUnit, | |||||
| udfEffectiveDate = udfEffectiveDate, | |||||
| udfYieldratePP = bom.yield, | |||||
| udftypeoffood = "半成品", | |||||
| udfconfirmed = true, | |||||
| staffId = 232, | |||||
| flowTypeId = flowTypeId, | |||||
| virDeptId = 117, | |||||
| status = "Y", | |||||
| ) | |||||
| logger.info( | |||||
| "[M18 BOM] buildSaveRequest fpsmsBomId=$bomId routingCode=$routingCode itemCode=$itemCode headerCode=$headerCode " + | |||||
| "mainM18Id=$headerM18IdForRequest (override=$headerM18IdOverride, bom.m18Id=${bom.m18Id})", | |||||
| ) | |||||
| return M18BomForShopSaveRequest( | |||||
| udfbomforshop = M18MainUdfBomForShopWrapper(values = listOf(header)), | |||||
| udfproduct = M18UdfProductWrapper(values = lines), | |||||
| ) | |||||
| } | |||||
| @Suppress("LongParameterList") | |||||
| private fun resolveHeaderCodeAndM18Id( | |||||
| bomId: Long, | |||||
| itemCode: String, | |||||
| lines: List<M18UdfProductSaveValue>, | |||||
| udfUnit: Long, | |||||
| udfHarvest: String, | |||||
| udfHarvestUnit: String?, | |||||
| udfEffectiveDate: Long?, | |||||
| bomYield: BigDecimal?, | |||||
| bomName: String?, | |||||
| bomDescription: String?, | |||||
| flowTypeId: Int, | |||||
| headerM18IdOverride: Long?, | |||||
| bomM18Id: Long?, | |||||
| ): Triple<String, String?, Long?> { | |||||
| val draftHeader = M18MainUdfBomForShopValue( | |||||
| id = null, | |||||
| code = null, | |||||
| beId = bomShopMainBeId, | |||||
| desc = bomName ?: bomDescription, | |||||
| descEn = bomName ?: bomDescription, | |||||
| udfBomCode = itemCode, | |||||
| rev = null, | |||||
| udfUnit = udfUnit, | |||||
| udfHarvest = udfHarvest, | |||||
| udfHarvestUnit = udfHarvestUnit, | |||||
| udfEffectiveDate = udfEffectiveDate, | |||||
| udfYieldratePP = bomYield, | |||||
| udftypeoffood = "半成品", | |||||
| udfconfirmed = true, | |||||
| staffId = 232, | |||||
| flowTypeId = flowTypeId, | |||||
| virDeptId = 117, | |||||
| status = "Y", | |||||
| ) | |||||
| val draftRequest = M18BomForShopSaveRequest( | |||||
| udfbomforshop = M18MainUdfBomForShopWrapper(values = listOf(draftHeader)), | |||||
| udfproduct = M18UdfProductWrapper(values = lines), | |||||
| ) | |||||
| val fp = contentFingerprint(draftRequest) | |||||
| val forcedId = headerM18IdOverride?.takeIf { it > 0 } | |||||
| if (forcedId != null) { | |||||
| val latest = m18BomShopSyncLogRepository.findFirstByBomIdOrderByIdDesc(bomId) | |||||
| val codeForUpdate = | |||||
| latest?.m18HeaderCode?.takeIf { it.isNotBlank() } | |||||
| ?: formatBomShopHeaderCode(itemCode, 0) | |||||
| val forcedRev = parseTrailingVersion(codeForUpdate) | |||||
| ?: "0".repeat(BOM_SHOP_HEADER_VERSION_DIGITS) | |||||
| return Triple(codeForUpdate, forcedRev, forcedId) | |||||
| } | |||||
| // Identical BOM details already synced — reuse header code + M18 id (update), never allocate a new version. | |||||
| findSuccessfulSyncByFingerprint(bomId, fp)?.let { match -> | |||||
| val reuseCode = match.m18HeaderCode?.trim().orEmpty() | |||||
| if (reuseCode.isNotEmpty()) { | |||||
| val reuseId = match.m18RecordId?.takeIf { it > 0L } ?: bomM18Id | |||||
| val revReuse = parseTrailingVersion(reuseCode) | |||||
| ?: "0".repeat(BOM_SHOP_HEADER_VERSION_DIGITS) | |||||
| return Triple(reuseCode, revReuse, reuseId) | |||||
| } | |||||
| } | |||||
| // Content changed — next version number (new M18 header code). | |||||
| val maxV = maxVersionFromLogs(bomId, itemCode) | |||||
| val nextV = maxV + 1 | |||||
| val newCode = formatBomShopHeaderCode(itemCode, nextV) | |||||
| val rev = nextV.toString().padStart(BOM_SHOP_HEADER_VERSION_DIGITS, '0') | |||||
| return Triple(newCode, rev, null) | |||||
| } | |||||
| private fun findSuccessfulSyncByFingerprint(bomId: Long, fingerprint: String): M18BomShopSyncLog? = | |||||
| m18BomShopSyncLogRepository.findFirstByBomIdAndSyncedIsTrueAndRequestFingerprintOrderByIdDesc( | |||||
| bomId, | |||||
| fingerprint, | |||||
| ) | |||||
| private fun maxVersionFromLogs(bomId: Long, itemCode: String): Int { | |||||
| val versionPat = Regex("^BOM${Regex.escape(itemCode)}V(\\d+)$") | |||||
| // Only successful syncs advance the numeric tail; failed attempts log a code but must not consume Vnnnn. | |||||
| return m18BomShopSyncLogRepository.findTop100ByBomIdAndSyncedIsTrueOrderByIdDesc(bomId) | |||||
| .mapNotNull { row -> | |||||
| val c = row.m18HeaderCode?.trim().orEmpty().ifEmpty { | |||||
| extractHeaderCodeFromJson(row.requestJson).orEmpty() | |||||
| } | |||||
| versionPat.find(c)?.groupValues?.get(1)?.toIntOrNull() | |||||
| } | |||||
| .maxOrNull() ?: -1 | |||||
| } | |||||
| private fun extractHeaderCodeFromJson(json: String?): String? { | |||||
| if (json.isNullOrBlank()) return null | |||||
| return runCatching { | |||||
| val node = objectMapper.readTree(json) | |||||
| val text = node.path("udfbomforshop").path("values").path(0).path("code").asText() | |||||
| text.trim().takeIf { it.isNotEmpty() } | |||||
| }.getOrNull() | |||||
| } | |||||
| private fun parseTrailingVersion(headerCode: String): String? = | |||||
| Regex("V(\\d+)$").find(headerCode.trim())?.groupValues?.get(1)?.let { normalizedHeaderRevision(it) } | |||||
| /** | |||||
| * From the **finished-good** [Bom.item] stock unit [com.ffii.fpsms.modules.master.entity.UomConversion.code] | |||||
| * (pattern `LETTER_PREFIX` + `DIGITS` + `UNIT_SUFFIX`, e.g. PACK2LB): harvest qty = outputQty × digits, unit = suffix. | |||||
| * Falls back to plain [outputQty] and null unit when item/stock UOM/code is missing or does not match. | |||||
| */ | |||||
| private fun resolveUdfHarvestFields(bom: Bom, outputQty: BigDecimal): Pair<String, String?> { | |||||
| val itemId = bom.item?.id | |||||
| if (itemId == null) { | |||||
| logger.warn("[M18 BOM] bom.item id missing; udfHarvest=outputQty only. bomId=${bom.id}") | |||||
| return outputQty.stripTrailingZeros().toPlainString() to null | |||||
| } | |||||
| val stockCode = itemUomService.findStockUnitByItemId(itemId)?.uom?.code?.trim().orEmpty() | |||||
| if (stockCode.isEmpty()) { | |||||
| logger.warn("[M18 BOM] stock UOM code missing for bom itemId=$itemId; udfHarvest=outputQty only. bomId=${bom.id}") | |||||
| return outputQty.stripTrailingZeros().toPlainString() to null | |||||
| } | |||||
| val match = bomItemStockUomPackCodeRegex.matchEntire(stockCode) | |||||
| if (match == null) { | |||||
| logger.warn( | |||||
| "[M18 BOM] stock UOM code '$stockCode' does not match PREFIX+NUMBER+SUFFIX; " + | |||||
| "udfHarvest=outputQty only. bomId=${bom.id} itemId=$itemId", | |||||
| ) | |||||
| return outputQty.stripTrailingZeros().toPlainString() to null | |||||
| } | |||||
| val mult = match.groupValues[2].toBigDecimalOrNull() | |||||
| if (mult == null || mult.compareTo(BigDecimal.ZERO) <= 0) { | |||||
| logger.warn( | |||||
| "[M18 BOM] invalid pack multiple in stock UOM code '$stockCode'; udfHarvest=outputQty only. bomId=${bom.id}", | |||||
| ) | |||||
| return outputQty.stripTrailingZeros().toPlainString() to null | |||||
| } | |||||
| val unitSuffix = match.groupValues[3] | |||||
| val harvestQty = outputQty.multiply(mult).setScale(HARVEST_CALC_SCALE, RoundingMode.HALF_UP).stripTrailingZeros() | |||||
| return harvestQty.toPlainString() to unitSuffix | |||||
| } | |||||
| private fun toProductLine( | |||||
| mat: BomMaterial, | |||||
| lineNo: Int, | |||||
| flowTypeId: Int, | |||||
| targetBeId: Long?, | |||||
| supplierCache: MutableMap<String, Long?>, | |||||
| ): M18UdfProductSaveValue? { | |||||
| val proId = mat.item?.m18Id?.takeIf { it > 0 } ?: run { | |||||
| logger.warn("[M18 BOM] material item m18Id missing bomMaterialId=${mat.id} itemId=${mat.item?.id}") | |||||
| return null | |||||
| } | |||||
| val udfBaseUnit = mat.uom?.code?.trim()?.takeIf { it.isNotEmpty() } ?: run { | |||||
| logger.warn("[M18 BOM] material UOM code missing bomMaterialId=${mat.id}") | |||||
| return null | |||||
| } | |||||
| val itemId = mat.item?.id | |||||
| val poLines = itemId?.let { id -> | |||||
| purchaseOrderLineRepository.findLatestLinesForBomM18ByItemId(id, PageRequest.of(0, 20)) | |||||
| } ?: emptyList() | |||||
| val supplierAndUnit = resolvePoSupplierAndPurchaseUnit(poLines, flowTypeId, targetBeId, supplierCache) | |||||
| val supplierM18Id = supplierAndUnit.supplierM18Id | |||||
| val purchaseUnitM18Id = supplierAndUnit.purchaseUnitM18Id | |||||
| val udfqty = (mat.qty ?: BigDecimal.ZERO).setScale(8, RoundingMode.HALF_UP).toDouble() | |||||
| return M18UdfProductSaveValue( | |||||
| id = mat.m18Id?.takeIf { it > 0 }, | |||||
| udfqty = udfqty, | |||||
| udfProduct = proId, | |||||
| udfIngredients = mat.itemName ?: mat.item?.name, | |||||
| udfBaseUnit = udfBaseUnit, | |||||
| udfSupplier = supplierM18Id, | |||||
| udfpurchaseUnit = purchaseUnitM18Id, | |||||
| itemNo = String.format("%6d", lineNo), | |||||
| udfoptions = "", | |||||
| udfoption = 0.0, | |||||
| udfYieldRate = 0.0, | |||||
| ) | |||||
| } | |||||
| /** Prefer a PO line whose header [com.ffii.fpsms.modules.purchaseOrder.entity.PurchaseOrder.m18BeId] matches the BOM BE. */ | |||||
| private fun pickPreferredPoLine(lines: List<PurchaseOrderLine>, preferredBeId: Long?): PurchaseOrderLine? { | |||||
| if (lines.isEmpty()) return null | |||||
| if (preferredBeId == null) return lines.first() | |||||
| return lines.firstOrNull { it.purchaseOrder?.m18BeId == preferredBeId } ?: lines.first() | |||||
| } | |||||
| private fun pickPoLineMatchingBeOnly(lines: List<PurchaseOrderLine>, beId: Long?): PurchaseOrderLine? { | |||||
| if (lines.isEmpty() || beId == null) return null | |||||
| return lines.firstOrNull { it.purchaseOrder?.m18BeId == beId } | |||||
| } | |||||
| /** M18 line price unit ([M18PurchaseOrderPot.unitId]): [PurchaseOrderLine.uomM18] then [PurchaseOrderLine.uom]. */ | |||||
| private fun purchaseUnitM18IdFromPoLine(poLine: PurchaseOrderLine?): Long? = | |||||
| poLine?.uomM18?.m18Id?.takeIf { it > 0L } | |||||
| ?: poLine?.uom?.m18Id?.takeIf { it > 0L } | |||||
| private data class PoSupplierUnitContext( | |||||
| val supplierM18Id: Long?, | |||||
| val purchaseUnitM18Id: Long?, | |||||
| ) | |||||
| /** | |||||
| * Supplier + purchase unit for BOM material lines. | |||||
| * PF/PP BOMs: [udfpurchaseUnit] must come from a PO line on the **same business entity** (PP unit with PP supplier). | |||||
| * When PP supplier is resolved via supplier code from a PF/other PO, look for a PP PO line for the same item + supplier code for the unit. | |||||
| */ | |||||
| private fun resolvePoSupplierAndPurchaseUnit( | |||||
| poLines: List<PurchaseOrderLine>, | |||||
| flowTypeId: Int, | |||||
| targetBeId: Long?, | |||||
| supplierCache: MutableMap<String, Long?>, | |||||
| ): PoSupplierUnitContext { | |||||
| if (poLines.isEmpty()) { | |||||
| return PoSupplierUnitContext(null, null) | |||||
| } | |||||
| val beMatchedLine = pickPoLineMatchingBeOnly(poLines, targetBeId) | |||||
| if (beMatchedLine != null) { | |||||
| return PoSupplierUnitContext( | |||||
| supplierM18Id = resolveSupplierM18Id(beMatchedLine, flowTypeId, supplierCache), | |||||
| purchaseUnitM18Id = purchaseUnitM18IdFromPoLine(beMatchedLine), | |||||
| ) | |||||
| } | |||||
| if (flowTypeId != 2 && flowTypeId != 3) { | |||||
| val line = pickPreferredPoLine(poLines, targetBeId) | |||||
| return PoSupplierUnitContext( | |||||
| supplierM18Id = resolveSupplierM18Id(line, flowTypeId, supplierCache), | |||||
| purchaseUnitM18Id = purchaseUnitM18IdFromPoLine(line), | |||||
| ) | |||||
| } | |||||
| val supplierSourceLine = pickPreferredPoLine(poLines, targetBeId) | |||||
| val supplierCode = supplierSourceLine?.purchaseOrder?.supplier?.code?.trim()?.takeIf { it.isNotEmpty() } | |||||
| val supplierM18Id = resolveSupplierM18Id(supplierSourceLine, flowTypeId, supplierCache) | |||||
| val unitLine = | |||||
| if (targetBeId != null && !supplierCode.isNullOrEmpty()) { | |||||
| poLines.firstOrNull { pol -> | |||||
| pol.purchaseOrder?.m18BeId == targetBeId && | |||||
| pol.purchaseOrder?.supplier?.code?.trim().equals(supplierCode, ignoreCase = true) | |||||
| } | |||||
| } else { | |||||
| null | |||||
| } | |||||
| val purchaseUnitM18Id = purchaseUnitM18IdFromPoLine(unitLine) | |||||
| if (supplierM18Id != null && purchaseUnitM18Id == null && supplierSourceLine != null) { | |||||
| val beLabel = if (flowTypeId == 2) "PF" else "PP" | |||||
| logger.warn( | |||||
| "[M18 BOM] $beLabel supplier resolved from PO code={} supplierCode={} but no $beLabel PO line " + | |||||
| "for same item+supplier — omitting udfpurchaseUnit (PF/other BE unit is invalid with $beLabel supplier)", | |||||
| supplierSourceLine.purchaseOrder?.code, | |||||
| supplierCode, | |||||
| ) | |||||
| } | |||||
| return PoSupplierUnitContext(supplierM18Id, purchaseUnitM18Id) | |||||
| } | |||||
| private fun resolveTargetBeId(flowTypeId: Int): Long? = when (flowTypeId) { | |||||
| 2 -> m18Config.BEID_PF.toLongOrNull() | |||||
| 3 -> m18Config.BEID_PP.toLongOrNull() | |||||
| else -> null | |||||
| } | |||||
| /** | |||||
| * Resolves M18 vendor id for BOM material line supplier: | |||||
| * - PF BOMs: M18 search by supplier code + [M18Config.BEID_PF] | |||||
| * - PP BOMs: M18 search by supplier code + [M18Config.BEID_PP] (never local [Shop.m18Id] first — duplicate codes may be PF ids) | |||||
| */ | |||||
| private fun resolveSupplierM18Id( | |||||
| latestPoLine: PurchaseOrderLine?, | |||||
| flowTypeId: Int, | |||||
| cache: MutableMap<String, Long?>, | |||||
| ): Long? { | |||||
| val po = latestPoLine?.purchaseOrder | |||||
| val supplier = po?.supplier | |||||
| val directM18Id = supplier?.m18Id?.takeIf { it > 0L } | |||||
| val supplierCode = supplier?.code?.trim()?.takeIf { it.isNotEmpty() } | |||||
| val targetBeId = resolveTargetBeId(flowTypeId) | |||||
| val poBeId = po?.m18BeId | |||||
| if (supplierCode == null) { | |||||
| return directM18Id | |||||
| } | |||||
| if (flowTypeId == 2 || flowTypeId == 3) { | |||||
| val cacheKey = "$supplierCode|$flowTypeId" | |||||
| cache[cacheKey]?.let { return it } | |||||
| val beId = if (flowTypeId == 2) m18Config.BEID_PF else m18Config.BEID_PP | |||||
| val beLabel = if (flowTypeId == 2) "PF" else "PP" | |||||
| val resolved = | |||||
| m18VendorLookupService.findVendorM18IdByCode(supplierCode, beId) | |||||
| ?: directM18Id.takeIf { poBeId != null && poBeId == targetBeId } | |||||
| if (resolved == null) { | |||||
| logger.warn("[M18 BOM] $beLabel vendor M18 id not found for supplierCode=$supplierCode") | |||||
| } | |||||
| cache[cacheKey] = resolved | |||||
| return resolved | |||||
| } | |||||
| return shopService.findVendorByCode(supplierCode)?.m18Id?.takeIf { it > 0L } | |||||
| ?: directM18Id | |||||
| } | |||||
| private fun resolveFlowTypeId(code: String): Int = when { | |||||
| code.startsWith("TOA") -> 1 | |||||
| code.startsWith("BOMPP") || code.startsWith("PP") -> 3 | |||||
| code.startsWith("BOMPF") || code.startsWith("PF") || code.startsWith("PFP") -> 2 | |||||
| else -> 1 | |||||
| } | |||||
| /** M18 rejects duplicate header [M18MainUdfBomForShopValue.code] on create (core_101903). */ | |||||
| open fun isSameCodeFoundError(resp: GoodsReceiptNoteResponse?): Boolean { | |||||
| if (resp == null || resp.status) return false | |||||
| return resp.messages.any { msg -> | |||||
| msg.msgCode == "core_101903" || | |||||
| msg.msgDetail?.contains("Same Code found", ignoreCase = true) == true | |||||
| } | |||||
| } | |||||
| private fun withHeaderM18Id( | |||||
| request: M18BomForShopSaveRequest, | |||||
| headerCode: String, | |||||
| m18Id: Long, | |||||
| ): M18BomForShopSaveRequest { | |||||
| val header = request.udfbomforshop.values.firstOrNull() | |||||
| ?: return request | |||||
| val rev = parseTrailingVersion(headerCode) ?: header.rev | |||||
| val newHeader = header.copy( | |||||
| id = m18Id.toString(), | |||||
| code = headerCode, | |||||
| rev = rev, | |||||
| ) | |||||
| return request.copy( | |||||
| udfbomforshop = request.udfbomforshop.copy(values = listOf(newHeader)), | |||||
| ) | |||||
| } | |||||
| /** | |||||
| * Increments `BOM{item}Vnnnn` tail, clears header `id` (new M18 row), updates `rev`. | |||||
| * Returns null when [udfBomCode] is missing. | |||||
| */ | |||||
| open fun bumpHeaderVersionForRetry(request: M18BomForShopSaveRequest): M18BomForShopSaveRequest? { | |||||
| val header = request.udfbomforshop.values.firstOrNull() ?: return null | |||||
| val itemCode = header.udfBomCode?.trim().orEmpty().ifEmpty { return null } | |||||
| val currentCode = header.code?.trim().orEmpty() | |||||
| val currentV = | |||||
| parseTrailingVersion(currentCode)?.toIntOrNull() | |||||
| ?: Regex("V(\\d+)$").find(currentCode)?.groupValues?.get(1)?.toIntOrNull() | |||||
| ?: -1 | |||||
| val nextV = currentV + 1 | |||||
| val newCode = formatBomShopHeaderCode(itemCode, nextV) | |||||
| val newRev = nextV.toString().padStart(BOM_SHOP_HEADER_VERSION_DIGITS, '0') | |||||
| val newHeader = header.copy(id = null, code = newCode, rev = newRev) | |||||
| return request.copy( | |||||
| udfbomforshop = request.udfbomforshop.copy(values = listOf(newHeader)), | |||||
| ) | |||||
| } | |||||
| /** | |||||
| * Saves to M18. On duplicate code: update existing row when details match a prior sync or M18 lookup; | |||||
| * bump version only when BOM content (fingerprint) is new. | |||||
| */ | |||||
| open fun saveBomForShopWithVersionRetry( | |||||
| request: M18BomForShopSaveRequest, | |||||
| bomId: Long, | |||||
| maxSameCodeRetries: Int = 20, | |||||
| ): M18BomForShopSaveAttemptResult { | |||||
| val fp = contentFingerprint(request) | |||||
| findSuccessfulSyncByFingerprint(bomId, fp)?.let { match -> | |||||
| val reuseCode = match.m18HeaderCode?.trim().orEmpty() | |||||
| val reuseId = match.m18RecordId?.takeIf { it > 0L } | |||||
| if (reuseCode.isNotEmpty() && reuseId != null) { | |||||
| logger.info( | |||||
| "[M18 BOM] Unchanged BOM details; skip new code (reuse headerCode={} m18Id={})", | |||||
| reuseCode, | |||||
| reuseId, | |||||
| ) | |||||
| return M18BomForShopSaveAttemptResult( | |||||
| request = withHeaderM18Id(request, reuseCode, reuseId), | |||||
| response = GoodsReceiptNoteResponse(recordId = reuseId, status = true), | |||||
| callError = null, | |||||
| versionBumps = 0, | |||||
| skippedUnchanged = true, | |||||
| ) | |||||
| } | |||||
| } | |||||
| var current = request | |||||
| var bumps = 0 | |||||
| var lastResp: GoodsReceiptNoteResponse? = null | |||||
| var lastError: Throwable? = null | |||||
| val attachIdAttemptedForCode = mutableSetOf<String>() | |||||
| while (true) { | |||||
| lastError = null | |||||
| try { | |||||
| lastResp = saveBomForShop(current) | |||||
| } catch (e: Exception) { | |||||
| lastError = e | |||||
| break | |||||
| } | |||||
| if ( | |||||
| lastResp == null || | |||||
| !isSameCodeFoundError(lastResp) || | |||||
| bumps >= maxSameCodeRetries | |||||
| ) { | |||||
| break | |||||
| } | |||||
| val header = current.udfbomforshop.values.firstOrNull() ?: break | |||||
| val code = header.code?.trim().orEmpty() | |||||
| // Same details already synced — update existing row, do not create a new version code. | |||||
| val reuseFromSync = resolveReuseFromSuccessfulSync(bomId, fp) | |||||
| if (reuseFromSync != null) { | |||||
| val (reuseCode, reuseId) = reuseFromSync | |||||
| current = withHeaderM18Id(current, reuseCode, reuseId) | |||||
| logger.info( | |||||
| "[M18 BOM] Same Code found; same details as prior sync — update headerCode={} m18Id={}", | |||||
| reuseCode, | |||||
| reuseId, | |||||
| ) | |||||
| continue | |||||
| } | |||||
| // Code exists in M18 without header id — attach id and update (same code, not a new version). | |||||
| if (header.id.isNullOrBlank() && code.isNotEmpty() && code !in attachIdAttemptedForCode) { | |||||
| attachIdAttemptedForCode.add(code) | |||||
| val m18Id = m18BomHeaderLookupService.findM18IdByHeaderCode(code) | |||||
| if (m18Id != null) { | |||||
| current = withHeaderM18Id(current, code, m18Id) | |||||
| logger.info("[M18 BOM] Same Code found; attach M18 id={} for headerCode={}", m18Id, code) | |||||
| continue | |||||
| } | |||||
| } | |||||
| // Same code already holds this exact content in our sync log — update that row. | |||||
| if (code.isNotEmpty()) { | |||||
| val logAtCode = | |||||
| m18BomShopSyncLogRepository.findFirstByBomIdAndSyncedIsTrueAndM18HeaderCodeOrderByIdDesc( | |||||
| bomId, | |||||
| code, | |||||
| ) | |||||
| if (logAtCode?.requestFingerprint == fp && logAtCode.m18RecordId != null) { | |||||
| current = withHeaderM18Id(current, code, logAtCode.m18RecordId!!) | |||||
| logger.info( | |||||
| "[M18 BOM] Same Code found; headerCode={} already has matching content — update m18Id={}", | |||||
| code, | |||||
| logAtCode.m18RecordId, | |||||
| ) | |||||
| continue | |||||
| } | |||||
| } | |||||
| // Content differs from existing code — allocate next version (new code for new details). | |||||
| val bumped = bumpHeaderVersionForRetry(current) ?: break | |||||
| val bumpedCode = bumped.udfbomforshop.values.firstOrNull()?.code?.trim().orEmpty() | |||||
| if (bumpedCode.isNotEmpty()) { | |||||
| val logAtBumped = | |||||
| m18BomShopSyncLogRepository.findFirstByBomIdAndSyncedIsTrueAndM18HeaderCodeOrderByIdDesc( | |||||
| bomId, | |||||
| bumpedCode, | |||||
| ) | |||||
| if (logAtBumped?.requestFingerprint == fp && logAtBumped.m18RecordId != null) { | |||||
| current = withHeaderM18Id(bumped, bumpedCode, logAtBumped.m18RecordId!!) | |||||
| logger.info( | |||||
| "[M18 BOM] Version {} already synced with same details — update m18Id={}", | |||||
| bumpedCode, | |||||
| logAtBumped.m18RecordId, | |||||
| ) | |||||
| continue | |||||
| } | |||||
| } | |||||
| current = bumped | |||||
| bumps++ | |||||
| logger.info( | |||||
| "[M18 BOM] Same Code found; content changed — bump version (#$bumps) headerCode={}", | |||||
| bumpedCode, | |||||
| ) | |||||
| } | |||||
| return M18BomForShopSaveAttemptResult( | |||||
| request = current, | |||||
| response = lastResp, | |||||
| callError = lastError, | |||||
| versionBumps = bumps, | |||||
| ) | |||||
| } | |||||
| private fun resolveReuseFromSuccessfulSync(bomId: Long, fingerprint: String): Pair<String, Long>? { | |||||
| val match = findSuccessfulSyncByFingerprint(bomId, fingerprint) ?: return null | |||||
| val reuseCode = match.m18HeaderCode?.trim().orEmpty().ifEmpty { return null } | |||||
| val reuseId = match.m18RecordId?.takeIf { it > 0L } ?: return null | |||||
| return reuseCode to reuseId | |||||
| } | |||||
| open fun toJson(request: M18BomForShopSaveRequest): String = | |||||
| objectMapper.writeValueAsString(request) | |||||
| open fun toJson(response: GoodsReceiptNoteResponse): String = | |||||
| objectMapper.writeValueAsString(response) | |||||
| open fun saveBomForShop(request: M18BomForShopSaveRequest): GoodsReceiptNoteResponse? = | |||||
| saveBomForShopMono(request).block() | |||||
| open fun saveBomForShopMono(request: M18BomForShopSaveRequest): Mono<GoodsReceiptNoteResponse> { | |||||
| val queryParams = LinkedMultiValueMap<String, String>().apply { | |||||
| add("menuCode", menuCode) | |||||
| } | |||||
| val qs = queryParams.entries.flatMap { (k, v) -> v.map { "$k=$it" } }.joinToString("&") | |||||
| val fullUrl = "${m18Config.BASE_URL}$savePath?$qs" | |||||
| val bodyJson = objectMapper.writeValueAsString(request) | |||||
| logger.info("[M18 BOM udfBomForShop] PUT url=$fullUrl bodyUtf8Bytes=${bodyJson.toByteArray(StandardCharsets.UTF_8).size}") | |||||
| logger.debug("[M18 BOM udfBomForShop] PUT body=$bodyJson") | |||||
| return apiCallerService.putWithJsonString<GoodsReceiptNoteResponse>( | |||||
| urlPath = savePath, | |||||
| queryParams = queryParams, | |||||
| bodyJson = bodyJson, | |||||
| ).doOnSuccess { r -> | |||||
| logger.info("[M18 BOM udfBomForShop] response status=${r.status} recordId=${r.recordId} messages=${r.messages}") | |||||
| }.doOnError { e -> | |||||
| logger.error("[M18 BOM udfBomForShop] failed: ${e.message}", e) | |||||
| } | |||||
| } | |||||
| } | |||||
| @@ -0,0 +1,41 @@ | |||||
| package com.ffii.fpsms.m18.service | |||||
| import com.ffii.fpsms.api.service.ApiCallerService | |||||
| import com.ffii.fpsms.m18.model.M18BomListResponse | |||||
| import com.ffii.fpsms.m18.model.M18CommonListRequest | |||||
| import com.ffii.fpsms.m18.model.StSearchType | |||||
| import org.slf4j.Logger | |||||
| import org.slf4j.LoggerFactory | |||||
| import org.springframework.stereotype.Service | |||||
| /** | |||||
| * M18 udfBomForShop header lookup by [code] — isolated from [M18MasterDataService] / [M18BomForShopService] cycles. | |||||
| */ | |||||
| @Service | |||||
| open class M18BomHeaderLookupService( | |||||
| private val apiCallerService: ApiCallerService, | |||||
| ) { | |||||
| private val logger: Logger = LoggerFactory.getLogger(M18BomHeaderLookupService::class.java) | |||||
| private val fetchListApi = "/search/search" | |||||
| open fun findM18IdByHeaderCode(headerCode: String): Long? { | |||||
| val trimmed = headerCode.trim() | |||||
| if (trimmed.isEmpty()) return null | |||||
| val conds = "(code=equal=$trimmed)" | |||||
| val listResponse = try { | |||||
| apiCallerService.get<M18BomListResponse, M18CommonListRequest>( | |||||
| fetchListApi, | |||||
| M18CommonListRequest( | |||||
| stSearch = StSearchType.BOM.value, | |||||
| params = null, | |||||
| conds = conds, | |||||
| ), | |||||
| ).block() | |||||
| } catch (e: Exception) { | |||||
| logger.warn("(findM18IdByHeaderCode) M18 search failed code=$trimmed: ${e.message}") | |||||
| null | |||||
| } | |||||
| return listResponse?.values?.firstOrNull()?.id?.takeIf { it > 0L } | |||||
| } | |||||
| } | |||||
| @@ -12,6 +12,7 @@ import com.ffii.fpsms.modules.deliveryOrder.enums.DeliveryOrderLineStatus | |||||
| import com.ffii.fpsms.modules.deliveryOrder.enums.DeliveryOrderStatus | import com.ffii.fpsms.modules.deliveryOrder.enums.DeliveryOrderStatus | ||||
| import com.ffii.fpsms.modules.deliveryOrder.service.DeliveryOrderLineService | import com.ffii.fpsms.modules.deliveryOrder.service.DeliveryOrderLineService | ||||
| import com.ffii.fpsms.modules.deliveryOrder.service.DeliveryOrderService | import com.ffii.fpsms.modules.deliveryOrder.service.DeliveryOrderService | ||||
| import com.ffii.fpsms.modules.deliveryOrder.entity.DeliveryOrderRepository | |||||
| import com.ffii.fpsms.modules.deliveryOrder.web.models.SaveDeliveryOrderLineRequest | import com.ffii.fpsms.modules.deliveryOrder.web.models.SaveDeliveryOrderLineRequest | ||||
| import com.ffii.fpsms.modules.deliveryOrder.web.models.SaveDeliveryOrderRequest | import com.ffii.fpsms.modules.deliveryOrder.web.models.SaveDeliveryOrderRequest | ||||
| import com.ffii.fpsms.modules.master.entity.ItemUom | import com.ffii.fpsms.modules.master.entity.ItemUom | ||||
| @@ -23,7 +24,6 @@ import com.ffii.fpsms.modules.purchaseOrder.enums.PurchaseOrderType | |||||
| import org.slf4j.Logger | import org.slf4j.Logger | ||||
| import org.slf4j.LoggerFactory | import org.slf4j.LoggerFactory | ||||
| import org.springframework.stereotype.Service | import org.springframework.stereotype.Service | ||||
| import java.sql.SQLException | |||||
| import java.time.LocalDateTime | import java.time.LocalDateTime | ||||
| import java.time.format.DateTimeFormatter | import java.time.format.DateTimeFormatter | ||||
| import kotlin.reflect.full.memberProperties | import kotlin.reflect.full.memberProperties | ||||
| @@ -35,6 +35,7 @@ open class M18DeliveryOrderService( | |||||
| val apiCallerService: ApiCallerService, | val apiCallerService: ApiCallerService, | ||||
| val m18DataLogService: M18DataLogService, | val m18DataLogService: M18DataLogService, | ||||
| val deliveryOrderService: DeliveryOrderService, | val deliveryOrderService: DeliveryOrderService, | ||||
| val deliveryOrderRepository: DeliveryOrderRepository, | |||||
| val deliveryOrderLineService: DeliveryOrderLineService, | val deliveryOrderLineService: DeliveryOrderLineService, | ||||
| val itemsService: ItemsService, | val itemsService: ItemsService, | ||||
| val shopService: ShopService, | val shopService: ShopService, | ||||
| @@ -106,7 +107,6 @@ open class M18DeliveryOrderService( | |||||
| if (request.dDateEqual != null) { | if (request.dDateEqual != null) { | ||||
| shopPoConds += "=and=(${dDateEqualConds})" | shopPoConds += "=and=(${dDateEqualConds})" | ||||
| } | } | ||||
| logger.info("shopPoConds: ${shopPoConds}") | logger.info("shopPoConds: ${shopPoConds}") | ||||
| val shopPoParams = M18PurchaseOrderListRequest( | val shopPoParams = M18PurchaseOrderListRequest( | ||||
| @@ -151,20 +151,41 @@ open class M18DeliveryOrderService( | |||||
| return deliveryOrder | return deliveryOrder | ||||
| } | } | ||||
| open fun saveDeliveryOrders(request: M18CommonRequest): SyncResult { | |||||
| open fun saveDeliveryOrders(request: M18CommonRequest, skipExistingDo: Boolean = false): SyncResult { | |||||
| val deliveryOrdersWithType = getDeliveryOrdersWithType(request) | val deliveryOrdersWithType = getDeliveryOrdersWithType(request) | ||||
| return saveDeliveryOrdersWithPreparedList(deliveryOrdersWithType) | |||||
| return saveDeliveryOrdersWithPreparedList( | |||||
| deliveryOrdersWithType, | |||||
| syncisExtra = false, | |||||
| skipExistingDo = skipExistingDo, | |||||
| ) | |||||
| } | } | ||||
| /** | /** | ||||
| * Sync a single M18 shop PO / delivery order by document [code], same search pattern as | * Sync a single M18 shop PO / delivery order by document [code], same search pattern as | ||||
| * [com.ffii.fpsms.m18.service.M18PurchaseOrderService.savePurchaseOrderByCode]. | * [com.ffii.fpsms.m18.service.M18PurchaseOrderService.savePurchaseOrderByCode]. | ||||
| * | |||||
| * @param isExtraSync when true, persist local `delivery_order.isExtra=true` (manual DO(加單) sync). | |||||
| * No M18-side "加單" filtering is used. | |||||
| * @param newOnly when true, skip if a non-deleted local DO already exists with the same `code`. | |||||
| */ | */ | ||||
| open fun saveDeliveryOrderByCode(code: String): SyncResult { | |||||
| open fun saveDeliveryOrderByCode( | |||||
| code: String, | |||||
| isExtraSync: Boolean = false, | |||||
| newOnly: Boolean = false, | |||||
| ): SyncResult { | |||||
| if (newOnly && deliveryOrderRepository.existsByCodeAndDeletedIsFalse(code)) { | |||||
| return SyncResult( | |||||
| totalProcessed = 1, | |||||
| totalSuccess = 0, | |||||
| totalFail = 0, | |||||
| query = "skipped (newOnly=true): delivery_order.code already exists: $code", | |||||
| ) | |||||
| } | |||||
| val conds = "(code=equal=$code)" | |||||
| val searchRequest = M18PurchaseOrderListRequest( | val searchRequest = M18PurchaseOrderListRequest( | ||||
| stSearch = "po", | stSearch = "po", | ||||
| params = null, | params = null, | ||||
| conds = "(code=equal=$code)" | |||||
| conds = conds | |||||
| ) | ) | ||||
| val doListResponse = try { | val doListResponse = try { | ||||
| apiCallerService.get<M18PurchaseOrderListResponse, M18PurchaseOrderListRequest>( | apiCallerService.get<M18PurchaseOrderListResponse, M18PurchaseOrderListRequest>( | ||||
| @@ -183,30 +204,36 @@ open class M18DeliveryOrderService( | |||||
| totalProcessed = 1, | totalProcessed = 1, | ||||
| totalSuccess = 0, | totalSuccess = 0, | ||||
| totalFail = 1, | totalFail = 1, | ||||
| query = "code=equal=$code" | |||||
| query = conds | |||||
| ) | ) | ||||
| } | } | ||||
| val prepared = M18PurchaseOrderListResponseWithType( | val prepared = M18PurchaseOrderListResponseWithType( | ||||
| valuesWithType = mutableListOf(Pair(PurchaseOrderType.SHOP, doListResponse)), | valuesWithType = mutableListOf(Pair(PurchaseOrderType.SHOP, doListResponse)), | ||||
| query = "code=equal=$code" | |||||
| query = conds | |||||
| ) | ) | ||||
| return saveDeliveryOrdersWithPreparedList(prepared) | |||||
| return saveDeliveryOrdersWithPreparedList(prepared, syncisExtra = isExtraSync, skipExistingDo = newOnly) | |||||
| } | } | ||||
| private fun saveDeliveryOrdersWithPreparedList( | private fun saveDeliveryOrdersWithPreparedList( | ||||
| deliveryOrdersWithType: M18PurchaseOrderListResponseWithType? | |||||
| deliveryOrdersWithType: M18PurchaseOrderListResponseWithType?, | |||||
| syncisExtra: Boolean = false, | |||||
| skipExistingDo: Boolean = false, | |||||
| ): SyncResult { | ): SyncResult { | ||||
| logger.info("--------------------------------------------Start - Saving M18 Delivery Order--------------------------------------------") | logger.info("--------------------------------------------Start - Saving M18 Delivery Order--------------------------------------------") | ||||
| if (skipExistingDo) { | |||||
| logger.info("skipExistingDo=true — local delivery orders will not be updated") | |||||
| } | |||||
| val successList = mutableListOf<Long>() | val successList = mutableListOf<Long>() | ||||
| val skippedList = mutableListOf<Long>() | |||||
| val successDetailList = mutableListOf<Long>() | val successDetailList = mutableListOf<Long>() | ||||
| val failList = mutableListOf<Long>() | val failList = mutableListOf<Long>() | ||||
| val failDetailList = mutableListOf<Long>() | val failDetailList = mutableListOf<Long>() | ||||
| val failItemDetailList = mutableListOf<Long>() | val failItemDetailList = mutableListOf<Long>() | ||||
| val uomByM18IdCache = mutableMapOf<Long, ItemUom?>() | val uomByM18IdCache = mutableMapOf<Long, ItemUom?>() | ||||
| val itemIdCache = mutableMapOf<Long, Long?>() | |||||
| val itemIdCache = mutableMapOf<Long, Long>() | |||||
| val stockUomIdCache = mutableMapOf<Pair<Long, Long>, Long?>() | val stockUomIdCache = mutableMapOf<Pair<Long, Long>, Long?>() | ||||
| val doRefType = "Delivery Order" | val doRefType = "Delivery Order" | ||||
| @@ -223,6 +250,22 @@ open class M18DeliveryOrderService( | |||||
| if (deliveryOrdersValues != null) { | if (deliveryOrdersValues != null) { | ||||
| deliveryOrdersValues.forEach { deliveryOrder -> | deliveryOrdersValues.forEach { deliveryOrder -> | ||||
| if (skipExistingDo) { | |||||
| val latestDeliveryOrderLog = | |||||
| m18DataLogService.findLatestM18DataLogWithSuccess(deliveryOrder.id, doRefType) | |||||
| val existingByM18 = latestDeliveryOrderLog?.id?.let { | |||||
| deliveryOrderService.findByM18DataLogId(it) | |||||
| } | |||||
| if (existingByM18 != null && existingByM18.deleted != true) { | |||||
| logger.info( | |||||
| "${doRefType}: skipExistingDo — skipping M18 id=${deliveryOrder.id} " + | |||||
| "code=${existingByM18.code} localId=${existingByM18.id} status=${existingByM18.status}" | |||||
| ) | |||||
| skippedList.add(deliveryOrder.id) | |||||
| return@forEach | |||||
| } | |||||
| } | |||||
| val deliveryOrderDetail = getDeliveryOrder(deliveryOrder.id) | val deliveryOrderDetail = getDeliveryOrder(deliveryOrder.id) | ||||
| var deliveryOrderId: Long? = null //FP-MTMS | var deliveryOrderId: Long? = null //FP-MTMS | ||||
| @@ -236,6 +279,14 @@ open class M18DeliveryOrderService( | |||||
| // delivery_order + m18_data_log table | // delivery_order + m18_data_log table | ||||
| if (mainpo != null) { | if (mainpo != null) { | ||||
| if (skipExistingDo && deliveryOrderRepository.existsByCodeAndDeletedIsFalse(mainpo.code)) { | |||||
| logger.info( | |||||
| "${doRefType}: skipExistingDo — skipping M18 id=${deliveryOrder.id} code=${mainpo.code} (local DO exists by code)" | |||||
| ) | |||||
| skippedList.add(deliveryOrder.id) | |||||
| return@forEach | |||||
| } | |||||
| // Find the latest m18 data log by m18 id & type | // Find the latest m18 data log by m18 id & type | ||||
| // logger.info("${doRefType}: Finding For Latest M18 Data Log...") | // logger.info("${doRefType}: Finding For Latest M18 Data Log...") | ||||
| val latestDeliveryOrderLog = | val latestDeliveryOrderLog = | ||||
| @@ -283,7 +334,8 @@ open class M18DeliveryOrderService( | |||||
| m18DataLogId = saveM18DeliveryOrderLog.id, | m18DataLogId = saveM18DeliveryOrderLog.id, | ||||
| handlerId = null, | handlerId = null, | ||||
| m18BeId = mainpo.beId, | m18BeId = mainpo.beId, | ||||
| deleted = mainpo.udfIsVoid == true | |||||
| deleted = mainpo.udfIsVoid == true, | |||||
| isExtra = syncisExtra, | |||||
| ) | ) | ||||
| val saveDeliveryOrderResponse = | val saveDeliveryOrderResponse = | ||||
| @@ -354,14 +406,10 @@ open class M18DeliveryOrderService( | |||||
| // logger.info("${doLineRefType}: Saved M18 Data Log. ID: ${saveM18DeliveryOrderLineLog.id}") | // logger.info("${doLineRefType}: Saved M18 Data Log. ID: ${saveM18DeliveryOrderLineLog.id}") | ||||
| // logger.info("${doLineRefType}: Finding item...") | // logger.info("${doLineRefType}: Finding item...") | ||||
| val itemId: Long? = itemIdCache.getOrPut(line.proId) { | |||||
| val item = itemsService.findByM18Id(line.proId) | |||||
| if (item == null) { | |||||
| m18MasterDataService.saveProduct(line.proId)?.id | |||||
| } else { | |||||
| item.id | |||||
| val itemId: Long? = itemIdCache[line.proId] | |||||
| ?: m18MasterDataService.resolveLocalItemId(line.proId)?.also { | |||||
| itemIdCache[line.proId] = it | |||||
| } | } | ||||
| } | |||||
| val stockUomId: Long? = if (itemId != null) { | val stockUomId: Long? = if (itemId != null) { | ||||
| val key = line.proId to line.unitId // safe key | val key = line.proId to line.unitId // safe key | ||||
| @@ -373,6 +421,23 @@ open class M18DeliveryOrderService( | |||||
| // logger.info("${doLineRefType}: Item ID: ${itemId} | M18 Item ID: ${line.proId}") | // logger.info("${doLineRefType}: Item ID: ${itemId} | M18 Item ID: ${line.proId}") | ||||
| if (itemId == null) { | |||||
| failDetailList.add(line.id) | |||||
| failItemDetailList.add(line.proId) | |||||
| logger.error( | |||||
| "${doLineRefType}: Cannot resolve local item for M18 proId=${line.proId}, skipping line ${line.id}" | |||||
| ) | |||||
| val errorSaveM18DeliveryOrderLineLogRequest = SaveM18DataLogRequest( | |||||
| id = saveM18DeliveryOrderLineLog.id, | |||||
| dataLog = mutableMapOf( | |||||
| "Exception Message" to "Cannot resolve local item for M18 proId=${line.proId}" | |||||
| ), | |||||
| statusEnum = M18DataLogStatus.FAIL | |||||
| ) | |||||
| m18DataLogService.saveM18DataLog(errorSaveM18DeliveryOrderLineLogRequest) | |||||
| return@forEach | |||||
| } | |||||
| try { | try { | ||||
| // Find the delivery_order_line if exist | // Find the delivery_order_line if exist | ||||
| // logger.info("${doLineRefType}: Finding exising delivery order line...") | // logger.info("${doLineRefType}: Finding exising delivery order line...") | ||||
| @@ -387,14 +452,27 @@ open class M18DeliveryOrderService( | |||||
| itemUomService.findByM18Id(line.unitId) | itemUomService.findByM18Id(line.unitId) | ||||
| } | } | ||||
| val m18UomId = itemUom?.uom?.id | |||||
| val sourceQty = line.qty | |||||
| val stockQty = | |||||
| if (itemId != null && m18UomId != null && m18UomId == stockUomId) { | |||||
| // M18 line unit is already the stock unit — skip ratio conversion | |||||
| // (avoids bad qty when item_uom ratioN/ratioD hold spec numbers like 350g). | |||||
| sourceQty | |||||
| } else if (itemId != null && m18UomId != null) { | |||||
| itemUomService.convertQtyToStockQty(itemId, m18UomId, sourceQty) | |||||
| } else { | |||||
| sourceQty | |||||
| } | |||||
| val saveDeliveryOrderLineRequest = SaveDeliveryOrderLineRequest( | val saveDeliveryOrderLineRequest = SaveDeliveryOrderLineRequest( | ||||
| id = existingDeliveryOrderLine?.id, | id = existingDeliveryOrderLine?.id, | ||||
| itemId = itemId, | itemId = itemId, | ||||
| uomIdM18 = itemUom?.uom?.id, | |||||
| uomIdM18 = m18UomId, | |||||
| uomId= stockUomId, | uomId= stockUomId, | ||||
| deliveryOrderId = deliveryOrderId, | deliveryOrderId = deliveryOrderId, | ||||
| qtyM18 = line.qty, | |||||
| qty = itemUomService.convertQtyToStockQty(itemId?:0, itemUom?.uom?.id?: 0, line.qty), | |||||
| qtyM18 = sourceQty, | |||||
| qty = stockQty, | |||||
| up = line.up, | up = line.up, | ||||
| price = line.amt, | price = line.amt, | ||||
| // m18CurrencyId = mainpo.curId, | // m18CurrencyId = mainpo.curId, | ||||
| @@ -421,7 +499,7 @@ open class M18DeliveryOrderService( | |||||
| successDetailList.add(line.id) | successDetailList.add(line.id) | ||||
| // logger.info("${doLineRefType}: Delivery order ID: ${deliveryOrderId} | M18 ID: ${deliveryOrder.id}") | // logger.info("${doLineRefType}: Delivery order ID: ${deliveryOrderId} | M18 ID: ${deliveryOrder.id}") | ||||
| //logger.info("${doLineRefType}: Saved delivery order line. ID: ${saveDeliveryOrderLineResponse.id} | M18 Line ID: ${line.id} | Delivery order ID: ${deliveryOrderId} | M18 ID: ${deliveryOrder.id}") | //logger.info("${doLineRefType}: Saved delivery order line. ID: ${saveDeliveryOrderLineResponse.id} | M18 Line ID: ${line.id} | Delivery order ID: ${deliveryOrderId} | M18 ID: ${deliveryOrder.id}") | ||||
| } catch (e: SQLException) { | |||||
| } catch (e: Exception) { | |||||
| failDetailList.add(line.id) | failDetailList.add(line.id) | ||||
| failItemDetailList.add(line.proId) | failItemDetailList.add(line.proId) | ||||
| // logger.error("${doLineRefType}: Saving Failure!") | // logger.error("${doLineRefType}: Saving Failure!") | ||||
| @@ -528,6 +606,9 @@ open class M18DeliveryOrderService( | |||||
| // End of save. Check result | // End of save. Check result | ||||
| logger.info("Total Success (${doRefType}) (${successList.size})") | logger.info("Total Success (${doRefType}) (${successList.size})") | ||||
| logger.error("Total Fail (${doRefType}) (${failList.size}): $failList") | logger.error("Total Fail (${doRefType}) (${failList.size}): $failList") | ||||
| if (skippedList.isNotEmpty()) { | |||||
| logger.info("Total Skipped (${doRefType}) (${skippedList.size}): $skippedList") | |||||
| } | |||||
| logger.info("Total Success (${doLineRefType}) (${successDetailList.size})") | logger.info("Total Success (${doLineRefType}) (${successDetailList.size})") | ||||
| logger.error("Total Fail (${doLineRefType}) (${failDetailList.size}): $failDetailList") | logger.error("Total Fail (${doLineRefType}) (${failDetailList.size}): $failDetailList") | ||||
| @@ -540,11 +621,12 @@ open class M18DeliveryOrderService( | |||||
| logger.info("--------------------------------------------End - Saving M18 Delivery Order--------------------------------------------") | logger.info("--------------------------------------------End - Saving M18 Delivery Order--------------------------------------------") | ||||
| val skippedSuffix = if (skippedList.isNotEmpty()) " | skipped=${skippedList.size}" else "" | |||||
| return SyncResult( | return SyncResult( | ||||
| totalProcessed = successList.size + failList.size, | |||||
| totalProcessed = successList.size + failList.size + skippedList.size, | |||||
| totalSuccess = successList.size, | totalSuccess = successList.size, | ||||
| totalFail = failList.size, | totalFail = failList.size, | ||||
| query = deliveryOrdersWithType?.query ?: "" | |||||
| query = (deliveryOrdersWithType?.query ?: "") + skippedSuffix, | |||||
| ) | ) | ||||
| } | } | ||||
| } | } | ||||
| @@ -188,6 +188,13 @@ open class M18MasterDataService( | |||||
| ) | ) | ||||
| } | } | ||||
| /** Resolve local items.id for an M18 product id; sync from M18 when missing. */ | |||||
| open fun resolveLocalItemId(m18ItemId: Long): Long? { | |||||
| itemsService.findByM18Id(m18ItemId)?.id?.let { return it } | |||||
| saveProduct(m18ItemId)?.id?.let { return it } | |||||
| return itemsService.findByM18Id(m18ItemId)?.id | |||||
| } | |||||
| open fun saveProduct(id: Long): MessageResponse? { | open fun saveProduct(id: Long): MessageResponse? { | ||||
| try { | try { | ||||
| ensureCunitSeededForAllIfEmpty() | ensureCunitSeededForAllIfEmpty() | ||||
| @@ -231,9 +238,18 @@ open class M18MasterDataService( | |||||
| ) | ) | ||||
| val savedItem = itemsService.saveItem(saveItemRequest) | val savedItem = itemsService.saveItem(saveItemRequest) | ||||
| val localItemId = savedItem.id | |||||
| if (localItemId == null) { | |||||
| logger.error("saveItem returned null id for M18 item $id (code=${pro.code}): ${savedItem.message}") | |||||
| return null | |||||
| } | |||||
| if (savedItem.errorPosition == "code") { | |||||
| logger.error("saveItem duplicate code for M18 item $id (code=${pro.code}): ${savedItem.message}") | |||||
| return null | |||||
| } | |||||
| logger.info("Processing item uom...") | logger.info("Processing item uom...") | ||||
| // Find the item uom that ready to delete (not in m18) | // Find the item uom that ready to delete (not in m18) | ||||
| val existingItemUoms = savedItem.id?.let { itemUomService.findAllByItemsId(it) } | |||||
| val existingItemUoms = itemUomService.findAllByItemsId(localItemId) | |||||
| val m18ItemUomIds = price?.map { it.id } ?: listOf() | val m18ItemUomIds = price?.map { it.id } ?: listOf() | ||||
| // Delete the item uom | // Delete the item uom | ||||
| @@ -267,7 +283,7 @@ open class M18MasterDataService( | |||||
| ) | ) | ||||
| val itemUomRequest = ItemUomRequest( | val itemUomRequest = ItemUomRequest( | ||||
| m18UomId = it.unitId, | m18UomId = it.unitId, | ||||
| itemId = savedItem.id, | |||||
| itemId = localItemId, | |||||
| baseUnit = it.basicUnit, | baseUnit = it.basicUnit, | ||||
| stockUnit = it.stkUnit, | stockUnit = it.stkUnit, | ||||
| pickingUnit = it.pickUnit, | pickingUnit = it.pickUnit, | ||||
| @@ -284,12 +300,11 @@ open class M18MasterDataService( | |||||
| deleted = it.expired || endInstant.isBefore(now) | deleted = it.expired || endInstant.isBefore(now) | ||||
| ) | ) | ||||
| // logger.info("saved item id: ${savedItem.id}") | |||||
| itemUomService.saveItemUom(itemUomRequest) | itemUomService.saveItemUom(itemUomRequest) | ||||
| } | } | ||||
| logger.info("Success (M18 Item): ${id} | ${pro.code} | ${pro.desc}") | logger.info("Success (M18 Item): ${id} | ${pro.code} | ${pro.desc}") | ||||
| return savedItem | |||||
| return savedItem.copy(id = localItemId) | |||||
| } else { | } else { | ||||
| logger.error("Fail Message: ${itemDetail?.messages?.get(0)?.msgDetail}") | logger.error("Fail Message: ${itemDetail?.messages?.get(0)?.msgDetail}") | ||||
| logger.error("Fail: Item ID - ${id} Not Found") | logger.error("Fail: Item ID - ${id} Not Found") | ||||
| @@ -404,11 +419,20 @@ open class M18MasterDataService( | |||||
| ) | ) | ||||
| val savedItem = itemsService.saveItem(saveItemRequest) | val savedItem = itemsService.saveItem(saveItemRequest) | ||||
| val localItemId = savedItem.id | |||||
| if (localItemId == null) { | |||||
| failList.add(item.id) | |||||
| logger.error("saveItem returned null id for M18 item ${item.id} (code=${pro.code}): ${savedItem.message}") | |||||
| return@forEach | |||||
| } | |||||
| if (savedItem.errorPosition == "code") { | |||||
| failList.add(item.id) | |||||
| logger.error("saveItem duplicate code for M18 item ${item.id} (code=${pro.code}): ${savedItem.message}") | |||||
| return@forEach | |||||
| } | |||||
| logger.info("Processing item uom...") | logger.info("Processing item uom...") | ||||
| // Optional: cache findAllByItemsId if you think it might be called multiple times | |||||
| // (usually not needed here because each savedItem.id is unique) | |||||
| val existingItemUoms = savedItem.id?.let { itemUomService.findAllByItemsId(it) } | |||||
| val existingItemUoms = itemUomService.findAllByItemsId(localItemId) | |||||
| val m18ItemUomIds = price?.map { it.id } ?: listOf() | val m18ItemUomIds = price?.map { it.id } ?: listOf() | ||||
| @@ -442,7 +466,7 @@ open class M18MasterDataService( | |||||
| val itemUomRequest = ItemUomRequest( | val itemUomRequest = ItemUomRequest( | ||||
| m18UomId = it.unitId, | m18UomId = it.unitId, | ||||
| itemId = savedItem.id, | |||||
| itemId = localItemId, | |||||
| baseUnit = it.basicUnit, | baseUnit = it.basicUnit, | ||||
| stockUnit = it.stkUnit, | stockUnit = it.stkUnit, | ||||
| pickingUnit = it.pickUnit, | pickingUnit = it.pickUnit, | ||||
| @@ -315,6 +315,19 @@ open class M18PurchaseOrderService( | |||||
| val latestPurchaseOrderLog = | val latestPurchaseOrderLog = | ||||
| m18DataLogService.findLatestM18DataLogWithSuccess(m18PurchaseOrderId, poRefType) | m18DataLogService.findLatestM18DataLogWithSuccess(m18PurchaseOrderId, poRefType) | ||||
| val existingPurchaseOrderForSync = | |||||
| latestPurchaseOrderLog?.id?.let { purchaseOrderService.findByM18DataLogId(it) } | |||||
| if (existingPurchaseOrderForSync != null && | |||||
| existingPurchaseOrderForSync.status != PurchaseOrderStatus.PENDING | |||||
| ) { | |||||
| logger.info( | |||||
| "${poRefType}: Skipping M18 sync — local PO id=${existingPurchaseOrderForSync.id} " + | |||||
| "code=${existingPurchaseOrderForSync.code} status=${existingPurchaseOrderForSync.status?.value} " + | |||||
| "(only pending may be overwritten). M18 ID: $m18PurchaseOrderId" | |||||
| ) | |||||
| return@forEach | |||||
| } | |||||
| // logger.info(latestPurchaseOrderLog.toString()) | // logger.info(latestPurchaseOrderLog.toString()) | ||||
| // Save to m18_data_log table | // Save to m18_data_log table | ||||
| // logger.info("${poRefType}: Saving for M18 Data Log...") | // logger.info("${poRefType}: Saving for M18 Data Log...") | ||||
| @@ -336,10 +349,9 @@ open class M18PurchaseOrderService( | |||||
| // logger.info("${poRefType}: Saved M18 Data Log. ID: ${saveM18PurchaseOrderLog.id}") | // logger.info("${poRefType}: Saved M18 Data Log. ID: ${saveM18PurchaseOrderLog.id}") | ||||
| try { | try { | ||||
| // Find the purchase_order if exist | |||||
| // Find the purchase_order if exist (re-use lookup from pending guard above) | |||||
| // logger.info("${poRefType}: Finding exising purchase order...") | // logger.info("${poRefType}: Finding exising purchase order...") | ||||
| val existingPurchaseOrder = | |||||
| latestPurchaseOrderLog?.id?.let { purchaseOrderService.findByM18DataLogId(it) } | |||||
| val existingPurchaseOrder = existingPurchaseOrderForSync | |||||
| // logger.info("${poRefType}: Exising purchase order ID: ${existingPurchaseOrder?.id}") | // logger.info("${poRefType}: Exising purchase order ID: ${existingPurchaseOrder?.id}") | ||||
| // Save to purchase_order table | // Save to purchase_order table | ||||
| @@ -427,14 +439,25 @@ open class M18PurchaseOrderService( | |||||
| // logger.info("${poLineRefType}: Saved M18 Data Log. ID: ${saveM18PurchaseOrderLineLog.id}") | // logger.info("${poLineRefType}: Saved M18 Data Log. ID: ${saveM18PurchaseOrderLineLog.id}") | ||||
| // logger.info("${poLineRefType}: Finding item...") | // logger.info("${poLineRefType}: Finding item...") | ||||
| val item = itemsService.findByM18Id(line.proId) | |||||
| val itemId: Long? = if (item == null) { | |||||
| m18MasterDataService.saveProduct(line.proId)?.id | |||||
| } else { | |||||
| item.id | |||||
| } | |||||
| val itemId: Long? = m18MasterDataService.resolveLocalItemId(line.proId) | |||||
| logger.info("${poLineRefType}: Item ID: ${itemId} | M18 Item ID: ${line.proId}") | logger.info("${poLineRefType}: Item ID: ${itemId} | M18 Item ID: ${line.proId}") | ||||
| if (itemId == null) { | |||||
| failDetailList.add(line.id) | |||||
| logger.error( | |||||
| "${poLineRefType}: PO ${mainpo.code}: Cannot resolve local item for M18 proId=${line.proId}, skipping line ${line.id}" | |||||
| ) | |||||
| val errorSaveM18PurchaseOrderLineLogRequest = SaveM18DataLogRequest( | |||||
| id = saveM18PurchaseOrderLineLog.id, | |||||
| dataLog = mutableMapOf( | |||||
| "Exception Message" to "PO ${mainpo.code} Cannot resolve local item for M18 proId=${line.proId}" | |||||
| ), | |||||
| statusEnum = M18DataLogStatus.FAIL | |||||
| ) | |||||
| m18DataLogService.saveM18DataLog(errorSaveM18PurchaseOrderLineLogRequest) | |||||
| return@forEach | |||||
| } | |||||
| try { | try { | ||||
| // Find the purchase_order_line if exist (stable key: PO + M18 line id) | // Find the purchase_order_line if exist (stable key: PO + M18 line id) | ||||
| // logger.info("${poLineRefType}: Finding exising purchase order line...") | // logger.info("${poLineRefType}: Finding exising purchase order line...") | ||||
| @@ -0,0 +1,43 @@ | |||||
| package com.ffii.fpsms.m18.service | |||||
| import com.ffii.fpsms.api.service.ApiCallerService | |||||
| import com.ffii.fpsms.m18.model.M18CommonListRequest | |||||
| import com.ffii.fpsms.m18.model.M18VendorListResponse | |||||
| import com.ffii.fpsms.m18.model.StSearchType | |||||
| import org.slf4j.Logger | |||||
| import org.slf4j.LoggerFactory | |||||
| import org.springframework.stereotype.Service | |||||
| /** | |||||
| * Lightweight M18 vendor search — kept separate from [M18MasterDataService] to avoid a Spring cycle | |||||
| * ([M18BomForShopService] → [M18MasterDataService] → [com.ffii.fpsms.modules.master.service.BomService] → [M18BomForShopService]). | |||||
| */ | |||||
| @Service | |||||
| open class M18VendorLookupService( | |||||
| private val apiCallerService: ApiCallerService, | |||||
| ) { | |||||
| private val logger: Logger = LoggerFactory.getLogger(M18VendorLookupService::class.java) | |||||
| private val fetchListApi = "/search/search" | |||||
| /** M18 vendor id for [code] scoped to [beId] (e.g. PF vs PP business entity). */ | |||||
| open fun findVendorM18IdByCode(code: String, beId: String): Long? { | |||||
| val trimmed = code.trim() | |||||
| if (trimmed.isEmpty() || beId.isBlank()) return null | |||||
| val conds = "(code=equal=$trimmed)=and=(beId=equal=$beId)" | |||||
| val listResponse = try { | |||||
| apiCallerService.get<M18VendorListResponse, M18CommonListRequest>( | |||||
| fetchListApi, | |||||
| M18CommonListRequest( | |||||
| stSearch = StSearchType.VENDOR.value, | |||||
| params = null, | |||||
| conds = conds, | |||||
| ), | |||||
| ).block() | |||||
| } catch (e: Exception) { | |||||
| logger.warn("(findVendorM18IdByCode) M18 search failed code=$trimmed beId=$beId: ${e.message}") | |||||
| null | |||||
| } | |||||
| return listResponse?.values?.firstOrNull()?.id?.takeIf { it > 0L } | |||||
| } | |||||
| } | |||||
| @@ -4,8 +4,9 @@ import com.ffii.core.utils.JwtTokenUtil | |||||
| import com.ffii.fpsms.m18.M18Config | import com.ffii.fpsms.m18.M18Config | ||||
| import com.ffii.fpsms.m18.model.SyncResult | import com.ffii.fpsms.m18.model.SyncResult | ||||
| import com.ffii.fpsms.m18.service.* | import com.ffii.fpsms.m18.service.* | ||||
| import com.ffii.fpsms.m18.model.M18BomShopSyncTriggerResult | |||||
| import com.ffii.fpsms.m18.web.models.M18CommonRequest | import com.ffii.fpsms.m18.web.models.M18CommonRequest | ||||
| import com.ffii.fpsms.modules.common.SettingNames | |||||
| import com.ffii.fpsms.modules.master.service.BomService | |||||
| import com.ffii.fpsms.modules.common.scheduler.service.SchedulerService | import com.ffii.fpsms.modules.common.scheduler.service.SchedulerService | ||||
| import com.ffii.fpsms.modules.master.entity.ItemUom | import com.ffii.fpsms.modules.master.entity.ItemUom | ||||
| import com.ffii.fpsms.modules.master.entity.Items | import com.ffii.fpsms.modules.master.entity.Items | ||||
| @@ -35,6 +36,7 @@ class M18TestController ( | |||||
| private val m18DeliveryOrderService: M18DeliveryOrderService, | private val m18DeliveryOrderService: M18DeliveryOrderService, | ||||
| val schedulerService: SchedulerService, | val schedulerService: SchedulerService, | ||||
| private val settingsService: SettingsService, | private val settingsService: SettingsService, | ||||
| private val bomService: BomService, | |||||
| ) { | ) { | ||||
| var logger: Logger = LoggerFactory.getLogger(JwtTokenUtil::class.java) | var logger: Logger = LoggerFactory.getLogger(JwtTokenUtil::class.java) | ||||
| @@ -65,6 +67,14 @@ class M18TestController ( | |||||
| return schedulerService.getM18Pos(); | return schedulerService.getM18Pos(); | ||||
| } | } | ||||
| @PostMapping("/test/bom-shop-sync/{bomId}") | |||||
| fun testBomShopSync( | |||||
| @PathVariable bomId: Long, | |||||
| @RequestParam(required = false) m18HeaderId: Long?, | |||||
| ): M18BomShopSyncTriggerResult { | |||||
| return bomService.pushBomToM18ShopIfAllowed(bomId, m18HeaderId) | |||||
| } | |||||
| @GetMapping("/test/po-by-code") | @GetMapping("/test/po-by-code") | ||||
| fun testSyncPoByCode(@RequestParam code: String): SyncResult { | fun testSyncPoByCode(@RequestParam code: String): SyncResult { | ||||
| return m18PurchaseOrderService.savePurchaseOrderByCode(code) | return m18PurchaseOrderService.savePurchaseOrderByCode(code) | ||||
| @@ -72,7 +82,14 @@ class M18TestController ( | |||||
| @GetMapping("/test/do-by-code") | @GetMapping("/test/do-by-code") | ||||
| fun testSyncDoByCode(@RequestParam code: String): SyncResult { | fun testSyncDoByCode(@RequestParam code: String): SyncResult { | ||||
| return m18DeliveryOrderService.saveDeliveryOrderByCode(code) | |||||
| return m18DeliveryOrderService.saveDeliveryOrderByCode(code, isExtraSync = false) | |||||
| } | |||||
| /** DO(加單):手動按 code 同步,並寫入本地 [DeliveryOrder.isExtra]=true(不做 M18 端加單條件過濾) */ | |||||
| @GetMapping("/test/do-by-code-extra") | |||||
| fun testSyncDoByCodeExtra(@RequestParam code: String): SyncResult { | |||||
| // 加單 tab: only sync when it's a NEW order (not existing in local system) | |||||
| return m18DeliveryOrderService.saveDeliveryOrderByCode(code, isExtraSync = true, newOnly = true) | |||||
| } | } | ||||
| @GetMapping("/test/product-by-code") | @GetMapping("/test/product-by-code") | ||||
| @@ -29,7 +29,7 @@ open class BagService( | |||||
| ) { | ) { | ||||
| open fun createBagLotLinesByBagId(request: CreateBagLotLineRequest): MessageResponse { | open fun createBagLotLinesByBagId(request: CreateBagLotLineRequest): MessageResponse { | ||||
| val bag = bagRepository.findById(request.bagId).orElse(null) | val bag = bagRepository.findById(request.bagId).orElse(null) | ||||
| val lot = inventoryLotRepository.findByLotNoAndItemId(request.lotNo, request.itemId) | |||||
| val lot = inventoryLotRepository.findByIdAndDeletedFalse(request.lotId) | |||||
| val BaseUnitOfMeasure= itemUomRepository.findByItemIdAndStockUnitIsTrueAndDeletedIsFalse(request.itemId) | val BaseUnitOfMeasure= itemUomRepository.findByItemIdAndStockUnitIsTrueAndDeletedIsFalse(request.itemId) | ||||
| val baseRatioN = BaseUnitOfMeasure?.ratioN ?: BigDecimal.ONE | val baseRatioN = BaseUnitOfMeasure?.ratioN ?: BigDecimal.ONE | ||||
| println("baseRatioN: $baseRatioN") | println("baseRatioN: $baseRatioN") | ||||
| @@ -1,38 +1,21 @@ | |||||
| package com.ffii.fpsms.modules.bag.web | package com.ffii.fpsms.modules.bag.web | ||||
| import com.ffii.core.response.RecordsRes | |||||
| import com.ffii.fpsms.modules.bag.service.BagService | import com.ffii.fpsms.modules.bag.service.BagService | ||||
| import jakarta.validation.Valid | |||||
| import com.ffii.fpsms.modules.bag.web.model.BagConsumptionResponse | |||||
| import com.ffii.fpsms.modules.bag.web.model.BagInfo | |||||
| import com.ffii.fpsms.modules.bag.web.model.BagLotLineResponse | |||||
| import com.ffii.fpsms.modules.bag.web.model.BagSummaryResponse | |||||
| import com.ffii.fpsms.modules.bag.web.model.BagUsageRecordResponse | |||||
| import com.ffii.fpsms.modules.bag.web.model.CreateJoBagConsumptionRequest | |||||
| import com.ffii.fpsms.modules.master.web.models.MessageResponse | |||||
| import org.springframework.web.bind.annotation.GetMapping | import org.springframework.web.bind.annotation.GetMapping | ||||
| import org.springframework.web.bind.annotation.ModelAttribute | |||||
| import org.springframework.web.bind.annotation.PathVariable | import org.springframework.web.bind.annotation.PathVariable | ||||
| import org.springframework.web.bind.annotation.PostMapping | import org.springframework.web.bind.annotation.PostMapping | ||||
| import org.springframework.web.bind.annotation.PutMapping | |||||
| import org.springframework.web.bind.annotation.RequestBody | import org.springframework.web.bind.annotation.RequestBody | ||||
| import org.springframework.web.bind.annotation.RequestMapping | import org.springframework.web.bind.annotation.RequestMapping | ||||
| import org.springframework.web.bind.annotation.RestController | import org.springframework.web.bind.annotation.RestController | ||||
| import com.ffii.fpsms.modules.jobOrder.service.JoPickOrderService | |||||
| import com.ffii.fpsms.modules.productProcess.service.ProductProcessService | |||||
| import com.ffii.fpsms.modules.jobOrder.web.model.* | |||||
| import com.ffii.fpsms.modules.jobOrder.web.model.ExportPickRecordRequest | |||||
| import com.ffii.fpsms.modules.jobOrder.web.model.PrintPickRecordRequest | |||||
| import com.ffii.fpsms.modules.jobOrder.web.model.SecondScanSubmitRequest | |||||
| import com.ffii.fpsms.modules.jobOrder.web.model.SecondScanIssueRequest | |||||
| import jakarta.servlet.http.HttpServletResponse | |||||
| import net.sf.jasperreports.engine.JasperExportManager | |||||
| import net.sf.jasperreports.engine.JasperPrint | |||||
| import org.aspectj.weaver.tools.UnsupportedPointcutPrimitiveException | |||||
| import org.springframework.context.NoSuchMessageException | |||||
| import java.io.OutputStream | |||||
| import java.io.UnsupportedEncodingException | |||||
| import java.text.ParseException | |||||
| import org.springframework.web.bind.annotation.* | |||||
| import org.springframework.web.bind.annotation.RequestParam | |||||
| import com.ffii.fpsms.modules.jobOrder.web.model.UpdateJoPickOrderHandledByRequest | |||||
| import com.ffii.fpsms.modules.jobOrder.entity.projections.JobOrderInfo | |||||
| import com.ffii.fpsms.modules.jobOrder.entity.projections.JobOrderInfoWithTypeName | |||||
| import com.ffii.fpsms.modules.jobOrder.web.model.ExportFGStockInLabelRequest | |||||
| import com.ffii.fpsms.modules.bag.web.model.* | |||||
| import com.ffii.fpsms.modules.master.web.models.MessageResponse | |||||
| @RestController | @RestController | ||||
| @RequestMapping("/bag") | @RequestMapping("/bag") | ||||
| class BagController( | class BagController( | ||||
| @@ -43,14 +26,17 @@ class BagController( | |||||
| fun getBagInfo(): List<BagInfo> { | fun getBagInfo(): List<BagInfo> { | ||||
| return bagService.getAllBagInfo() | return bagService.getAllBagInfo() | ||||
| } | } | ||||
| @PostMapping("/createJoBagConsumption") | @PostMapping("/createJoBagConsumption") | ||||
| fun createJoBagConsumption(@RequestBody request: CreateJoBagConsumptionRequest): MessageResponse { | fun createJoBagConsumption(@RequestBody request: CreateJoBagConsumptionRequest): MessageResponse { | ||||
| return bagService.createJoBagConsumption(request) | return bagService.createJoBagConsumption(request) | ||||
| } | } | ||||
| @GetMapping("/bagUsageRecords") | @GetMapping("/bagUsageRecords") | ||||
| fun getBagUsageRecords(): List<BagUsageRecordResponse> { | fun getBagUsageRecords(): List<BagUsageRecordResponse> { | ||||
| return bagService.getAllBagUsageRecords() | return bagService.getAllBagUsageRecords() | ||||
| } | } | ||||
| @GetMapping("/bags") | @GetMapping("/bags") | ||||
| fun getBags(): List<BagSummaryResponse> = | fun getBags(): List<BagSummaryResponse> = | ||||
| bagService.getBagSummaries() | bagService.getBagSummaries() | ||||
| @@ -66,4 +52,4 @@ class BagController( | |||||
| @PutMapping("/by-item/{itemId}/soft-delete") | @PutMapping("/by-item/{itemId}/soft-delete") | ||||
| fun softDeleteBagByItemId(@PathVariable itemId: Long): MessageResponse = | fun softDeleteBagByItemId(@PathVariable itemId: Long): MessageResponse = | ||||
| bagService.softDeleteBagByItemId(itemId) | bagService.softDeleteBagByItemId(itemId) | ||||
| } | |||||
| } | |||||
| @@ -3,6 +3,7 @@ package com.ffii.fpsms.modules.chart.service | |||||
| import com.ffii.core.support.JdbcDao | import com.ffii.core.support.JdbcDao | ||||
| import org.springframework.stereotype.Service | import org.springframework.stereotype.Service | ||||
| import java.time.LocalDate | import java.time.LocalDate | ||||
| import java.time.LocalDateTime | |||||
| @Service | @Service | ||||
| open class ChartService( | open class ChartService( | ||||
| @@ -15,52 +16,40 @@ open class ChartService( | |||||
| */ | */ | ||||
| fun getStockTransactionsByDate(startDate: LocalDate?, endDate: LocalDate?): List<Map<String, Any>> { | fun getStockTransactionsByDate(startDate: LocalDate?, endDate: LocalDate?): List<Map<String, Any>> { | ||||
| val args = mutableMapOf<String, Any>() | val args = mutableMapOf<String, Any>() | ||||
| val startSql = if (startDate != null) { | |||||
| args["startDate"] = startDate.toString() | |||||
| "AND DATE(sl.date) >= :startDate" | |||||
| } else "" | |||||
| val endSql = if (endDate != null) { | |||||
| args["endDate"] = endDate.toString() | |||||
| "AND DATE(sl.date) <= :endDate" | |||||
| } else "" | |||||
| val rangeSql = ledgerDateTimeRangeSql(args, "sl.date", startDate, endDate) | |||||
| val sql = """ | val sql = """ | ||||
| SELECT | SELECT | ||||
| DATE_FORMAT(sl.date, '%Y-%m-%d') AS date, | DATE_FORMAT(sl.date, '%Y-%m-%d') AS date, | ||||
| COALESCE(SUM(sl.inQty), 0) AS inQty, | COALESCE(SUM(sl.inQty), 0) AS inQty, | ||||
| COALESCE(SUM(sl.outQty), 0) AS outQty, | COALESCE(SUM(sl.outQty), 0) AS outQty, | ||||
| COALESCE(SUM(COALESCE(sl.inQty, 0) + COALESCE(sl.outQty, 0)), 0) AS totalQty | COALESCE(SUM(COALESCE(sl.inQty, 0) + COALESCE(sl.outQty, 0)), 0) AS totalQty | ||||
| FROM stock_ledger sl | |||||
| FROM stock_ledger sl FORCE INDEX (idx_sl_deleted_date) | |||||
| WHERE sl.deleted = 0 AND sl.date IS NOT NULL | WHERE sl.deleted = 0 AND sl.date IS NOT NULL | ||||
| $startSql $endSql | |||||
| GROUP BY sl.date | |||||
| ORDER BY sl.date | |||||
| $rangeSql | |||||
| GROUP BY DATE_FORMAT(sl.date, '%Y-%m-%d') | |||||
| ORDER BY date | |||||
| """.trimIndent() | """.trimIndent() | ||||
| return jdbcDao.queryForList(sql, args) | return jdbcDao.queryForList(sql, args) | ||||
| } | } | ||||
| /** | /** | ||||
| * Delivery orders: order count and total line qty by date. | * Delivery orders: order count and total line qty by date. | ||||
| * Uses delivery_order.completeDate or estimatedArrivalDate for date. | |||||
| * X-axis date: [delivery_order.estimatedArrivalDate] only (no completeDate/orderDate fallback). | |||||
| * Rows without estimatedArrivalDate are excluded. | |||||
| */ | */ | ||||
| fun getDeliveryOrderByDate(startDate: LocalDate?, endDate: LocalDate?): List<Map<String, Any>> { | fun getDeliveryOrderByDate(startDate: LocalDate?, endDate: LocalDate?): List<Map<String, Any>> { | ||||
| val args = mutableMapOf<String, Any>() | val args = mutableMapOf<String, Any>() | ||||
| val startSql = if (startDate != null) { | |||||
| args["startDate"] = startDate.toString() | |||||
| "AND DATE(COALESCE(do.completeDate, do.estimatedArrivalDate, do.orderDate)) >= :startDate" | |||||
| } else "" | |||||
| val endSql = if (endDate != null) { | |||||
| args["endDate"] = endDate.toString() | |||||
| "AND DATE(COALESCE(do.completeDate, do.estimatedArrivalDate, do.orderDate)) <= :endDate" | |||||
| } else "" | |||||
| val rangeSql = localDateRangeSql(args, "do.estimatedArrivalDate", startDate, endDate) | |||||
| val sql = """ | val sql = """ | ||||
| SELECT | SELECT | ||||
| DATE_FORMAT(COALESCE(do.completeDate, do.estimatedArrivalDate, do.orderDate), '%Y-%m-%d') AS date, | |||||
| DATE_FORMAT(do.estimatedArrivalDate, '%Y-%m-%d') AS date, | |||||
| COUNT(DISTINCT do.id) AS orderCount, | COUNT(DISTINCT do.id) AS orderCount, | ||||
| COALESCE(SUM(dol.qty), 0) AS totalQty | COALESCE(SUM(dol.qty), 0) AS totalQty | ||||
| FROM delivery_order do | FROM delivery_order do | ||||
| LEFT JOIN delivery_order_line dol ON dol.deliveryOrderId = do.id AND dol.deleted = 0 | LEFT JOIN delivery_order_line dol ON dol.deliveryOrderId = do.id AND dol.deleted = 0 | ||||
| WHERE do.deleted = 0 $startSql $endSql | |||||
| GROUP BY DATE(COALESCE(do.completeDate, do.estimatedArrivalDate, do.orderDate)) | |||||
| WHERE do.deleted = 0 AND do.estimatedArrivalDate IS NOT NULL | |||||
| $rangeSql | |||||
| GROUP BY DATE_FORMAT(do.estimatedArrivalDate, '%Y-%m-%d') | |||||
| ORDER BY date | ORDER BY date | ||||
| """.trimIndent() | """.trimIndent() | ||||
| return jdbcDao.queryForList(sql, args) | return jdbcDao.queryForList(sql, args) | ||||
| @@ -529,37 +518,45 @@ open class ChartService( | |||||
| * Stock in vs stock out by date. | * Stock in vs stock out by date. | ||||
| * Stock in: stock_in_line.acceptedQty, date from stock_in.completeDate or receiptDate/created. | * Stock in: stock_in_line.acceptedQty, date from stock_in.completeDate or receiptDate/created. | ||||
| * Stock out: stock_out_line.qty, date from stock_out.completeDate or created. | * Stock out: stock_out_line.qty, date from stock_out.completeDate or created. | ||||
| * | |||||
| * Date range is applied inside each UNION branch (predicate pushdown) so we do not aggregate | |||||
| * all history before filtering. Reads filtered headers first via STRAIGHT_JOIN (si/so then lines). | |||||
| */ | */ | ||||
| fun getStockInOutByDate(startDate: LocalDate?, endDate: LocalDate?): List<Map<String, Any>> { | fun getStockInOutByDate(startDate: LocalDate?, endDate: LocalDate?): List<Map<String, Any>> { | ||||
| val args = mutableMapOf<String, Any>() | val args = mutableMapOf<String, Any>() | ||||
| val startSql = if (startDate != null) { | |||||
| args["startDate"] = startDate.toString() | |||||
| "AND u.dt >= :startDate" | |||||
| } else "" | |||||
| val endSql = if (endDate != null) { | |||||
| args["endDate"] = endDate.toString() | |||||
| "AND u.dt <= :endDate" | |||||
| } else "" | |||||
| val rangeStart = startDate?.atStartOfDay() | |||||
| val rangeEndExclusive = endDate?.plusDays(1)?.atStartOfDay() | |||||
| if (rangeStart != null) args["inOutRangeStart"] = rangeStart | |||||
| if (rangeEndExclusive != null) args["inOutRangeEndExclusive"] = rangeEndExclusive | |||||
| val inDateFilter = stockInOutCoalescedDateRangeSql( | |||||
| "COALESCE(si.completeDate, sil.receiptDate, si.created)", | |||||
| rangeStart, | |||||
| rangeEndExclusive, | |||||
| ) | |||||
| val outDateFilter = stockInOutCoalescedDateRangeSql( | |||||
| "COALESCE(so.completeDate, so.created)", | |||||
| rangeStart, | |||||
| rangeEndExclusive, | |||||
| ) | |||||
| val sql = """ | val sql = """ | ||||
| SELECT DATE_FORMAT(u.dt, '%Y-%m-%d') AS date, | |||||
| SELECT u.dt AS date, | |||||
| COALESCE(SUM(u.inQty), 0) AS inQty, | COALESCE(SUM(u.inQty), 0) AS inQty, | ||||
| COALESCE(SUM(u.outQty), 0) AS outQty | COALESCE(SUM(u.outQty), 0) AS outQty | ||||
| FROM ( | FROM ( | ||||
| SELECT DATE(COALESCE(si.completeDate, sil.receiptDate, si.created)) AS dt, | |||||
| SELECT DATE_FORMAT(COALESCE(si.completeDate, sil.receiptDate, si.created), '%Y-%m-%d') AS dt, | |||||
| SUM(COALESCE(sil.acceptedQty, 0)) AS inQty, 0 AS outQty | SUM(COALESCE(sil.acceptedQty, 0)) AS inQty, 0 AS outQty | ||||
| FROM stock_in_line sil | |||||
| INNER JOIN stock_in si ON sil.stockInId = si.id AND si.deleted = 0 | |||||
| WHERE sil.deleted = 0 | |||||
| GROUP BY DATE(COALESCE(si.completeDate, sil.receiptDate, si.created)) | |||||
| FROM stock_in si | |||||
| STRAIGHT_JOIN stock_in_line sil ON sil.stockInId = si.id AND sil.deleted = 0 | |||||
| WHERE si.deleted = 0$inDateFilter | |||||
| GROUP BY DATE_FORMAT(COALESCE(si.completeDate, sil.receiptDate, si.created), '%Y-%m-%d') | |||||
| UNION ALL | UNION ALL | ||||
| SELECT DATE(COALESCE(so.completeDate, so.created)) AS dt, | |||||
| SELECT DATE_FORMAT(COALESCE(so.completeDate, so.created), '%Y-%m-%d') AS dt, | |||||
| 0 AS inQty, SUM(COALESCE(sol.qty, 0)) AS outQty | 0 AS inQty, SUM(COALESCE(sol.qty, 0)) AS outQty | ||||
| FROM stock_out_line sol | |||||
| INNER JOIN stock_out so ON sol.stockOutId = so.id AND so.deleted = 0 | |||||
| WHERE sol.deleted = 0 | |||||
| GROUP BY DATE(COALESCE(so.completeDate, so.created)) | |||||
| FROM stock_out so | |||||
| STRAIGHT_JOIN stock_out_line sol ON sol.stockOutId = so.id AND sol.deleted = 0 | |||||
| WHERE so.deleted = 0$outDateFilter | |||||
| GROUP BY DATE_FORMAT(COALESCE(so.completeDate, so.created), '%Y-%m-%d') | |||||
| ) u | ) u | ||||
| WHERE 1=1 $startSql $endSql | |||||
| GROUP BY u.dt | GROUP BY u.dt | ||||
| ORDER BY u.dt | ORDER BY u.dt | ||||
| """.trimIndent() | """.trimIndent() | ||||
| @@ -568,23 +565,19 @@ open class ChartService( | |||||
| /** | /** | ||||
| * Distinct items that appear in delivery_order_line in the period (for multi-select options). | * Distinct items that appear in delivery_order_line in the period (for multi-select options). | ||||
| * Period filter: [delivery_order.estimatedArrivalDate] only; null ETA excluded. | |||||
| * Uses STRAIGHT_JOIN so MySQL reads filtered `delivery_order` first (avoids full scan on `delivery_order_line`). | |||||
| */ | */ | ||||
| fun getTopDeliveryItemsItemOptions(startDate: LocalDate?, endDate: LocalDate?): List<Map<String, Any>> { | fun getTopDeliveryItemsItemOptions(startDate: LocalDate?, endDate: LocalDate?): List<Map<String, Any>> { | ||||
| val args = mutableMapOf<String, Any>() | val args = mutableMapOf<String, Any>() | ||||
| val startSql = if (startDate != null) { | |||||
| args["startDate"] = startDate.toString() | |||||
| "AND DATE(COALESCE(do.completeDate, do.estimatedArrivalDate, do.orderDate)) >= :startDate" | |||||
| } else "" | |||||
| val endSql = if (endDate != null) { | |||||
| args["endDate"] = endDate.toString() | |||||
| "AND DATE(COALESCE(do.completeDate, do.estimatedArrivalDate, do.orderDate)) <= :endDate" | |||||
| } else "" | |||||
| val rangeSql = localDateRangeSql(args, "do.estimatedArrivalDate", startDate, endDate) | |||||
| val sql = """ | val sql = """ | ||||
| SELECT DISTINCT it.code AS itemCode, COALESCE(it.name, '') AS itemName | SELECT DISTINCT it.code AS itemCode, COALESCE(it.name, '') AS itemName | ||||
| FROM delivery_order_line dol | |||||
| INNER JOIN delivery_order do ON dol.deliveryOrderId = do.id AND do.deleted = 0 | |||||
| INNER JOIN items it ON dol.itemId = it.id AND it.deleted = 0 | |||||
| WHERE dol.deleted = 0 $startSql $endSql | |||||
| FROM delivery_order do | |||||
| STRAIGHT_JOIN delivery_order_line dol ON dol.deliveryOrderId = do.id AND dol.deleted = 0 | |||||
| STRAIGHT_JOIN items it ON it.id = dol.itemId AND it.deleted = 0 | |||||
| WHERE do.deleted = 0 AND do.estimatedArrivalDate IS NOT NULL | |||||
| $rangeSql | |||||
| ORDER BY it.code | ORDER BY it.code | ||||
| """.trimIndent() | """.trimIndent() | ||||
| return jdbcDao.queryForList(sql, args) | return jdbcDao.queryForList(sql, args) | ||||
| @@ -592,6 +585,8 @@ open class ChartService( | |||||
| /** | /** | ||||
| * Top delivery items by total qty in the period. When itemCodes is non-empty, only those items (still ordered by totalQty, limit applied). | * Top delivery items by total qty in the period. When itemCodes is non-empty, only those items (still ordered by totalQty, limit applied). | ||||
| * Period filter: [delivery_order.estimatedArrivalDate] only; null ETA excluded. | |||||
| * Uses STRAIGHT_JOIN so MySQL reads filtered `delivery_order` first (avoids full scan on `delivery_order_line`). | |||||
| */ | */ | ||||
| fun getTopDeliveryItems( | fun getTopDeliveryItems( | ||||
| startDate: LocalDate?, | startDate: LocalDate?, | ||||
| @@ -600,14 +595,7 @@ open class ChartService( | |||||
| itemCodes: List<String>? | itemCodes: List<String>? | ||||
| ): List<Map<String, Any>> { | ): List<Map<String, Any>> { | ||||
| val args = mutableMapOf<String, Any>("limit" to limit) | val args = mutableMapOf<String, Any>("limit" to limit) | ||||
| val startSql = if (startDate != null) { | |||||
| args["startDate"] = startDate.toString() | |||||
| "AND DATE(COALESCE(do.completeDate, do.estimatedArrivalDate, do.orderDate)) >= :startDate" | |||||
| } else "" | |||||
| val endSql = if (endDate != null) { | |||||
| args["endDate"] = endDate.toString() | |||||
| "AND DATE(COALESCE(do.completeDate, do.estimatedArrivalDate, do.orderDate)) <= :endDate" | |||||
| } else "" | |||||
| val rangeSql = localDateRangeSql(args, "do.estimatedArrivalDate", startDate, endDate) | |||||
| val itemSql = if (!itemCodes.isNullOrEmpty()) { | val itemSql = if (!itemCodes.isNullOrEmpty()) { | ||||
| val codes = itemCodes.map { it.trim() }.filter { it.isNotBlank() } | val codes = itemCodes.map { it.trim() }.filter { it.isNotBlank() } | ||||
| if (codes.isEmpty()) "" else { | if (codes.isEmpty()) "" else { | ||||
| @@ -620,10 +608,11 @@ open class ChartService( | |||||
| it.code AS itemCode, | it.code AS itemCode, | ||||
| it.name AS itemName, | it.name AS itemName, | ||||
| SUM(COALESCE(dol.qty, 0)) AS totalQty | SUM(COALESCE(dol.qty, 0)) AS totalQty | ||||
| FROM delivery_order_line dol | |||||
| INNER JOIN delivery_order do ON dol.deliveryOrderId = do.id AND do.deleted = 0 | |||||
| INNER JOIN items it ON dol.itemId = it.id AND it.deleted = 0 | |||||
| WHERE dol.deleted = 0 $startSql $endSql $itemSql | |||||
| FROM delivery_order do | |||||
| STRAIGHT_JOIN delivery_order_line dol ON dol.deliveryOrderId = do.id AND dol.deleted = 0 | |||||
| STRAIGHT_JOIN items it ON it.id = dol.itemId AND it.deleted = 0 | |||||
| WHERE do.deleted = 0 AND do.estimatedArrivalDate IS NOT NULL | |||||
| $rangeSql $itemSql | |||||
| GROUP BY dol.itemId, it.code, it.name | GROUP BY dol.itemId, it.code, it.name | ||||
| ORDER BY totalQty DESC | ORDER BY totalQty DESC | ||||
| LIMIT :limit | LIMIT :limit | ||||
| @@ -641,26 +630,26 @@ open class ChartService( | |||||
| itemCode: String? | itemCode: String? | ||||
| ): List<Map<String, Any>> { | ): List<Map<String, Any>> { | ||||
| val args = mutableMapOf<String, Any>() | val args = mutableMapOf<String, Any>() | ||||
| val startSql = if (startDate != null) { | |||||
| args["startDate"] = startDate.toString() | |||||
| "AND sl.date >= :startDate" | |||||
| } else "" | |||||
| val endSql = if (endDate != null) { | |||||
| args["endDate"] = endDate.toString() | |||||
| "AND sl.date <= :endDate" | |||||
| } else "" | |||||
| val itemSql = if (!itemCode.isNullOrBlank()) { | |||||
| val rangeSql = ledgerDateTimeRangeSql(args, "sl.date", startDate, endDate) | |||||
| val hasItemFilter = !itemCode.isNullOrBlank() | |||||
| if (hasItemFilter) { | |||||
| args["itemCode"] = "%$itemCode%" | args["itemCode"] = "%$itemCode%" | ||||
| "AND sl.itemCode LIKE :itemCode" | |||||
| } else "" | |||||
| } | |||||
| val itemSql = if (hasItemFilter) "AND sl.itemCode LIKE :itemCode" else "" | |||||
| val fromClause = if (hasItemFilter) { | |||||
| "FROM stock_ledger sl" | |||||
| } else { | |||||
| "FROM stock_ledger sl FORCE INDEX (idx_sl_deleted_date)" | |||||
| } | |||||
| val sql = """ | val sql = """ | ||||
| SELECT | SELECT | ||||
| DATE_FORMAT(sl.date, '%Y-%m-%d') AS date, | DATE_FORMAT(sl.date, '%Y-%m-%d') AS date, | ||||
| COALESCE(SUM(sl.balance), 0) AS balance | COALESCE(SUM(sl.balance), 0) AS balance | ||||
| FROM stock_ledger sl | |||||
| WHERE sl.deleted = 0 AND sl.date IS NOT NULL $startSql $endSql $itemSql | |||||
| GROUP BY sl.date | |||||
| ORDER BY sl.date | |||||
| $fromClause | |||||
| WHERE sl.deleted = 0 AND sl.date IS NOT NULL | |||||
| $rangeSql $itemSql | |||||
| GROUP BY DATE_FORMAT(sl.date, '%Y-%m-%d') | |||||
| ORDER BY date | |||||
| """.trimIndent() | """.trimIndent() | ||||
| return jdbcDao.queryForList(sql, args) | return jdbcDao.queryForList(sql, args) | ||||
| } | } | ||||
| @@ -677,27 +666,35 @@ open class ChartService( | |||||
| ): List<Map<String, Any>> { | ): List<Map<String, Any>> { | ||||
| val args = mutableMapOf<String, Any>() | val args = mutableMapOf<String, Any>() | ||||
| val yearSql = if (year != null) { | val yearSql = if (year != null) { | ||||
| args["year"] = year | |||||
| "AND YEAR(sl.date) = :year" | |||||
| } else "" | |||||
| val startSql = if (startDate != null) { | |||||
| args["startDate"] = startDate.toString() | |||||
| "AND sl.date >= :startDate" | |||||
| } else "" | |||||
| val endSql = if (endDate != null) { | |||||
| args["endDate"] = endDate.toString() | |||||
| "AND sl.date <= :endDate" | |||||
| args["consumptionYearStart"] = LocalDate.of(year, 1, 1).atStartOfDay() | |||||
| args["consumptionYearEndExclusive"] = LocalDate.of(year + 1, 1, 1).atStartOfDay() | |||||
| "AND sl.date >= :consumptionYearStart AND sl.date < :consumptionYearEndExclusive" | |||||
| } else "" | } else "" | ||||
| val itemSql = if (!itemCode.isNullOrBlank()) { | |||||
| val rangeSql = ledgerDateTimeRangeSql( | |||||
| args, | |||||
| "sl.date", | |||||
| startDate, | |||||
| endDate, | |||||
| startArg = "consumptionRangeStart", | |||||
| endArg = "consumptionRangeEndExclusive", | |||||
| ) | |||||
| val hasItemFilter = !itemCode.isNullOrBlank() | |||||
| if (hasItemFilter) { | |||||
| args["itemCode"] = "%$itemCode%" | args["itemCode"] = "%$itemCode%" | ||||
| "AND sl.itemCode LIKE :itemCode" | |||||
| } else "" | |||||
| } | |||||
| val itemSql = if (hasItemFilter) "AND sl.itemCode LIKE :itemCode" else "" | |||||
| val fromClause = if (hasItemFilter) { | |||||
| "FROM stock_ledger sl" | |||||
| } else { | |||||
| "FROM stock_ledger sl FORCE INDEX (idx_sl_deleted_date)" | |||||
| } | |||||
| val sql = """ | val sql = """ | ||||
| SELECT | SELECT | ||||
| DATE_FORMAT(sl.date, '%Y-%m') AS month, | DATE_FORMAT(sl.date, '%Y-%m') AS month, | ||||
| COALESCE(SUM(sl.outQty), 0) AS outQty | COALESCE(SUM(sl.outQty), 0) AS outQty | ||||
| FROM stock_ledger sl | |||||
| WHERE sl.deleted = 0 AND sl.date IS NOT NULL $yearSql $startSql $endSql $itemSql | |||||
| $fromClause | |||||
| WHERE sl.deleted = 0 AND sl.date IS NOT NULL | |||||
| $yearSql $rangeSql $itemSql | |||||
| GROUP BY DATE_FORMAT(sl.date, '%Y-%m') | GROUP BY DATE_FORMAT(sl.date, '%Y-%m') | ||||
| ORDER BY month | ORDER BY month | ||||
| """.trimIndent() | """.trimIndent() | ||||
| @@ -721,23 +718,29 @@ open class ChartService( | |||||
| /** | /** | ||||
| * Staff delivery performance: daily pick ticket count and total time per staff. | * Staff delivery performance: daily pick ticket count and total time per staff. | ||||
| * Uses do_pick_order_record (handler = handledBy); time = sum of (ticketCompleteDateTime - ticketReleaseTime) per record. | |||||
| * Optionally use do_pick_order_line_record for line count; here orderCount = number of completed pick tickets. | |||||
| * Uses delivery_order_pick_order (handler = handledBy); time = sum of | |||||
| * (ticketCompleteDateTime - ticketReleaseTime) per completed ticket. | |||||
| * staffNos: when non-empty, filter to these staff by user.staffNo (multi-select). | * staffNos: when non-empty, filter to these staff by user.staffNo (multi-select). | ||||
| * storeIdNull: when true, only rows with dop.storeId IS NULL (takes precedence over storeId). | |||||
| * storeId: when non-blank and storeIdNull is not true, filter dop.storeId equality (trimmed). | |||||
| * When no store filter, FORCE INDEX (idx_dopo_staff_perf_complete) so the optimizer uses a | |||||
| * ticketCompleteDateTime range scan instead of a less selective store composite index. | |||||
| */ | */ | ||||
| fun getStaffDeliveryPerformance( | fun getStaffDeliveryPerformance( | ||||
| startDate: LocalDate?, | startDate: LocalDate?, | ||||
| endDate: LocalDate?, | endDate: LocalDate?, | ||||
| staffNos: List<String>? | |||||
| staffNos: List<String>?, | |||||
| storeId: String?, | |||||
| storeIdNull: Boolean?, | |||||
| ): List<Map<String, Any>> { | ): List<Map<String, Any>> { | ||||
| val args = mutableMapOf<String, Any>() | val args = mutableMapOf<String, Any>() | ||||
| val startSql = if (startDate != null) { | val startSql = if (startDate != null) { | ||||
| args["startDate"] = startDate.toString() | |||||
| "AND DATE(dpor.ticketCompleteDateTime) >= :startDate" | |||||
| args["startDate"] = startDate.atStartOfDay() | |||||
| "AND dop.ticketCompleteDateTime >= :startDate" | |||||
| } else "" | } else "" | ||||
| val endSql = if (endDate != null) { | val endSql = if (endDate != null) { | ||||
| args["endDate"] = endDate.toString() | |||||
| "AND DATE(dpor.ticketCompleteDateTime) <= :endDate" | |||||
| args["endExclusive"] = endDate.plusDays(1).atStartOfDay() | |||||
| "AND dop.ticketCompleteDateTime < :endExclusive" | |||||
| } else "" | } else "" | ||||
| val staffSql = if (!staffNos.isNullOrEmpty()) { | val staffSql = if (!staffNos.isNullOrEmpty()) { | ||||
| val nos = staffNos.map { it.trim() }.filter { it.isNotBlank() } | val nos = staffNos.map { it.trim() }.filter { it.isNotBlank() } | ||||
| @@ -746,25 +749,40 @@ open class ChartService( | |||||
| "AND u.staffNo IN (:staffNos)" | "AND u.staffNo IN (:staffNos)" | ||||
| } | } | ||||
| } else "" | } else "" | ||||
| val storeSql = when { | |||||
| storeIdNull == true -> "AND dop.storeId IS NULL" | |||||
| !storeId.isNullOrBlank() -> { | |||||
| args["filterStoreId"] = storeId.trim() | |||||
| "AND dop.storeId = :filterStoreId" | |||||
| } | |||||
| else -> "" | |||||
| } | |||||
| val useStoreFilter = storeIdNull == true || !storeId.isNullOrBlank() | |||||
| val fromClause = if (useStoreFilter) { | |||||
| "FROM delivery_order_pick_order dop" | |||||
| } else { | |||||
| "FROM delivery_order_pick_order dop FORCE INDEX (idx_dopo_staff_perf_complete)" | |||||
| } | |||||
| val sql = """ | val sql = """ | ||||
| SELECT | SELECT | ||||
| DATE_FORMAT(dpor.ticketCompleteDateTime, '%Y-%m-%d') AS date, | |||||
| COALESCE(u.name, dpor.handler_name, 'Unknown') AS staffName, | |||||
| COUNT(dpor.id) AS orderCount, | |||||
| DATE_FORMAT(dop.ticketCompleteDateTime, '%Y-%m-%d') AS date, | |||||
| COALESCE(NULLIF(TRIM(COALESCE(u.name, '')), ''), dop.handlerName, 'Unknown') AS staffName, | |||||
| COUNT(dop.id) AS orderCount, | |||||
| COALESCE(SUM( | COALESCE(SUM( | ||||
| CASE | CASE | ||||
| WHEN dpor.ticket_release_time IS NOT NULL AND dpor.ticketCompleteDateTime IS NOT NULL | |||||
| THEN GREATEST(0, TIMESTAMPDIFF(MINUTE, dpor.ticket_release_time, dpor.ticketCompleteDateTime)) | |||||
| WHEN dop.ticketReleaseTime IS NOT NULL AND dop.ticketCompleteDateTime IS NOT NULL | |||||
| THEN GREATEST(0, TIMESTAMPDIFF(MINUTE, dop.ticketReleaseTime, dop.ticketCompleteDateTime)) | |||||
| ELSE 0 | ELSE 0 | ||||
| END | END | ||||
| ), 0) AS totalMinutes | ), 0) AS totalMinutes | ||||
| FROM do_pick_order_record dpor | |||||
| LEFT JOIN user u ON dpor.handled_by = u.id AND u.deleted = 0 | |||||
| WHERE dpor.deleted = 0 | |||||
| AND dpor.ticket_status = 'completed' | |||||
| AND dpor.ticketCompleteDateTime IS NOT NULL | |||||
| $startSql $endSql $staffSql | |||||
| GROUP BY DATE(dpor.ticketCompleteDateTime), dpor.handled_by, u.name, dpor.handler_name | |||||
| $fromClause | |||||
| LEFT JOIN user u ON dop.handledBy = u.id AND u.deleted = 0 | |||||
| WHERE dop.deleted = 0 | |||||
| AND dop.ticketStatus = 'completed' | |||||
| AND dop.ticketCompleteDateTime IS NOT NULL | |||||
| $startSql $endSql $staffSql $storeSql | |||||
| GROUP BY DATE_FORMAT(dop.ticketCompleteDateTime, '%Y-%m-%d'), | |||||
| dop.handledBy, u.name, dop.handlerName | |||||
| ORDER BY date, orderCount DESC | ORDER BY date, orderCount DESC | ||||
| """.trimIndent() | """.trimIndent() | ||||
| return jdbcDao.queryForList(sql, args) | return jdbcDao.queryForList(sql, args) | ||||
| @@ -1572,4 +1590,56 @@ open class ChartService( | |||||
| """.trimIndent() | """.trimIndent() | ||||
| return jdbcDao.queryForList(sql, args) | return jdbcDao.queryForList(sql, args) | ||||
| } | } | ||||
| /** Half-open [start, end+1 day) on a DATE/DATETIME column (no DATE() wrapper). */ | |||||
| private fun localDateRangeSql( | |||||
| args: MutableMap<String, Any>, | |||||
| column: String, | |||||
| startDate: LocalDate?, | |||||
| endDate: LocalDate?, | |||||
| startArg: String = "chartRangeStart", | |||||
| endArg: String = "chartRangeEndExclusive", | |||||
| ): String = buildString { | |||||
| if (startDate != null) { | |||||
| args[startArg] = startDate | |||||
| append(" AND $column >= :$startArg") | |||||
| } | |||||
| if (endDate != null) { | |||||
| args[endArg] = endDate.plusDays(1) | |||||
| append(" AND $column < :$endArg") | |||||
| } | |||||
| } | |||||
| /** Half-open range on stock_ledger.date (DATETIME). */ | |||||
| private fun ledgerDateTimeRangeSql( | |||||
| args: MutableMap<String, Any>, | |||||
| column: String, | |||||
| startDate: LocalDate?, | |||||
| endDate: LocalDate?, | |||||
| startArg: String = "ledgerRangeStart", | |||||
| endArg: String = "ledgerRangeEndExclusive", | |||||
| ): String = buildString { | |||||
| if (startDate != null) { | |||||
| args[startArg] = startDate.atStartOfDay() | |||||
| append(" AND $column >= :$startArg") | |||||
| } | |||||
| if (endDate != null) { | |||||
| args[endArg] = endDate.plusDays(1).atStartOfDay() | |||||
| append(" AND $column < :$endArg") | |||||
| } | |||||
| } | |||||
| /** COALESCE datetime expression; args [inOutRangeStart] / [inOutRangeEndExclusive] must already be in map when non-null. */ | |||||
| private fun stockInOutCoalescedDateRangeSql( | |||||
| coalescedExpr: String, | |||||
| rangeStart: LocalDateTime?, | |||||
| rangeEndExclusive: LocalDateTime?, | |||||
| ): String = buildString { | |||||
| if (rangeStart != null) { | |||||
| append(" AND $coalescedExpr >= :inOutRangeStart") | |||||
| } | |||||
| if (rangeEndExclusive != null) { | |||||
| append(" AND $coalescedExpr < :inOutRangeEndExclusive") | |||||
| } | |||||
| } | |||||
| } | } | ||||
| @@ -26,7 +26,7 @@ class ChartController( | |||||
| /** | /** | ||||
| * GET /chart/delivery-order-by-date?startDate=&endDate= | * GET /chart/delivery-order-by-date?startDate=&endDate= | ||||
| * Returns [{ date, orderCount, totalQty }] | |||||
| * Returns [{ date, orderCount, totalQty }]. Date axis: delivery_order.estimatedArrivalDate only (null ETA excluded). | |||||
| */ | */ | ||||
| @GetMapping("/delivery-order-by-date") | @GetMapping("/delivery-order-by-date") | ||||
| fun getDeliveryOrderByDate( | fun getDeliveryOrderByDate( | ||||
| @@ -129,7 +129,7 @@ class ChartController( | |||||
| /** | /** | ||||
| * GET /chart/stock-in-out-by-date?startDate=&endDate= | * GET /chart/stock-in-out-by-date?startDate=&endDate= | ||||
| * Returns [{ date, inQty, outQty }] | |||||
| * Returns [{ date, inQty, outQty }]. Date range pushed into each UNION branch; si/so read before lines. | |||||
| */ | */ | ||||
| @GetMapping("/stock-in-out-by-date") | @GetMapping("/stock-in-out-by-date") | ||||
| fun getStockInOutByDate( | fun getStockInOutByDate( | ||||
| @@ -140,6 +140,7 @@ class ChartController( | |||||
| /** | /** | ||||
| * GET /chart/top-delivery-items-item-options?startDate=&endDate= | * GET /chart/top-delivery-items-item-options?startDate=&endDate= | ||||
| * Returns [{ itemCode, itemName }] — distinct items in delivery lines in the period (for multi-select). | * Returns [{ itemCode, itemName }] — distinct items in delivery lines in the period (for multi-select). | ||||
| * Period: delivery_order.estimatedArrivalDate only (null ETA excluded). | |||||
| */ | */ | ||||
| @GetMapping("/top-delivery-items-item-options") | @GetMapping("/top-delivery-items-item-options") | ||||
| fun getTopDeliveryItemsItemOptions( | fun getTopDeliveryItemsItemOptions( | ||||
| @@ -150,6 +151,7 @@ class ChartController( | |||||
| /** | /** | ||||
| * GET /chart/top-delivery-items?startDate=&endDate=&limit=20&itemCode=A&itemCode=B | * GET /chart/top-delivery-items?startDate=&endDate=&limit=20&itemCode=A&itemCode=B | ||||
| * Returns [{ itemCode, itemName, totalQty }]. When itemCode present, only those items (still by totalQty, limit). | * Returns [{ itemCode, itemName, totalQty }]. When itemCode present, only those items (still by totalQty, limit). | ||||
| * Period: delivery_order.estimatedArrivalDate only (null ETA excluded). | |||||
| */ | */ | ||||
| @GetMapping("/top-delivery-items") | @GetMapping("/top-delivery-items") | ||||
| fun getTopDeliveryItems( | fun getTopDeliveryItems( | ||||
| @@ -192,16 +194,20 @@ class ChartController( | |||||
| chartService.getStaffDeliveryPerformanceHandlers() | chartService.getStaffDeliveryPerformanceHandlers() | ||||
| /** | /** | ||||
| * GET /chart/staff-delivery-performance?startDate=&endDate=&staffNo=A001&staffNo=A002 | |||||
| * Returns [{ date, staffName, orderCount, totalMinutes }]. Data from do_pick_order_record (handled_by), orderCount = completed pick tickets, totalMinutes = sum(ticketCompleteDateTime - ticketReleaseTime). | |||||
| * GET /chart/staff-delivery-performance?startDate=&endDate=&staffNo=A001&staffNo=A002&storeId=2/F&storeIdNull=true | |||||
| * Returns [{ date, staffName, orderCount, totalMinutes }]. Data from delivery_order_pick_order | |||||
| * (handledBy), orderCount = completed pick tickets, totalMinutes = sum(ticketCompleteDateTime - ticketReleaseTime). | |||||
| * Optional storeId filters delivery_order_pick_order.storeId; storeIdNull=true means IS NULL (overrides storeId). | |||||
| */ | */ | ||||
| @GetMapping("/staff-delivery-performance") | @GetMapping("/staff-delivery-performance") | ||||
| fun getStaffDeliveryPerformance( | fun getStaffDeliveryPerformance( | ||||
| @RequestParam(required = false) @DateTimeFormat(iso = DateTimeFormat.ISO.DATE) startDate: LocalDate?, | @RequestParam(required = false) @DateTimeFormat(iso = DateTimeFormat.ISO.DATE) startDate: LocalDate?, | ||||
| @RequestParam(required = false) @DateTimeFormat(iso = DateTimeFormat.ISO.DATE) endDate: LocalDate?, | @RequestParam(required = false) @DateTimeFormat(iso = DateTimeFormat.ISO.DATE) endDate: LocalDate?, | ||||
| @RequestParam(required = false) staffNo: List<String>?, | @RequestParam(required = false) staffNo: List<String>?, | ||||
| @RequestParam(required = false) storeId: String?, | |||||
| @RequestParam(required = false) storeIdNull: Boolean?, | |||||
| ): List<Map<String, Any>> = | ): List<Map<String, Any>> = | ||||
| chartService.getStaffDeliveryPerformance(startDate, endDate, staffNo) | |||||
| chartService.getStaffDeliveryPerformance(startDate, endDate, staffNo, storeId, storeIdNull) | |||||
| // ---------- Job order reports ---------- | // ---------- Job order reports ---------- | ||||
| @@ -23,12 +23,11 @@ open class MailSMTP(settingsService: SettingsService) { | |||||
| if (other == null || other !is MailSMTP) return false | if (other == null || other !is MailSMTP) return false | ||||
| val o = other as MailSMTP | val o = other as MailSMTP | ||||
| if (StringUtils.equals( | |||||
| this.host, | |||||
| o.host | |||||
| ) && this.port == o.port && | |||||
| if (StringUtils.equals(this.host, o.host) && | |||||
| this.port == o.port && | |||||
| StringUtils.equals(this.username, o.username) && | StringUtils.equals(this.username, o.username) && | ||||
| StringUtils.equals(this.password, o.password) | |||||
| StringUtils.equals(this.password, o.password) && | |||||
| this.auth == o.auth | |||||
| ) { | ) { | ||||
| return true | return true | ||||
| } | } | ||||
| @@ -28,8 +28,13 @@ public abstract class SettingNames { | |||||
| public static final String SCHEDULE_M18_DO1 = "SCHEDULE.m18.do1"; | public static final String SCHEDULE_M18_DO1 = "SCHEDULE.m18.do1"; | ||||
| /** Saturday-only DO1 time (default 03:10). Mon–Fri & Sun use [SCHEDULE_M18_DO1] time via a second trigger. */ | /** Saturday-only DO1 time (default 03:10). Mon–Fri & Sun use [SCHEDULE_M18_DO1] time via a second trigger. */ | ||||
| public static final String SCHEDULE_M18_DO1_SAT = "SCHEDULE.m18.do1.sat"; | public static final String SCHEDULE_M18_DO1_SAT = "SCHEDULE.m18.do1.sat"; | ||||
| /** Comma-separated dDates (yyyy-MM-dd) of completed one-time DO1 catch-ups ([scheduler.do1CatchUp]). */ | |||||
| public static final String SCHEDULE_M18_DO1_CATCHUP_DONE_DDATE = "SCHEDULE.m18.do1.catchup.doneDDate"; | |||||
| public static final String SCHEDULE_M18_DO2 = "SCHEDULE.m18.do2"; | public static final String SCHEDULE_M18_DO2 = "SCHEDULE.m18.do2"; | ||||
| /** Daily push FPSMS BOMs → M18 udfBomForShop (default 23:00; requires [M18_BOM_SHOP_SYNC_ENABLED] and scheduler.m18Sync.enabled). */ | |||||
| public static final String SCHEDULE_M18_BOM_SHOP = "SCHEDULE.m18.bom.shop"; | |||||
| public static final String SCHEDULE_M18_MASTER = "SCHEDULE.m18.master"; | public static final String SCHEDULE_M18_MASTER = "SCHEDULE.m18.master"; | ||||
| /** M18 unit master sync via GET /search/search?stSearch=unit (cron, e.g. "0 40 12 * * *" for 12:40 daily) */ | /** M18 unit master sync via GET /search/search?stSearch=unit (cron, e.g. "0 40 12 * * *" for 12:40 daily) */ | ||||
| @@ -41,6 +46,11 @@ public abstract class SettingNames { | |||||
| */ | */ | ||||
| public static final String M18_UNITS_SYNC_INITIAL_FULL_SYNC_DONE = "M18.units.sync.initialFullSyncDone"; | public static final String M18_UNITS_SYNC_INITIAL_FULL_SYNC_DONE = "M18.units.sync.initialFullSyncDone"; | ||||
| /** | |||||
| * When "true", FPSMS may push BOM header + materials to M18 udfBomForShop. | |||||
| */ | |||||
| public static final String M18_BOM_SHOP_SYNC_ENABLED = "M18.bom.shop.sync.enabled"; | |||||
| /** Post completed DN and process M18 GRN (cron, e.g. "0 40 23 * * *" for 23:40 daily) */ | /** Post completed DN and process M18 GRN (cron, e.g. "0 40 23 * * *" for 23:40 daily) */ | ||||
| public static final String SCHEDULE_POST_COMPLETED_DN_GRN = "SCHEDULE.postCompletedDn.grn"; | public static final String SCHEDULE_POST_COMPLETED_DN_GRN = "SCHEDULE.postCompletedDn.grn"; | ||||
| @@ -52,6 +62,11 @@ public abstract class SettingNames { | |||||
| public static final String SCHEDULE_PROD_ROUGH = "SCHEDULE.prod.rough"; | public static final String SCHEDULE_PROD_ROUGH = "SCHEDULE.prod.rough"; | ||||
| public static final String SCHEDULE_PROD_DETAILED = "SCHEDULE.prod.detailed"; | public static final String SCHEDULE_PROD_DETAILED = "SCHEDULE.prod.detailed"; | ||||
| /** | |||||
| * Job order plan-start overdue batch (default 00:00:15 daily): hide or reschedule JOs whose plan day was yesterday. | |||||
| */ | |||||
| public static final String SCHEDULE_JO_PLAN_START = "SCHEDULE.jo.planStart"; | |||||
| /* | /* | ||||
| * Mail settings | * Mail settings | ||||
| */ | */ | ||||
| @@ -0,0 +1,18 @@ | |||||
| package com.ffii.fpsms.modules.common.alert | |||||
| import org.slf4j.LoggerFactory | |||||
| /** Dev / disabled mode: log SMS body instead of sending. */ | |||||
| class LoggingSmsSender : SmsSender { | |||||
| private val logger = LoggerFactory.getLogger(LoggingSmsSender::class.java) | |||||
| override fun send( | |||||
| toNumbers: List<String>, | |||||
| body: String, | |||||
| job: String, | |||||
| code: String, | |||||
| detail: String, | |||||
| ) { | |||||
| logger.warn("[SMS would send to {}] {}", toNumbers.joinToString(), body) | |||||
| } | |||||
| } | |||||
| @@ -0,0 +1,27 @@ | |||||
| package com.ffii.fpsms.modules.common.alert | |||||
| /** | |||||
| * Parses PO line sync failure rows written by [com.ffii.fpsms.m18.service.M18PurchaseOrderService]. | |||||
| * Expected format: "PO {code} Cannot resolve local item for M18 proId={id}". | |||||
| */ | |||||
| internal object PoLineFailureAlertSupport { | |||||
| private val WITH_PO_CODE = | |||||
| Regex("^PO (\\S+) Cannot resolve local item for M18 proId=(\\d+)$") | |||||
| /** @return (poCode, bullet line for email) or null when the message cannot be parsed. */ | |||||
| fun parseExceptionMessage(raw: String?): Pair<String, String>? { | |||||
| val trimmed = raw?.trim().orEmpty() | |||||
| if (trimmed.isEmpty()) { | |||||
| return null | |||||
| } | |||||
| val match = WITH_PO_CODE.matchEntire(trimmed) ?: return null | |||||
| val poCode = match.groupValues[1] | |||||
| val proId = match.groupValues[2] | |||||
| return poCode to "Cannot resolve local item for M18 proId=$proId" | |||||
| } | |||||
| fun buildEmailBody(poCode: String, bulletLines: Collection<String>): String { | |||||
| val detail = bulletLines.joinToString("\n") { "- $it" } | |||||
| return "FPSMS PO_LINE FAIL: $poCode\n\n$detail" | |||||
| } | |||||
| } | |||||
| @@ -0,0 +1,73 @@ | |||||
| package com.ffii.fpsms.modules.common.alert | |||||
| import org.springframework.boot.context.properties.ConfigurationProperties | |||||
| @ConfigurationProperties(prefix = "scheduler.sync-alert") | |||||
| data class SchedulerSyncAlertProperties( | |||||
| /** When false, checks still run but SMS is not sent (logged only). */ | |||||
| val enabled: Boolean = false, | |||||
| /** Cron for the watchdog that evaluates sync health (default: every 15 minutes). */ | |||||
| val checkCron: String = "0 */15 * * * *", | |||||
| val sms: SmsProperties = SmsProperties(), | |||||
| val email: EmailAlertProperties = EmailAlertProperties(), | |||||
| val do1: Do1AlertProperties = Do1AlertProperties(), | |||||
| val presence: PresenceAlertProperties = PresenceAlertProperties(), | |||||
| val poLine: PoLineAlertProperties = PoLineAlertProperties(), | |||||
| ) | |||||
| data class SmsProperties( | |||||
| /** When false, no SMS/WhatsApp alerts (email-only). */ | |||||
| val enabled: Boolean = false, | |||||
| /** twilio | log (dev: log message only, no HTTP) */ | |||||
| val provider: String = "log", | |||||
| /** sms or whatsapp (Twilio WhatsApp uses whatsapp:+E164 on To/From). */ | |||||
| val channel: String = "sms", | |||||
| val accountSid: String = "", | |||||
| val authToken: String = "", | |||||
| val fromNumber: String = "", | |||||
| /** Comma-separated E.164 numbers, e.g. +85291234567 (whatsapp prefix added when channel=whatsapp). */ | |||||
| val toNumbers: String = "", | |||||
| /** | |||||
| * Twilio Content Template SID (WhatsApp business-initiated messages). | |||||
| * When set, sends ContentSid + ContentVariables instead of Body. | |||||
| * Template should use {{1}}=job (DO1/PO/…) and {{2}}=alert detail. | |||||
| */ | |||||
| val contentSid: String = "", | |||||
| val contentDetailMaxLength: Int = 200, | |||||
| /** | |||||
| * Maps to Twilio ContentVariables `"1"` (template {{1}}). | |||||
| * job | date (d/M, e.g. 17/6) | time (3pm) | detail | summary (job + code + detail) | |||||
| */ | |||||
| val contentVar1: String = "job", | |||||
| /** Maps to ContentVariables `"2"`. Same options as [contentVar1]. */ | |||||
| val contentVar2: String = "detail", | |||||
| ) | |||||
| data class EmailAlertProperties( | |||||
| /** Send sync alerts by email (uses DB MAIL.smtp.* — Office 365). */ | |||||
| val enabled: Boolean = false, | |||||
| /** Comma-separated recipients, e.g. [email protected],[email protected] */ | |||||
| val toAddresses: String = "", | |||||
| /** Subject prefix; full subject is prefix + job + code in brackets. */ | |||||
| val subjectPrefix: String = "FPSMS M18 sync alert", | |||||
| ) | |||||
| data class Do1AlertProperties( | |||||
| /** Warn when recordsProcessed is below this (typical full day ~800+). */ | |||||
| val minRecordsProcessed: Int = 400, | |||||
| /** Minutes after today's DO1 schedule time before evaluating DO1 rules. */ | |||||
| val graceMinutesAfterSchedule: Int = 30, | |||||
| val alertOnFailed: Boolean = true, | |||||
| val alertOnZeroRecords: Boolean = true, | |||||
| val alertOnLineFailures: Boolean = true, | |||||
| ) | |||||
| /** PO / DO2 / master-data: alert if no SUCCESS log by schedule time + grace. */ | |||||
| data class PresenceAlertProperties( | |||||
| val graceMinutesAfterSchedule: Int = 60, | |||||
| ) | |||||
| /** Purchase order line sync failures from [m18_data_log] (one email per PO code per day). */ | |||||
| data class PoLineAlertProperties( | |||||
| val enabled: Boolean = true, | |||||
| ) | |||||
| @@ -0,0 +1,504 @@ | |||||
| package com.ffii.fpsms.modules.common.alert | |||||
| import com.ffii.fpsms.m18.entity.M18DataLogRepository | |||||
| import com.ffii.fpsms.m18.entity.SchedulerSyncLog | |||||
| import com.ffii.fpsms.m18.entity.SchedulerSyncLogRepository | |||||
| import com.ffii.fpsms.m18.enums.M18DataLogStatus | |||||
| import com.ffii.fpsms.modules.common.SettingNames | |||||
| import com.ffii.fpsms.modules.common.scheduler.service.SchedulerService | |||||
| import com.ffii.fpsms.modules.settings.entity.Settings | |||||
| import com.ffii.fpsms.modules.settings.service.SettingsService | |||||
| import org.slf4j.LoggerFactory | |||||
| import org.springframework.beans.factory.annotation.Value | |||||
| import org.springframework.scheduling.support.CronExpression | |||||
| import org.springframework.stereotype.Service | |||||
| import org.springframework.web.reactive.function.client.WebClient | |||||
| import java.time.DayOfWeek | |||||
| import java.time.LocalDate | |||||
| import java.time.LocalDateTime | |||||
| import java.time.LocalTime | |||||
| import java.time.format.DateTimeFormatter | |||||
| import java.util.Locale | |||||
| import kotlin.jvm.optionals.getOrNull | |||||
| /** | |||||
| * Watches [scheduler_sync_log] after M18 cron jobs and sends SMS when rules fail. | |||||
| * | |||||
| * - **DO1**: low volume (< min records), FAILED status, zero records, line failures. | |||||
| * - **PO / DO2 / master-data**: no SUCCESS log by schedule + grace window. | |||||
| * - **PO_LINE**: [m18_data_log] purchase order line FAIL rows (one email per PO code per day). | |||||
| */ | |||||
| @Service | |||||
| open class SchedulerSyncAlertService( | |||||
| private val properties: SchedulerSyncAlertProperties, | |||||
| private val smsSender: SmsSender, | |||||
| private val syncAlertEmailSender: SyncAlertEmailSender, | |||||
| private val schedulerSyncLogRepository: SchedulerSyncLogRepository, | |||||
| private val m18DataLogRepository: M18DataLogRepository, | |||||
| private val settingsService: SettingsService, | |||||
| private val webClientBuilder: WebClient.Builder, | |||||
| @Value("\${scheduler.m18Sync.enabled:false}") private val m18SyncEnabled: Boolean, | |||||
| ) { | |||||
| private val logger = LoggerFactory.getLogger(SchedulerSyncAlertService::class.java) | |||||
| private companion object { | |||||
| const val PO_LINE_REF_TYPE = "Purchase Order Line" | |||||
| const val PO_LINE_ALERT_JOB = "PO_LINE" | |||||
| } | |||||
| /** Master-data sub-jobs written by [SchedulerService.getM18MasterData]. */ | |||||
| private val masterDataSyncTypes = | |||||
| listOf("Units", "Products", "Vendors", "BusinessUnits", "Currencies") | |||||
| open fun runChecks(now: LocalDateTime = LocalDateTime.now()): List<String> { | |||||
| val alerts = mutableListOf<Triple<String, String, String>>() | |||||
| alerts += checkPoLineFailures(now) | |||||
| if (!m18SyncEnabled) { | |||||
| for (message in alerts) { | |||||
| sendAlert(message.first, message.second, message.third) | |||||
| } | |||||
| if (alerts.isEmpty()) { | |||||
| logger.debug("Sync alert skipped (scheduler.m18Sync.enabled=false)") | |||||
| } | |||||
| return alerts.map { it.third } | |||||
| } | |||||
| alerts += checkDo1(now) | |||||
| alerts += checkPresenceSync("PO", SettingNames.SCHEDULE_M18_PO, "0 0 2 * * *", now) | |||||
| alerts += checkPresenceSync("DO2", SettingNames.SCHEDULE_M18_DO2, SchedulerService.DO2_DEFAULT_CRON, now) | |||||
| alerts += checkMasterDataPresence(now) | |||||
| for (message in alerts) { | |||||
| sendAlert(message.first, message.second, message.third) | |||||
| } | |||||
| return alerts.map { it.third } | |||||
| } | |||||
| /** | |||||
| * Email when M18 PO line sync wrote FAIL rows to [m18_data_log] today. | |||||
| * Dedupes via [sendAlert] using job [PO_LINE_ALERT_JOB] and PO code (one email per PO per day). | |||||
| */ | |||||
| private fun checkPoLineFailures(now: LocalDateTime): List<Triple<String, String, String>> { | |||||
| if (!properties.poLine.enabled) { | |||||
| return emptyList() | |||||
| } | |||||
| val dayStart = now.toLocalDate().atStartOfDay() | |||||
| val logs = | |||||
| m18DataLogRepository.findAllByRefTypeAndStatusAndDeletedIsFalseAndCreatedGreaterThanEqualOrderByIdAsc( | |||||
| PO_LINE_REF_TYPE, | |||||
| M18DataLogStatus.FAIL, | |||||
| dayStart, | |||||
| ) | |||||
| if (logs.isEmpty()) { | |||||
| return emptyList() | |||||
| } | |||||
| val bulletsByPo = linkedMapOf<String, LinkedHashSet<String>>() | |||||
| for (log in logs) { | |||||
| val raw = log.dataLog?.get("Exception Message")?.toString() | |||||
| val parsed = PoLineFailureAlertSupport.parseExceptionMessage(raw) ?: continue | |||||
| bulletsByPo.getOrPut(parsed.first) { LinkedHashSet() }.add(parsed.second) | |||||
| } | |||||
| if (bulletsByPo.isEmpty()) { | |||||
| return emptyList() | |||||
| } | |||||
| return bulletsByPo.map { (poCode, bullets) -> | |||||
| Triple( | |||||
| PO_LINE_ALERT_JOB, | |||||
| poCode, | |||||
| PoLineFailureAlertSupport.buildEmailBody(poCode, bullets), | |||||
| ) | |||||
| } | |||||
| } | |||||
| private fun checkDo1(now: LocalDateTime): List<Triple<String, String, String>> { | |||||
| val runDate = now.toLocalDate() | |||||
| val scheduledTime = resolveDo1ScheduledTime(runDate) ?: return emptyList() | |||||
| val checkAfter = scheduledTime.plusMinutes(properties.do1.graceMinutesAfterSchedule.toLong()) | |||||
| if (now.isBefore(checkAfter)) { | |||||
| return emptyList() | |||||
| } | |||||
| val log = latestLogOnDay("DO1", runDate) ?: run { | |||||
| return listOf(Triple("DO1", "MISSING", alertMessage("DO1", "MISSING", "No DO1 sync log today (expected after ${scheduledTime.toLocalTime()})."))) | |||||
| } | |||||
| val issues = mutableListOf<String>() | |||||
| val cfg = properties.do1 | |||||
| if (cfg.alertOnFailed && log.status.equals("FAILED", ignoreCase = true)) { | |||||
| issues += "status=FAILED ${log.errorMessage.orEmpty()}".trim() | |||||
| } | |||||
| if (cfg.alertOnZeroRecords && log.recordsProcessed == 0) { | |||||
| issues += "0 orders processed (possible M18 list timeout)" | |||||
| } | |||||
| if (log.recordsProcessed < cfg.minRecordsProcessed) { | |||||
| issues += "only ${log.recordsProcessed} processed (min ${cfg.minRecordsProcessed})" | |||||
| } | |||||
| if (cfg.alertOnLineFailures && log.recordsFailed > 0) { | |||||
| issues += "${log.recordsFailed} line(s) failed" | |||||
| } | |||||
| if (issues.isEmpty()) { | |||||
| return emptyList() | |||||
| } | |||||
| return listOf(Triple("DO1", "PROBLEM", alertMessage("DO1", "PROBLEM", issues.joinToString("; ")))) | |||||
| } | |||||
| private fun checkMasterDataPresence(now: LocalDateTime): List<Triple<String, String, String>> { | |||||
| val runDate = now.toLocalDate() | |||||
| val cron = | |||||
| settingsService.findByName(SettingNames.SCHEDULE_M18_MASTER).getOrNull()?.value | |||||
| ?: "0 0 1 * * *" | |||||
| val scheduledTime = scheduledTimeToday(cron, runDate) ?: return emptyList() | |||||
| val checkAfter = scheduledTime.plusMinutes(properties.presence.graceMinutesAfterSchedule.toLong()) | |||||
| if (now.isBefore(checkAfter)) { | |||||
| return emptyList() | |||||
| } | |||||
| val missing = masterDataSyncTypes.filter { type -> | |||||
| val log = latestLogOnDay(type, runDate) | |||||
| log == null || !log.status.equals("SUCCESS", ignoreCase = true) | |||||
| } | |||||
| if (missing.isEmpty()) { | |||||
| return emptyList() | |||||
| } | |||||
| return listOf( | |||||
| Triple( | |||||
| "MASTER", | |||||
| "MISSING", | |||||
| alertMessage("MASTER", "MISSING", "No SUCCESS today for: ${missing.joinToString()}"), | |||||
| ), | |||||
| ) | |||||
| } | |||||
| private fun checkPresenceSync( | |||||
| label: String, | |||||
| settingName: String, | |||||
| defaultCron: String, | |||||
| now: LocalDateTime, | |||||
| ): List<Triple<String, String, String>> { | |||||
| val runDate = now.toLocalDate() | |||||
| val cron = settingsService.findByName(settingName).getOrNull()?.value ?: defaultCron | |||||
| val scheduledTime = scheduledTimeToday(cron, runDate) ?: return emptyList() | |||||
| val checkAfter = scheduledTime.plusMinutes(properties.presence.graceMinutesAfterSchedule.toLong()) | |||||
| if (now.isBefore(checkAfter)) { | |||||
| return emptyList() | |||||
| } | |||||
| val log = latestLogOnDay(label, runDate) | |||||
| when { | |||||
| log == null -> | |||||
| return listOf( | |||||
| Triple( | |||||
| label, | |||||
| "MISSING", | |||||
| alertMessage(label, "MISSING", "No $label sync log today (expected after ${scheduledTime.toLocalTime()})."), | |||||
| ), | |||||
| ) | |||||
| !log.status.equals("SUCCESS", ignoreCase = true) -> | |||||
| return listOf( | |||||
| Triple( | |||||
| label, | |||||
| log.status.uppercase(), | |||||
| alertMessage( | |||||
| label, | |||||
| log.status.uppercase(), | |||||
| "processed=${log.recordsProcessed} failed=${log.recordsFailed} ${log.errorMessage.orEmpty()}".trim(), | |||||
| ), | |||||
| ), | |||||
| ) | |||||
| else -> return emptyList() | |||||
| } | |||||
| } | |||||
| private fun latestLogOnDay(syncType: String, date: LocalDate): SchedulerSyncLog? { | |||||
| val dayStart = date.atStartOfDay() | |||||
| val dayEnd = dayStart.plusDays(1) | |||||
| return schedulerSyncLogRepository.findFirstBySyncTypeAndStartTimeBetweenOrderByEndTimeDesc( | |||||
| syncType, | |||||
| dayStart, | |||||
| dayEnd, | |||||
| ) | |||||
| } | |||||
| private fun resolveDo1ScheduledTime(date: LocalDate): LocalDateTime? { | |||||
| val cron = | |||||
| if (date.dayOfWeek == DayOfWeek.SATURDAY) { | |||||
| settingsService.findByName(SettingNames.SCHEDULE_M18_DO1_SAT).getOrNull()?.value | |||||
| ?: "0 10 3 ? * SAT" | |||||
| } else { | |||||
| settingsService.findByName(SettingNames.SCHEDULE_M18_DO1).getOrNull()?.value | |||||
| ?: "0 10 19 * * *" | |||||
| } | |||||
| return scheduledTimeToday(cron, date) | |||||
| } | |||||
| /** Next fire time on [date] from a 6-field Spring cron, if that day matches the DOW field. */ | |||||
| internal fun scheduledTimeToday(cronExpression: String, date: LocalDate): LocalDateTime? { | |||||
| if (!cronMatchesDate(cronExpression, date)) { | |||||
| return null | |||||
| } | |||||
| val parts = cronExpression.trim().split(Regex("\\s+")) | |||||
| if (parts.size != 6) { | |||||
| return null | |||||
| } | |||||
| val second = parts[0].toIntOrNull() ?: 0 | |||||
| val minute = parts[1].toIntOrNull() ?: return null | |||||
| val hour = parts[2].toIntOrNull() ?: return null | |||||
| return date.atTime(LocalTime.of(hour, minute, second)) | |||||
| } | |||||
| internal fun cronMatchesDate(cronExpression: String, date: LocalDate): Boolean { | |||||
| val parts = cronExpression.trim().split(Regex("\\s+")) | |||||
| if (parts.size != 6) { | |||||
| return false | |||||
| } | |||||
| val dowField = parts[5].uppercase() | |||||
| if (dowField == "*" || dowField == "?") { | |||||
| return true | |||||
| } | |||||
| val day = date.dayOfWeek | |||||
| val tokens = dowField.split(",") | |||||
| for (token in tokens) { | |||||
| val trimmed = token.trim() | |||||
| when { | |||||
| trimmed.contains("-") -> { | |||||
| val range = trimmed.split("-") | |||||
| if (range.size == 2) { | |||||
| val from = parseDayOfWeek(range[0]) ?: continue | |||||
| val to = parseDayOfWeek(range[1]) ?: continue | |||||
| if (day.value in from.value..to.value) { | |||||
| return true | |||||
| } | |||||
| } | |||||
| } | |||||
| else -> { | |||||
| parseDayOfWeek(trimmed)?.let { if (day == it) return true } | |||||
| } | |||||
| } | |||||
| } | |||||
| return false | |||||
| } | |||||
| private fun parseDayOfWeek(token: String): DayOfWeek? = | |||||
| when (token.uppercase()) { | |||||
| "SUN", "0", "7" -> DayOfWeek.SUNDAY | |||||
| "MON", "1" -> DayOfWeek.MONDAY | |||||
| "TUE", "2" -> DayOfWeek.TUESDAY | |||||
| "WED", "3" -> DayOfWeek.WEDNESDAY | |||||
| "THU", "4" -> DayOfWeek.THURSDAY | |||||
| "FRI", "5" -> DayOfWeek.FRIDAY | |||||
| "SAT", "6" -> DayOfWeek.SATURDAY | |||||
| else -> null | |||||
| } | |||||
| private fun alertMessage(job: String, code: String, detail: String): String = | |||||
| "FPSMS $job $code: $detail" | |||||
| private fun detailFromMessage(message: String): String { | |||||
| val prefix = message.indexOf(": ") | |||||
| return if (prefix >= 0) message.substring(prefix + 2) else message | |||||
| } | |||||
| private fun sendAlert(job: String, code: String, message: String) { | |||||
| val dedupeKey = "SCHEDULE.syncAlert.sent.$job.$code.${LocalDate.now()}" | |||||
| if (alreadySent(dedupeKey)) { | |||||
| logger.debug("Sync alert already sent today: {}", message) | |||||
| return | |||||
| } | |||||
| if (!properties.enabled) { | |||||
| logger.warn("[sync-alert disabled] {}", message) | |||||
| return | |||||
| } | |||||
| var anySent = false | |||||
| if (properties.email.enabled) { | |||||
| val emailTo = properties.email.toAddresses.split(",") | |||||
| .map { it.trim() } | |||||
| .filter { it.isNotBlank() } | |||||
| if (emailTo.isEmpty()) { | |||||
| logger.error("Sync alert email skipped (no scheduler.sync-alert.email.to-addresses): {}", message) | |||||
| } else if (!syncAlertEmailSender.isSmtpConfigured()) { | |||||
| logger.error( | |||||
| "Sync alert email skipped (configure MAIL.smtp.username/password/host in settings): {}", | |||||
| message, | |||||
| ) | |||||
| } else { | |||||
| try { | |||||
| val subject = "${properties.email.subjectPrefix} [$job] $code" | |||||
| syncAlertEmailSender.send(emailTo, subject, message) | |||||
| anySent = true | |||||
| logger.info("Sync alert email sent: {}", message) | |||||
| } catch (e: Exception) { | |||||
| logger.error("Sync alert email failed: {}", message, e) | |||||
| } | |||||
| } | |||||
| } | |||||
| val smsTo = properties.sms.toNumbers.split(",") | |||||
| .map { it.trim() } | |||||
| .filter { it.isNotBlank() } | |||||
| val smsActive = | |||||
| properties.sms.enabled && | |||||
| !properties.sms.provider.equals("log", ignoreCase = true) && | |||||
| smsTo.isNotEmpty() | |||||
| if (smsActive) { | |||||
| try { | |||||
| smsSender.send(smsTo, message, job, code, detailFromMessage(message)) | |||||
| anySent = true | |||||
| logger.info("Sync alert SMS sent: {}", message) | |||||
| } catch (e: Exception) { | |||||
| logger.error("Sync alert SMS failed: {}", message, e) | |||||
| } | |||||
| } | |||||
| if (anySent) { | |||||
| markSent(dedupeKey, message) | |||||
| } else { | |||||
| logger.error("Sync alert not delivered on any channel: {}", message) | |||||
| } | |||||
| } | |||||
| private fun alreadySent(key: String): Boolean = | |||||
| settingsService.findByName(key).map { it.value == Settings.VALUE_BOOLEAN_TRUE }.orElse(false) | |||||
| private fun markSent(key: String, message: String) { | |||||
| val existing = settingsService.findByName(key).orElse(null) | |||||
| if (existing != null) { | |||||
| settingsService.update(key, Settings.VALUE_BOOLEAN_TRUE) | |||||
| } else { | |||||
| val setting = Settings() | |||||
| setting.name = key | |||||
| setting.value = Settings.VALUE_BOOLEAN_TRUE | |||||
| setting.category = "SCHEDULE" | |||||
| setting.type = Settings.TYPE_BOOLEAN | |||||
| settingsService.save(setting) | |||||
| } | |||||
| } | |||||
| /** Validates cron at startup / schedule registration. */ | |||||
| fun isValidCronExpression(cronExpression: String): Boolean = | |||||
| try { | |||||
| CronExpression.parse(cronExpression) | |||||
| true | |||||
| } catch (_: IllegalArgumentException) { | |||||
| false | |||||
| } | |||||
| /** | |||||
| * Send one WhatsApp using your Twilio Content template (same shape as Twilio console sample). | |||||
| * Defaults: var1=today d/M, var2=now h:mma (e.g. 17/6, 7:30pm). Pass var1=12/1&var2=3pm to match sandbox demo. | |||||
| */ | |||||
| open fun sendTestWhatsApp(var1: String?, var2: String?): String { | |||||
| if (!properties.sms.enabled) { | |||||
| return "WhatsApp/SMS sync alerts are disabled (scheduler.sync-alert.sms.enabled=false)." | |||||
| } | |||||
| twilioConfigIssue()?.let { return it } | |||||
| val twilio = resolveTwilioSender()!! | |||||
| val recipients = properties.sms.toNumbers.split(",") | |||||
| .map { it.trim() } | |||||
| .filter { it.isNotBlank() } | |||||
| val now = LocalDateTime.now() | |||||
| val v1 = var1?.trim()?.takeIf { it.isNotEmpty() } | |||||
| ?: now.format(DateTimeFormatter.ofPattern("d/M")) | |||||
| val v2 = var2?.trim()?.takeIf { it.isNotEmpty() } | |||||
| ?: now.format(DateTimeFormatter.ofPattern("h:mma", Locale.ENGLISH)) | |||||
| twilio.sendWithTemplateVars(recipients, v1, v2) | |||||
| return "WhatsApp test sent ContentVariables={\"1\":\"$v1\",\"2\":\"$v2\"} to ${recipients.joinToString()}" | |||||
| } | |||||
| /** Send a test email via DB SMTP (Office 365). Does not require sync-alert.enabled. */ | |||||
| open fun sendTestEmail(message: String? = null, subject: String? = null): String { | |||||
| val emailTo = properties.email.toAddresses.split(",") | |||||
| .map { it.trim() } | |||||
| .filter { it.isNotBlank() } | |||||
| if (emailTo.isEmpty()) { | |||||
| return "Set scheduler.sync-alert.email.to-addresses (e.g. [email protected])" | |||||
| } | |||||
| if (!syncAlertEmailSender.isSmtpConfigured()) { | |||||
| return syncAlertEmailSender.smtpConfigIssue() | |||||
| ?: "Configure MAIL.smtp.username, MAIL.smtp.password, MAIL.smtp.host in settings table" | |||||
| } | |||||
| val from = syncAlertEmailSender.resolveFromAddress() ?: "unknown" | |||||
| val body = | |||||
| message?.trim()?.takeIf { it.isNotEmpty() } | |||||
| ?: ( | |||||
| "FPSMS sync alert email test.\n\n" + | |||||
| "If you receive this, Office 365 SMTP is working.\n" + | |||||
| "From (MAIL.smtp.username): $from\n" + | |||||
| "Time: ${LocalDateTime.now()}" | |||||
| ) | |||||
| val emailSubject = | |||||
| subject?.trim()?.takeIf { it.isNotEmpty() } | |||||
| ?: "${properties.email.subjectPrefix} [TEST]" | |||||
| return try { | |||||
| syncAlertEmailSender.send(emailTo, emailSubject, body) | |||||
| "Test email sent from $from to ${emailTo.joinToString()}\nSubject: $emailSubject" | |||||
| } catch (e: Exception) { | |||||
| val hint = | |||||
| when { | |||||
| e.message?.contains("530", ignoreCase = true) == true || | |||||
| e.message?.contains("not authenticated", ignoreCase = true) == true -> | |||||
| "\n\nOffice 365 fix checklist:\n" + | |||||
| "1. UPDATE settings SET value='true' WHERE name='MAIL.smtp.auth';\n" + | |||||
| "2. MAIL.smtp.username must match the mailbox ([email protected])\n" + | |||||
| "3. Use an App password if MFA is on (not your normal login password)\n" + | |||||
| "4. IT must enable 'Authenticated SMTP' for this mailbox in Exchange admin\n" + | |||||
| "5. Restart backend after changing settings (SMTP client is cached)" | |||||
| else -> "" | |||||
| } | |||||
| "Email send failed: ${e.message}$hint" | |||||
| } | |||||
| } | |||||
| /** Uses injected [TwilioSmsSender] bean, or builds one when Twilio credentials are in config. */ | |||||
| private fun resolveTwilioSender(): TwilioSmsSender? { | |||||
| if (twilioConfigIssue() != null) { | |||||
| return null | |||||
| } | |||||
| if (smsSender is TwilioSmsSender) { | |||||
| return smsSender | |||||
| } | |||||
| return TwilioSmsSender(properties, webClientBuilder) | |||||
| } | |||||
| private fun twilioConfigIssue(): String? { | |||||
| val sms = properties.sms | |||||
| val missing = mutableListOf<String>() | |||||
| if (sms.accountSid.isBlank()) { | |||||
| missing += "TWILIO_ACCOUNT_SID (or scheduler.sync-alert.sms.account-sid)" | |||||
| } | |||||
| if (sms.authToken.isBlank()) { | |||||
| missing += "TWILIO_AUTH_TOKEN (or scheduler.sync-alert.sms.auth-token)" | |||||
| } | |||||
| if (sms.fromNumber.isBlank()) { | |||||
| missing += "TWILIO_FROM_NUMBER" | |||||
| } | |||||
| if (sms.toNumbers.isBlank()) { | |||||
| missing += "SYNC_ALERT_SMS_TO (or scheduler.sync-alert.sms.to-numbers)" | |||||
| } | |||||
| if (sms.contentSid.isBlank()) { | |||||
| missing += "TWILIO_WHATSAPP_CONTENT_SID" | |||||
| } | |||||
| if (missing.isEmpty()) { | |||||
| return null | |||||
| } | |||||
| return ( | |||||
| "Twilio not configured. Missing: ${missing.joinToString(", ")}.\n" + | |||||
| "Set env vars and restart backend. Example (PowerShell before bootRun):\n" + | |||||
| "\$env:TWILIO_ACCOUNT_SID='AC…'; \$env:TWILIO_AUTH_TOKEN='…'; " + | |||||
| "\$env:SYNC_ALERT_SMS_PROVIDER='twilio'" | |||||
| ) | |||||
| } | |||||
| } | |||||
| @@ -0,0 +1,27 @@ | |||||
| package com.ffii.fpsms.modules.common.alert | |||||
| import org.springframework.boot.context.properties.EnableConfigurationProperties | |||||
| import org.springframework.context.annotation.Bean | |||||
| import org.springframework.context.annotation.Configuration | |||||
| import org.springframework.web.reactive.function.client.WebClient | |||||
| @Configuration | |||||
| @EnableConfigurationProperties(SchedulerSyncAlertProperties::class) | |||||
| open class SmsAlertConfig( | |||||
| private val properties: SchedulerSyncAlertProperties, | |||||
| private val webClientBuilder: WebClient.Builder, | |||||
| ) { | |||||
| @Bean | |||||
| open fun smsSender(): SmsSender { | |||||
| val sms = properties.sms | |||||
| val useTwilio = | |||||
| sms.enabled && | |||||
| (sms.provider.equals("twilio", ignoreCase = true) || | |||||
| (sms.accountSid.isNotBlank() && sms.authToken.isNotBlank())) | |||||
| return if (useTwilio) { | |||||
| TwilioSmsSender(properties, webClientBuilder) | |||||
| } else { | |||||
| LoggingSmsSender() | |||||
| } | |||||
| } | |||||
| } | |||||
| @@ -0,0 +1,11 @@ | |||||
| package com.ffii.fpsms.modules.common.alert | |||||
| interface SmsSender { | |||||
| fun send( | |||||
| toNumbers: List<String>, | |||||
| body: String, | |||||
| job: String = "", | |||||
| code: String = "", | |||||
| detail: String = "", | |||||
| ) | |||||
| } | |||||
| @@ -0,0 +1,68 @@ | |||||
| package com.ffii.fpsms.modules.common.alert | |||||
| import com.ffii.fpsms.modules.common.SettingNames | |||||
| import com.ffii.fpsms.modules.common.mail.service.MailSenderService | |||||
| import com.ffii.fpsms.modules.settings.service.SettingsService | |||||
| import jakarta.mail.internet.InternetAddress | |||||
| import org.slf4j.LoggerFactory | |||||
| import org.springframework.mail.javamail.MimeMessageHelper | |||||
| import org.springframework.stereotype.Service | |||||
| /** | |||||
| * Plain-text alert emails via existing Office 365 SMTP settings in DB | |||||
| * ([SettingNames.MAIL_SMTP_HOST] = smtp.office365.com, port 587, etc.). | |||||
| */ | |||||
| @Service | |||||
| open class SyncAlertEmailSender( | |||||
| private val mailSenderService: MailSenderService, | |||||
| private val settingsService: SettingsService, | |||||
| ) { | |||||
| private val logger = LoggerFactory.getLogger(SyncAlertEmailSender::class.java) | |||||
| open fun isSmtpConfigured(): Boolean { | |||||
| return smtpConfigIssue() == null | |||||
| } | |||||
| /** Null if OK; otherwise a short message for UI/logs. */ | |||||
| open fun smtpConfigIssue(): String? { | |||||
| return try { | |||||
| val username = settingsService.findByName(SettingNames.MAIL_SMTP_USERNAME).orElse(null)?.value | |||||
| val password = settingsService.findByName(SettingNames.MAIL_SMTP_PASSWORD).orElse(null)?.value | |||||
| val host = settingsService.findByName(SettingNames.MAIL_SMTP_HOST).orElse(null)?.value | |||||
| when { | |||||
| host.isNullOrBlank() -> "MAIL.smtp.host is empty" | |||||
| username.isNullOrBlank() -> "MAIL.smtp.username is empty" | |||||
| password.isNullOrBlank() -> "MAIL.smtp.password is empty" | |||||
| else -> null | |||||
| } | |||||
| } catch (_: Exception) { | |||||
| "MAIL.smtp settings missing in settings table" | |||||
| } | |||||
| } | |||||
| open fun resolveFromAddress(): String? = | |||||
| settingsService.findByName(SettingNames.MAIL_SMTP_USERNAME).orElse(null)?.value?.trim()?.takeIf { it.isNotEmpty() } | |||||
| open fun send(toAddresses: List<String>, subject: String, body: String) { | |||||
| require(toAddresses.isNotEmpty()) { "No email recipients" } | |||||
| smtpConfigIssue()?.let { throw IllegalStateException(it) } | |||||
| val from = resolveFromAddress() | |||||
| ?: throw IllegalStateException("MAIL.smtp.username is not set in settings") | |||||
| try { | |||||
| val sender = mailSenderService.get() | |||||
| val mimeMessage = sender.createMimeMessage() | |||||
| val helper = MimeMessageHelper(mimeMessage, false, Charsets.UTF_8.name()) | |||||
| helper.setFrom(InternetAddress(from)) | |||||
| helper.setTo(toAddresses.toTypedArray()) | |||||
| helper.setSubject(subject) | |||||
| helper.setText(body, false) | |||||
| sender.send(mimeMessage) | |||||
| logger.info("Sync alert email sent from {} to {}", from, toAddresses.joinToString()) | |||||
| } catch (e: Exception) { | |||||
| logger.error("Sync alert email failed from {}: {}", from, e.message, e) | |||||
| throw e | |||||
| } | |||||
| } | |||||
| } | |||||
| @@ -0,0 +1,138 @@ | |||||
| package com.ffii.fpsms.modules.common.alert | |||||
| import com.google.gson.Gson | |||||
| import org.slf4j.LoggerFactory | |||||
| import org.springframework.http.MediaType | |||||
| import org.springframework.util.LinkedMultiValueMap | |||||
| import org.springframework.web.reactive.function.BodyInserters | |||||
| import org.springframework.web.reactive.function.client.WebClient | |||||
| import java.time.LocalDateTime | |||||
| import java.time.format.DateTimeFormatter | |||||
| import java.util.Locale | |||||
| /** | |||||
| * Twilio Programmable Messaging (SMS or WhatsApp) — same API as: | |||||
| * | |||||
| * ``` | |||||
| * curl 'https://api.twilio.com/2010-04-01/Accounts/{AccountSid}/Messages.json' -X POST \ | |||||
| * --data-urlencode 'To=whatsapp:+85292710394' \ | |||||
| * --data-urlencode 'From=whatsapp:+14155238886' \ | |||||
| * --data-urlencode 'ContentSid=HXb5b62575e6e4ff6129ad7c8efe1f983e' \ | |||||
| * --data-urlencode 'ContentVariables={"1":"12/1","2":"3pm"}' \ | |||||
| * -u {AccountSid}:{AuthToken} | |||||
| * ``` | |||||
| */ | |||||
| class TwilioSmsSender( | |||||
| private val properties: SchedulerSyncAlertProperties, | |||||
| webClientBuilder: WebClient.Builder, | |||||
| ) : SmsSender { | |||||
| private val logger = LoggerFactory.getLogger(TwilioSmsSender::class.java) | |||||
| private val webClient = webClientBuilder.build() | |||||
| private val gson = Gson() | |||||
| override fun send( | |||||
| toNumbers: List<String>, | |||||
| body: String, | |||||
| job: String, | |||||
| code: String, | |||||
| detail: String, | |||||
| ) { | |||||
| sendRaw(toNumbers, body, buildContentVariables(job, code, detail, body)) | |||||
| } | |||||
| /** Send with explicit template variables (for connectivity tests matching a Twilio sample). */ | |||||
| fun sendWithTemplateVars(toNumbers: List<String>, var1: String, var2: String) { | |||||
| val json = gson.toJson(mapOf("1" to var1, "2" to var2)) | |||||
| sendRaw(toNumbers, "FPSMS test", json) | |||||
| } | |||||
| private fun sendRaw(toNumbers: List<String>, body: String, contentVariablesJson: String?) { | |||||
| val sms = properties.sms | |||||
| require(sms.accountSid.isNotBlank()) { "scheduler.sync-alert.sms.accountSid is required for Twilio" } | |||||
| require(sms.authToken.isNotBlank()) { "scheduler.sync-alert.sms.authToken is required for Twilio" } | |||||
| require(sms.fromNumber.isNotBlank()) { "scheduler.sync-alert.sms.fromNumber is required for Twilio" } | |||||
| val url = "https://api.twilio.com/2010-04-01/Accounts/${sms.accountSid}/Messages.json" | |||||
| val from = formatAddress(sms.fromNumber, sms.channel) | |||||
| val useTemplate = sms.contentSid.isNotBlank() | |||||
| for (to in toNumbers) { | |||||
| val form = LinkedMultiValueMap<String, String>() | |||||
| form.add("To", formatAddress(to, sms.channel)) | |||||
| form.add("From", from) | |||||
| if (useTemplate) { | |||||
| require(!contentVariablesJson.isNullOrBlank()) { "ContentVariables required when contentSid is set" } | |||||
| form.add("ContentSid", sms.contentSid) | |||||
| form.add("ContentVariables", contentVariablesJson) | |||||
| logger.info("Twilio WhatsApp ContentVariables={}", contentVariablesJson) | |||||
| } else { | |||||
| form.add("Body", body.take(1600)) | |||||
| } | |||||
| try { | |||||
| webClient.post() | |||||
| .uri(url) | |||||
| .headers { it.setBasicAuth(sms.accountSid, sms.authToken) } | |||||
| .contentType(MediaType.APPLICATION_FORM_URLENCODED) | |||||
| .body(BodyInserters.fromFormData(form)) | |||||
| .retrieve() | |||||
| .bodyToMono(String::class.java) | |||||
| .block() | |||||
| logger.info("Twilio {} sent to {}", sms.channel, to) | |||||
| } catch (e: Exception) { | |||||
| logger.error("Twilio {} failed for {}: {}", sms.channel, to, e.message, e) | |||||
| throw e | |||||
| } | |||||
| } | |||||
| } | |||||
| private fun formatAddress(number: String, channel: String): String { | |||||
| val trimmed = number.trim() | |||||
| if (!channel.equals("whatsapp", ignoreCase = true)) { | |||||
| return trimmed | |||||
| } | |||||
| return if (trimmed.startsWith("whatsapp:", ignoreCase = true)) trimmed else "whatsapp:$trimmed" | |||||
| } | |||||
| private fun buildContentVariables(job: String, code: String, detail: String, body: String): String { | |||||
| val now = LocalDateTime.now() | |||||
| val summary = buildSummary(job, code, detail, body) | |||||
| val var1 = resolveContentVar(properties.sms.contentVar1, job, code, detail, body, summary, now) | |||||
| val var2 = resolveContentVar(properties.sms.contentVar2, job, code, detail, body, summary, now) | |||||
| return gson.toJson(mapOf("1" to var1, "2" to var2)) | |||||
| } | |||||
| private fun buildSummary(job: String, code: String, detail: String, body: String): String { | |||||
| val j = job.ifBlank { "FPSMS" } | |||||
| return when { | |||||
| code.isNotBlank() && detail.isNotBlank() -> "$j $code — $detail" | |||||
| detail.isNotBlank() -> "$j — $detail" | |||||
| body.isNotBlank() -> body.removePrefix("FPSMS ").take(properties.sms.contentDetailMaxLength) | |||||
| else -> j | |||||
| }.take(properties.sms.contentDetailMaxLength) | |||||
| } | |||||
| private fun resolveContentVar( | |||||
| kind: String, | |||||
| job: String, | |||||
| code: String, | |||||
| detail: String, | |||||
| body: String, | |||||
| summary: String, | |||||
| now: LocalDateTime, | |||||
| ): String = | |||||
| when (kind.lowercase()) { | |||||
| "date" -> DATE_FORMAT.format(now) | |||||
| "time" -> TIME_FORMAT.format(now) | |||||
| "job" -> job.ifBlank { "FPSMS" } | |||||
| "code" -> code.ifBlank { "ALERT" } | |||||
| "detail" -> detail.take(properties.sms.contentDetailMaxLength).ifBlank { summary } | |||||
| "summary" -> summary | |||||
| else -> summary | |||||
| } | |||||
| companion object { | |||||
| private val DATE_FORMAT = DateTimeFormatter.ofPattern("d/M", Locale.ENGLISH) | |||||
| private val TIME_FORMAT = DateTimeFormatter.ofPattern("h:mma", Locale.ENGLISH) | |||||
| } | |||||
| } | |||||
| @@ -22,19 +22,22 @@ open class MailSenderService(private val settingsService: SettingsService) { | |||||
| val sender = JavaMailSenderImpl() | val sender = JavaMailSenderImpl() | ||||
| val props = Properties() | val props = Properties() | ||||
| val auth = config.auth ?: false | |||||
| val hasCredentials = | |||||
| !config.username.isNullOrBlank() && !config.password.isNullOrBlank() | |||||
| // Office 365 requires AUTH; treat as enabled when username + password are set. | |||||
| val auth = (config.auth == true) || hasCredentials | |||||
| if (auth) { | if (auth) { | ||||
| props["mail.smtp.timeout"] = "20000" | props["mail.smtp.timeout"] = "20000" | ||||
| props["mail.smtp.connectiontimeout"] = "10000" | props["mail.smtp.connectiontimeout"] = "10000" | ||||
| } | } | ||||
| props["mail.smtp.auth"] = auth | |||||
| // The below setting needs to be included when the SMTP has TLS Version | |||||
| props["mail.smtp.auth"] = auth.toString() | |||||
| props["mail.smtp.starttls.enable"] = "true" | props["mail.smtp.starttls.enable"] = "true" | ||||
| props["mail.smtp.starttls.required"] = "true" | |||||
| props["mail.smtp.ssl.protocols"] = "TLSv1.2" | props["mail.smtp.ssl.protocols"] = "TLSv1.2" | ||||
| sender.host = config.host | sender.host = config.host | ||||
| sender.port = config.port!! | sender.port = config.port!! | ||||
| if (auth) { | |||||
| if (auth && hasCredentials) { | |||||
| sender.username = config.username | sender.username = config.username | ||||
| sender.password = config.password | sender.password = config.password | ||||
| } | } | ||||
| @@ -5,6 +5,7 @@ import com.ffii.fpsms.modules.common.mail.entity.MailTemplateRepository | |||||
| import com.ffii.fpsms.modules.common.mail.web.models.DownloadMailTemplateResponse | import com.ffii.fpsms.modules.common.mail.web.models.DownloadMailTemplateResponse | ||||
| import com.ffii.fpsms.modules.common.mail.web.models.MailTemplateRequest | import com.ffii.fpsms.modules.common.mail.web.models.MailTemplateRequest | ||||
| import com.ffii.fpsms.modules.qc.service.QcResultService | import com.ffii.fpsms.modules.qc.service.QcResultService | ||||
| import com.ffii.fpsms.modules.qc.web.model.QcResultInfoResponse | |||||
| import com.ffii.fpsms.modules.stock.entity.StockInLineRepository | import com.ffii.fpsms.modules.stock.entity.StockInLineRepository | ||||
| import com.ffii.fpsms.modules.stock.service.InventoryLotService | import com.ffii.fpsms.modules.stock.service.InventoryLotService | ||||
| import com.itextpdf.html2pdf.ConverterProperties | import com.itextpdf.html2pdf.ConverterProperties | ||||
| @@ -14,6 +15,7 @@ import org.jsoup.Jsoup | |||||
| import org.springframework.stereotype.Service | import org.springframework.stereotype.Service | ||||
| import java.io.ByteArrayOutputStream | import java.io.ByteArrayOutputStream | ||||
| import java.math.BigDecimal | import java.math.BigDecimal | ||||
| import java.time.LocalDateTime | |||||
| import java.time.format.DateTimeFormatter | import java.time.format.DateTimeFormatter | ||||
| import kotlin.jvm.optionals.getOrNull | import kotlin.jvm.optionals.getOrNull | ||||
| @@ -33,6 +35,19 @@ open class MailTemplateService( | |||||
| val formatter = DateTimeFormatter.ofPattern("yyyy-MM-dd") | val formatter = DateTimeFormatter.ofPattern("yyyy-MM-dd") | ||||
| private fun formatFailedQcResultLine(result: QcResultInfoResponse): String { | |||||
| val label = result.name?.takeIf { it.isNotBlank() } | |||||
| ?: result.code?.takeIf { it.isNotBlank() } | |||||
| ?: "N/A" | |||||
| val criteria = result.description?.takeIf { it.isNotBlank() } ?: "" | |||||
| val base = if (criteria.isNotBlank()) "$label - $criteria" else label | |||||
| val measurementSuffix = result.measurementValue?.let { value -> | |||||
| val unit = result.unit.orEmpty() | |||||
| ";實測值:$value$unit" | |||||
| }.orEmpty() | |||||
| return base + measurementSuffix | |||||
| } | |||||
| fun allMailTemplates(): List<MailTemplate> { | fun allMailTemplates(): List<MailTemplate> { | ||||
| return mailTemplateRepository.findAllByDeletedIsFalse(); | return mailTemplateRepository.findAllByDeletedIsFalse(); | ||||
| } | } | ||||
| @@ -166,19 +181,21 @@ open class MailTemplateService( | |||||
| val filteredResult = qcResults | val filteredResult = qcResults | ||||
| .groupBy { Pair(it.stockInLineId, it.qcItemId) } | .groupBy { Pair(it.stockInLineId, it.qcItemId) } | ||||
| .mapValues { (_, group) -> | .mapValues { (_, group) -> | ||||
| group.maxByOrNull { it.recordDate } | |||||
| group.maxByOrNull { it.recordDate ?: LocalDateTime.MIN } | |||||
| } | } | ||||
| .values | .values | ||||
| .filterNotNull() | .filterNotNull() | ||||
| .filter { !it.qcPassed } | .filter { !it.qcPassed } | ||||
| if (filteredResult.isNotEmpty()) { | if (filteredResult.isNotEmpty()) { | ||||
| qcDate = formatter.format(filteredResult.maxOf { it.recordDate }) | |||||
| filteredResult.mapNotNull { it.recordDate }.maxOrNull()?.let { latest -> | |||||
| qcDate = formatter.format(latest) | |||||
| } | |||||
| val tempDoc = Jsoup.parse("") | val tempDoc = Jsoup.parse("") | ||||
| val element = tempDoc.appendElement("ul") | val element = tempDoc.appendElement("ul") | ||||
| for (result in filteredResult) { | for (result in filteredResult) { | ||||
| element.appendElement("li") | element.appendElement("li") | ||||
| .text("${result.code} - ${result.description}") | |||||
| .text(formatFailedQcResultLine(result)) | |||||
| } | } | ||||
| tempDoc.outerHtml() | tempDoc.outerHtml() | ||||
| } else { | } else { | ||||
| @@ -1,6 +1,5 @@ | |||||
| package com.ffii.fpsms.modules.common.scheduler.service | package com.ffii.fpsms.modules.common.scheduler.service | ||||
| import com.ffii.core.utils.JwtTokenUtil | |||||
| import com.ffii.fpsms.m18.service.M18DeliveryOrderService | import com.ffii.fpsms.m18.service.M18DeliveryOrderService | ||||
| import com.ffii.fpsms.m18.service.M18GrnCodeSyncService | import com.ffii.fpsms.m18.service.M18GrnCodeSyncService | ||||
| import com.ffii.fpsms.m18.service.M18MasterDataService | import com.ffii.fpsms.m18.service.M18MasterDataService | ||||
| @@ -10,6 +9,9 @@ import com.ffii.fpsms.m18.entity.SchedulerSyncLog | |||||
| import com.ffii.fpsms.m18.entity.SchedulerSyncLogRepository | import com.ffii.fpsms.m18.entity.SchedulerSyncLogRepository | ||||
| import com.ffii.fpsms.m18.model.SyncResult | import com.ffii.fpsms.m18.model.SyncResult | ||||
| import com.ffii.fpsms.modules.common.SettingNames | import com.ffii.fpsms.modules.common.SettingNames | ||||
| import com.ffii.fpsms.modules.common.alert.SchedulerSyncAlertService | |||||
| import com.ffii.fpsms.modules.jobOrder.service.JobOrderPlanStartAutoService | |||||
| import com.ffii.fpsms.modules.master.service.BomM18ShopBulkPushService | |||||
| import com.ffii.fpsms.modules.master.service.ProductionScheduleService | import com.ffii.fpsms.modules.master.service.ProductionScheduleService | ||||
| import com.ffii.fpsms.modules.stock.service.SearchCompletedDnService | import com.ffii.fpsms.modules.stock.service.SearchCompletedDnService | ||||
| import com.ffii.fpsms.modules.stock.service.InventoryLotLineService | import com.ffii.fpsms.modules.stock.service.InventoryLotLineService | ||||
| @@ -25,6 +27,7 @@ import org.springframework.stereotype.Service | |||||
| import java.time.DayOfWeek | import java.time.DayOfWeek | ||||
| import java.time.LocalDate | import java.time.LocalDate | ||||
| import java.time.LocalDateTime | import java.time.LocalDateTime | ||||
| import java.time.ZoneId | |||||
| import java.time.format.DateTimeFormatter | import java.time.format.DateTimeFormatter | ||||
| import java.util.HashMap | import java.util.HashMap | ||||
| import java.util.concurrent.ScheduledFuture | import java.util.concurrent.ScheduledFuture | ||||
| @@ -42,6 +45,15 @@ open class SchedulerService( | |||||
| @Value("\${scheduler.inventoryLotExpiry.enabled:true}") val inventoryLotExpiryEnabled: Boolean, | @Value("\${scheduler.inventoryLotExpiry.enabled:true}") val inventoryLotExpiryEnabled: Boolean, | ||||
| /** When false (default), M18 PO / DO1 / DO2 / master-data cron jobs are not registered — use true in production only. */ | /** When false (default), M18 PO / DO1 / DO2 / master-data cron jobs are not registered — use true in production only. */ | ||||
| @Value("\${scheduler.m18Sync.enabled:false}") val m18SyncEnabled: Boolean, | @Value("\${scheduler.m18Sync.enabled:false}") val m18SyncEnabled: Boolean, | ||||
| @Value("\${scheduler.jo.planStart.enabled:true}") val jobOrderPlanStartAutoEnabled: Boolean, | |||||
| @Value("\${scheduler.do1CatchUp.enabled:false}") val do1CatchUpEnabled: Boolean, | |||||
| @Value("\${scheduler.do1CatchUp.dDate:}") val do1CatchUpDDate: String, | |||||
| @Value("\${scheduler.do1CatchUp.runAt:}") val do1CatchUpRunAt: String, | |||||
| @Value("\${scheduler.do1CatchUp.skipExistingDo:true}") val do1CatchUpSkipExistingDo: Boolean, | |||||
| @Value("\${scheduler.do1CatchUp2.enabled:false}") val do1CatchUp2Enabled: Boolean, | |||||
| @Value("\${scheduler.do1CatchUp2.dDate:}") val do1CatchUp2DDate: String, | |||||
| @Value("\${scheduler.do1CatchUp2.runAt:}") val do1CatchUp2RunAt: String, | |||||
| @Value("\${scheduler.do1CatchUp2.skipExistingDo:true}") val do1CatchUp2SkipExistingDo: Boolean, | |||||
| val settingsService: SettingsService, | val settingsService: SettingsService, | ||||
| /** | /** | ||||
| * Lookback window for GRN code sync: rows with `created` from **start of (today − N days)** through **now**, | * Lookback window for GRN code sync: rows with `created` from **start of (today − N days)** through **now**, | ||||
| @@ -56,8 +68,25 @@ open class SchedulerService( | |||||
| val searchCompletedDnService: SearchCompletedDnService, | val searchCompletedDnService: SearchCompletedDnService, | ||||
| val m18GrnCodeSyncService: M18GrnCodeSyncService, | val m18GrnCodeSyncService: M18GrnCodeSyncService, | ||||
| val inventoryLotLineService: InventoryLotLineService, | val inventoryLotLineService: InventoryLotLineService, | ||||
| val jobOrderPlanStartAutoService: JobOrderPlanStartAutoService, | |||||
| private val bomM18ShopBulkPushService: BomM18ShopBulkPushService, | |||||
| private val schedulerSyncAlertService: SchedulerSyncAlertService, | |||||
| @Value("\${scheduler.sync-alert.check-cron:0 */15 * * * *}") private val syncAlertCheckCron: String, | |||||
| @Value("\${scheduler.sync-alert.enabled:false}") private val syncAlertEnabled: Boolean, | |||||
| ) { | ) { | ||||
| var logger: Logger = LoggerFactory.getLogger(JwtTokenUtil::class.java) | |||||
| companion object { | |||||
| /** DO2: Spring 6-field cron default and M18 `lastModifyDate` upper bound hour (1pm local). */ | |||||
| const val DO2_MODIFIED_TO_HOUR: Int = 13 | |||||
| const val DO2_DEFAULT_CRON: String = "0 0 13 * * *" | |||||
| /** Default 23:00 daily — BOM → M18 udfBomForShop for all BOMs ([SettingNames.SCHEDULE_M18_BOM_SHOP]). */ | |||||
| const val M18_BOM_SHOP_DEFAULT_CRON: String = "0 0 23 * * *" | |||||
| /** Daily 00:00:15 — process job orders whose planStart was yesterday. */ | |||||
| const val JO_PLAN_START_DEFAULT_CRON: String = "15 0 0 * * *" | |||||
| } | |||||
| /** Class logger (was incorrectly wired to JwtTokenUtil, so all scheduler lines showed under that category). */ | |||||
| private val logger: Logger = LoggerFactory.getLogger(SchedulerService::class.java) | |||||
| val dataStringFormat = DateTimeFormatter.ofPattern("yyyy-MM-dd") | val dataStringFormat = DateTimeFormatter.ofPattern("yyyy-MM-dd") | ||||
| val dateTimeStringFormat = DateTimeFormatter.ofPattern("yyyy-MM-dd HH:mm:ss") | val dateTimeStringFormat = DateTimeFormatter.ofPattern("yyyy-MM-dd HH:mm:ss") | ||||
| val defaultCronExpression = "0 0 2 31 2 *"; | val defaultCronExpression = "0 0 2 31 2 *"; | ||||
| @@ -70,6 +99,8 @@ open class SchedulerService( | |||||
| var scheduledM18Do1Sat: ScheduledFuture<*>? = null | var scheduledM18Do1Sat: ScheduledFuture<*>? = null | ||||
| var scheduledM18Do2: ScheduledFuture<*>? = null | var scheduledM18Do2: ScheduledFuture<*>? = null | ||||
| var scheduledM18BomShop: ScheduledFuture<*>? = null | |||||
| @Volatile | @Volatile | ||||
| var scheduledM18Master: ScheduledFuture<*>? = null | var scheduledM18Master: ScheduledFuture<*>? = null | ||||
| @@ -80,6 +111,13 @@ open class SchedulerService( | |||||
| var scheduledGrnCodeSync: ScheduledFuture<*>? = null | var scheduledGrnCodeSync: ScheduledFuture<*>? = null | ||||
| var scheduledInventoryLotExpiry: ScheduledFuture<*>? = null | var scheduledInventoryLotExpiry: ScheduledFuture<*>? = null | ||||
| var scheduledJobOrderPlanStart: ScheduledFuture<*>? = null | |||||
| var scheduledDo1CatchUp: ScheduledFuture<*>? = null | |||||
| var scheduledDo1CatchUp2: ScheduledFuture<*>? = null | |||||
| var scheduledSyncAlert: ScheduledFuture<*>? = null | |||||
| //@Volatile | //@Volatile | ||||
| //var scheduledRoughProd: ScheduledFuture<*>? = null | //var scheduledRoughProd: ScheduledFuture<*>? = null | ||||
| @@ -165,14 +203,198 @@ open class SchedulerService( | |||||
| scheduleM18Po(); | scheduleM18Po(); | ||||
| scheduleM18Do1(); | scheduleM18Do1(); | ||||
| scheduleM18Do2(); | scheduleM18Do2(); | ||||
| scheduleM18BomShop(); | |||||
| scheduleM18MasterData(); | scheduleM18MasterData(); | ||||
| schedulePostCompletedDnGrn(); | schedulePostCompletedDnGrn(); | ||||
| scheduleGrnCodeSync(); | scheduleGrnCodeSync(); | ||||
| scheduleInventoryLotExpiry(); | scheduleInventoryLotExpiry(); | ||||
| scheduleJobOrderPlanStartAuto(); | |||||
| scheduleDo1CatchUpOnce(); | |||||
| scheduleSyncAlertWatchdog(); | |||||
| //scheduleRoughProd(); | //scheduleRoughProd(); | ||||
| //scheduleDetailedProd(); | //scheduleDetailedProd(); | ||||
| } | } | ||||
| /** Periodic check of scheduler_sync_log; sends SMS when M18 sync rules fail (production). */ | |||||
| fun scheduleSyncAlertWatchdog() { | |||||
| scheduledSyncAlert?.cancel(false) | |||||
| scheduledSyncAlert = null | |||||
| if (!m18SyncEnabled || !syncAlertEnabled) { | |||||
| logger.info( | |||||
| "M18 sync alert watchdog disabled (m18Sync={}, sync-alert.enabled={})", | |||||
| m18SyncEnabled, | |||||
| syncAlertEnabled, | |||||
| ) | |||||
| return | |||||
| } | |||||
| var cron = syncAlertCheckCron | |||||
| if (!isValidCronExpression(cron)) { | |||||
| cron = "0 */15 * * * *" | |||||
| } | |||||
| scheduledSyncAlert = taskScheduler.schedule( | |||||
| { schedulerSyncAlertService.runChecks() }, | |||||
| CronTrigger(cron), | |||||
| ) | |||||
| logger.info("Scheduled M18 sync alert watchdog: {}", cron) | |||||
| } | |||||
| open fun runSyncAlertCheckNow(): List<String> = schedulerSyncAlertService.runChecks() | |||||
| open fun sendSyncAlertTestWhatsApp(var1: String?, var2: String?): String = | |||||
| schedulerSyncAlertService.sendTestWhatsApp(var1, var2) | |||||
| open fun sendSyncAlertTestEmail(message: String?, subject: String?): String = | |||||
| schedulerSyncAlertService.sendTestEmail(message, subject) | |||||
| /** | |||||
| * One-time DO1 catch-up jobs for fixed dDates (e.g. missed 15/6 → dDate 17/6, 16/6 → dDate 18/6). | |||||
| * Requires [m18SyncEnabled] (production only). Config: scheduler.do1CatchUp / do1CatchUp2 in application-prod.yml. | |||||
| * Completed dDates are stored comma-separated in [SettingNames.SCHEDULE_M18_DO1_CATCHUP_DONE_DDATE]. | |||||
| */ | |||||
| fun scheduleDo1CatchUpOnce() { | |||||
| scheduledDo1CatchUp?.cancel(false) | |||||
| scheduledDo1CatchUp = null | |||||
| scheduledDo1CatchUp2?.cancel(false) | |||||
| scheduledDo1CatchUp2 = null | |||||
| if (!m18SyncEnabled) { | |||||
| logger.info("DO1 catch-up schedulers disabled (scheduler.m18Sync.enabled=false; production only)") | |||||
| return | |||||
| } | |||||
| scheduledDo1CatchUp = scheduleOneDo1CatchUp( | |||||
| scheduledDo1CatchUp, | |||||
| do1CatchUpEnabled, | |||||
| do1CatchUpDDate, | |||||
| do1CatchUpRunAt, | |||||
| do1CatchUpSkipExistingDo, | |||||
| "do1CatchUp", | |||||
| ) | |||||
| scheduledDo1CatchUp2 = scheduleOneDo1CatchUp( | |||||
| scheduledDo1CatchUp2, | |||||
| do1CatchUp2Enabled, | |||||
| do1CatchUp2DDate, | |||||
| do1CatchUp2RunAt, | |||||
| do1CatchUp2SkipExistingDo, | |||||
| "do1CatchUp2", | |||||
| ) | |||||
| } | |||||
| private fun scheduleOneDo1CatchUp( | |||||
| existing: ScheduledFuture<*>?, | |||||
| enabled: Boolean, | |||||
| dDateRaw: String, | |||||
| runAtRaw: String, | |||||
| skipExistingDo: Boolean, | |||||
| configKey: String, | |||||
| ): ScheduledFuture<*>? { | |||||
| existing?.cancel(false) | |||||
| if (!enabled) { | |||||
| return null | |||||
| } | |||||
| val dDateStr = dDateRaw.trim() | |||||
| val runAtStr = runAtRaw.trim() | |||||
| if (dDateStr.isEmpty() || runAtStr.isEmpty()) { | |||||
| logger.warn("{} enabled but dDate or runAt is blank — skipped", configKey) | |||||
| return null | |||||
| } | |||||
| val dDate = try { | |||||
| LocalDate.parse(dDateStr) | |||||
| } catch (e: Exception) { | |||||
| logger.error("Invalid scheduler.{}.dDate={}", configKey, dDateStr) | |||||
| return null | |||||
| } | |||||
| val runAt = try { | |||||
| LocalDateTime.parse(runAtStr) | |||||
| } catch (e: Exception) { | |||||
| logger.error("Invalid scheduler.{}.runAt={}", configKey, runAtStr) | |||||
| return null | |||||
| } | |||||
| if (isDo1CatchUpAlreadyDone(dDate)) { | |||||
| logger.info("DO1 catch-up ({}) already completed for dDate={}", configKey, dDate) | |||||
| return null | |||||
| } | |||||
| val now = LocalDateTime.now() | |||||
| if (!runAt.isAfter(now)) { | |||||
| logger.warn( | |||||
| "DO1 catch-up ({}) runAt={} is not in the future (now={}); use GET /scheduler/trigger/do1-catchup?dDate={}", | |||||
| configKey, | |||||
| runAt, | |||||
| now, | |||||
| dDate, | |||||
| ) | |||||
| return null | |||||
| } | |||||
| val scheduled = taskScheduler.schedule( | |||||
| { runDo1CatchUp(dDate, skipExistingDo) }, | |||||
| runAt.atZone(ZoneId.systemDefault()).toInstant(), | |||||
| ) | |||||
| logger.info( | |||||
| "Scheduled one-time DO1 catch-up ({}) for dDate={} at {} skipExistingDo={}", | |||||
| configKey, | |||||
| dDate, | |||||
| runAt, | |||||
| skipExistingDo, | |||||
| ) | |||||
| return scheduled | |||||
| } | |||||
| private fun getDo1CatchUpDoneDDateSet(): Set<String> { | |||||
| val done = settingsService.findByName(SettingNames.SCHEDULE_M18_DO1_CATCHUP_DONE_DDATE).getOrNull()?.value | |||||
| ?: return emptySet() | |||||
| return done.split(",").map { it.trim() }.filter { it.isNotEmpty() }.toSet() | |||||
| } | |||||
| private fun isDo1CatchUpAlreadyDone(dDate: LocalDate): Boolean { | |||||
| return dDate.toString() in getDo1CatchUpDoneDDateSet() | |||||
| } | |||||
| private fun markDo1CatchUpDone(dDate: LocalDate) { | |||||
| try { | |||||
| val name = SettingNames.SCHEDULE_M18_DO1_CATCHUP_DONE_DDATE | |||||
| val updated = (getDo1CatchUpDoneDDateSet() + dDate.toString()).sorted().joinToString(",") | |||||
| val existing = settingsService.findByName(name).orElse(null) | |||||
| if (existing != null) { | |||||
| settingsService.update(name, updated) | |||||
| } else { | |||||
| val setting = Settings() | |||||
| setting.name = name | |||||
| setting.value = updated | |||||
| setting.category = "SCHEDULE" | |||||
| setting.type = Settings.TYPE_STRING | |||||
| settingsService.save(setting) | |||||
| } | |||||
| } catch (e: Exception) { | |||||
| logger.error("Failed to persist DO1 catch-up done marker for dDate={}: {}", dDate, e.message, e) | |||||
| } | |||||
| } | |||||
| open fun runDo1CatchUp(dDate: LocalDate, skipExistingDo: Boolean = true) { | |||||
| if (!m18SyncEnabled) { | |||||
| logger.warn( | |||||
| "DO1 catch-up refused for dDate={}: production only (scheduler.m18Sync.enabled=false)", | |||||
| dDate, | |||||
| ) | |||||
| return | |||||
| } | |||||
| if (isDo1CatchUpAlreadyDone(dDate)) { | |||||
| logger.info("DO1 catch-up already completed for dDate={}", dDate) | |||||
| return | |||||
| } | |||||
| try { | |||||
| getM18Dos1ForDDate(dDate, syncType = "DO1_CATCHUP", skipExistingDo = skipExistingDo) | |||||
| } catch (e: Exception) { | |||||
| logger.error("DO1 catch-up sync failed for dDate={}: {}", dDate, e.message, e) | |||||
| return | |||||
| } | |||||
| markDo1CatchUpDone(dDate) | |||||
| logger.info("DO1 catch-up completed for dDate={}", dDate) | |||||
| } | |||||
| // Scheduler | // Scheduler | ||||
| // --------------------------- FP-MTMS --------------------------- // | // --------------------------- FP-MTMS --------------------------- // | ||||
| //fun scheduleRoughProd() { | //fun scheduleRoughProd() { | ||||
| @@ -206,7 +428,19 @@ open class SchedulerService( | |||||
| logger.info("M18 DO2 scheduler disabled (scheduler.m18Sync.enabled=false)") | logger.info("M18 DO2 scheduler disabled (scheduler.m18Sync.enabled=false)") | ||||
| return | return | ||||
| } | } | ||||
| scheduledM18Do2 = commonSchedule(scheduledM18Do2, SettingNames.SCHEDULE_M18_DO2, ::getM18Dos2) | |||||
| scheduledM18Do2 = commonSchedule(scheduledM18Do2, SettingNames.SCHEDULE_M18_DO2, DO2_DEFAULT_CRON, ::getM18Dos2) | |||||
| } | |||||
| /** Daily push FPSMS BOMs → M18; cron from settings [SettingNames.SCHEDULE_M18_BOM_SHOP] ([M18_BOM_SHOP_DEFAULT_CRON]); requires scheduler.m18Sync.enabled. */ | |||||
| fun scheduleM18BomShop() { | |||||
| if (!m18SyncEnabled) { | |||||
| scheduledM18BomShop?.cancel(false) | |||||
| scheduledM18BomShop = null | |||||
| logger.info("M18 BOM Shop scheduler disabled (scheduler.m18Sync.enabled=false)") | |||||
| return | |||||
| } | |||||
| scheduledM18BomShop = | |||||
| commonSchedule(scheduledM18BomShop, SettingNames.SCHEDULE_M18_BOM_SHOP, M18_BOM_SHOP_DEFAULT_CRON, ::getM18BomShopPushAllBoms) | |||||
| } | } | ||||
| fun scheduleM18MasterData() { | fun scheduleM18MasterData() { | ||||
| @@ -286,6 +520,42 @@ open class SchedulerService( | |||||
| ) | ) | ||||
| } | } | ||||
| /** | |||||
| * Job order plan-start batch at 00:00:15 daily (yesterday plan day). | |||||
| * Set scheduler.jo.planStart.enabled=false to disable. | |||||
| */ | |||||
| fun scheduleJobOrderPlanStartAuto() { | |||||
| if (!jobOrderPlanStartAutoEnabled) { | |||||
| scheduledJobOrderPlanStart?.cancel(false) | |||||
| scheduledJobOrderPlanStart = null | |||||
| logger.info("Job order plan-start auto scheduler disabled (scheduler.jo.planStart.enabled=false)") | |||||
| return | |||||
| } | |||||
| scheduledJobOrderPlanStart = commonSchedule( | |||||
| scheduledJobOrderPlanStart, | |||||
| SettingNames.SCHEDULE_JO_PLAN_START, | |||||
| JO_PLAN_START_DEFAULT_CRON, | |||||
| ::runJobOrderPlanStartAuto, | |||||
| ) | |||||
| logger.info("Scheduled job order plan-start auto (default cron={})", JO_PLAN_START_DEFAULT_CRON) | |||||
| } | |||||
| open fun runJobOrderPlanStartAuto() { | |||||
| try { | |||||
| val report = jobOrderPlanStartAutoService.runAutoProcess(LocalDateTime.now()) | |||||
| logger.info( | |||||
| "Scheduler - Job order plan-start auto: candidates={}, hidden={}, rescheduled={}, skipped={}, errors={}", | |||||
| report.candidates, | |||||
| report.hidden, | |||||
| report.rescheduled, | |||||
| report.skipped, | |||||
| report.errors, | |||||
| ) | |||||
| } catch (e: Exception) { | |||||
| logger.error("Scheduler - Job order plan-start auto failed: ${e.message}", e) | |||||
| } | |||||
| } | |||||
| /** Mark expired inventory lot lines as unavailable daily. Set scheduler.inventoryLotExpiry.enabled=false to disable. */ | /** Mark expired inventory lot lines as unavailable daily. Set scheduler.inventoryLotExpiry.enabled=false to disable. */ | ||||
| fun scheduleInventoryLotExpiry() { | fun scheduleInventoryLotExpiry() { | ||||
| if (!inventoryLotExpiryEnabled) { | if (!inventoryLotExpiryEnabled) { | ||||
| @@ -410,24 +680,42 @@ open class SchedulerService( | |||||
| open fun getM18Dos1() { | open fun getM18Dos1() { | ||||
| logger.info("DO Scheduler 1 - DO") | logger.info("DO Scheduler 1 - DO") | ||||
| val currentTime = LocalDateTime.now() | |||||
| val today = currentTime.toLocalDate().atStartOfDay() | |||||
| val twoDaysLater = today.plusDays(2L) | |||||
| var requestDO = M18CommonRequest( | |||||
| dDateTo = twoDaysLater.format(dateTimeStringFormat), | |||||
| dDateFrom = twoDaysLater.format(dateTimeStringFormat) | |||||
| ) | |||||
| val result = m18DeliveryOrderService.saveDeliveryOrders(requestDO); | |||||
| val today = LocalDateTime.now().toLocalDate().atStartOfDay() | |||||
| val dDate = today.plusDays(2L).toLocalDate() | |||||
| getM18Dos1ForDDate(dDate, syncType = "DO1") | |||||
| } | |||||
| saveSyncLog( | |||||
| type = "DO1", | |||||
| status = "SUCCESS", | |||||
| result = result, | |||||
| start = currentTime | |||||
| /** DO1 sync for an explicit delivery date (normal DO1 uses run-day + 2 days). */ | |||||
| open fun getM18Dos1ForDDate( | |||||
| dDate: LocalDate, | |||||
| syncType: String = "DO1", | |||||
| skipExistingDo: Boolean = syncType == "DO1_CATCHUP", | |||||
| ) { | |||||
| logger.info("{} sync for dDate={} skipExistingDo={}", syncType, dDate, skipExistingDo) | |||||
| val currentTime = LocalDateTime.now() | |||||
| val dDateStart = dDate.atStartOfDay() | |||||
| val requestDO = M18CommonRequest( | |||||
| dDateTo = dDateStart.format(dateTimeStringFormat), | |||||
| dDateFrom = dDateStart.format(dateTimeStringFormat), | |||||
| ) | ) | ||||
| try { | |||||
| val result = m18DeliveryOrderService.saveDeliveryOrders(requestDO, skipExistingDo = skipExistingDo) | |||||
| saveSyncLog( | |||||
| type = syncType, | |||||
| status = "SUCCESS", | |||||
| result = result?.copy(query = "dDate=$dDate ${result.query}".trim()), | |||||
| start = currentTime, | |||||
| ) | |||||
| } catch (e: Exception) { | |||||
| logger.error("{} sync failed for dDate={}: {}", syncType, dDate, e.message, e) | |||||
| saveSyncLog( | |||||
| type = syncType, | |||||
| status = "FAILED", | |||||
| error = e.message, | |||||
| start = currentTime, | |||||
| ) | |||||
| throw e | |||||
| } | |||||
| } | } | ||||
| private fun saveSyncLog(type: String, status: String, result: SyncResult? = null, error: String? = null, start: LocalDateTime) { | private fun saveSyncLog(type: String, status: String, result: SyncResult? = null, error: String? = null, start: LocalDateTime) { | ||||
| @@ -455,7 +743,7 @@ open class SchedulerService( | |||||
| val ysd = today.minusDays(1L) | val ysd = today.minusDays(1L) | ||||
| val tmr = today.plusDays(1L) | val tmr = today.plusDays(1L) | ||||
| // Default: lastModified from yesterday 19:00 (aligns with nightly DO2 expectation). | |||||
| // Default: lastModified from yesterday 19:00 through today's DO2 run hour (1pm; aligns with SCHEDULE.m18.do2). | |||||
| // On Sunday, yesterday is Saturday: use 03:00 instead so we include DO changed after Sat 03:10 DO1 | // On Sunday, yesterday is Saturday: use 03:00 instead so we include DO changed after Sat 03:10 DO1 | ||||
| // (otherwise Sat 03:00–18:59 would be skipped until a much later sync). | // (otherwise Sat 03:00–18:59 would be skipped until a much later sync). | ||||
| val isSundayDo2 = runDate.dayOfWeek == DayOfWeek.SUNDAY | val isSundayDo2 = runDate.dayOfWeek == DayOfWeek.SUNDAY | ||||
| @@ -465,21 +753,21 @@ open class SchedulerService( | |||||
| ysd.withHour(19).withMinute(0).withSecond(0) | ysd.withHour(19).withMinute(0).withSecond(0) | ||||
| } | } | ||||
| // Set to 11:00:00 of today | |||||
| val todayEleven = today.withHour(11).withMinute(0).withSecond(0) | |||||
| val modifiedDateToEnd = | |||||
| today.withHour(DO2_MODIFIED_TO_HOUR).withMinute(0).withSecond(0) | |||||
| logger.info( | logger.info( | ||||
| "DO2 modifiedDateFrom={} ({}), modifiedDateTo={}", | "DO2 modifiedDateFrom={} ({}), modifiedDateTo={}", | ||||
| modifiedFromStart.format(dateTimeStringFormat), | modifiedFromStart.format(dateTimeStringFormat), | ||||
| if (isSundayDo2) "Sunday window from Sat 03:00" else "from yesterday 19:00", | if (isSundayDo2) "Sunday window from Sat 03:00" else "from yesterday 19:00", | ||||
| todayEleven.format(dateTimeStringFormat), | |||||
| modifiedDateToEnd.format(dateTimeStringFormat), | |||||
| ) | ) | ||||
| val requestDO = M18CommonRequest( | val requestDO = M18CommonRequest( | ||||
| // These will now produce "yyyy-MM-dd HH:mm:ss" | // These will now produce "yyyy-MM-dd HH:mm:ss" | ||||
| dDateTo = tmr.format(dateTimeStringFormat), // e.g. 2026-01-19 00:00:00 | dDateTo = tmr.format(dateTimeStringFormat), // e.g. 2026-01-19 00:00:00 | ||||
| dDateFrom = tmr.format(dateTimeStringFormat), // e.g. 2026-01-19 00:00:00 | dDateFrom = tmr.format(dateTimeStringFormat), // e.g. 2026-01-19 00:00:00 | ||||
| modifiedDateTo = todayEleven.format(dateTimeStringFormat), // 2026-01-18 11:00:00 | |||||
| modifiedDateTo = modifiedDateToEnd.format(dateTimeStringFormat), | |||||
| modifiedDateFrom = modifiedFromStart.format(dateTimeStringFormat), | modifiedDateFrom = modifiedFromStart.format(dateTimeStringFormat), | ||||
| ) | ) | ||||
| @@ -493,6 +781,44 @@ open class SchedulerService( | |||||
| ) | ) | ||||
| } | } | ||||
| open fun getM18BomShopPushAllBoms() { | |||||
| val currentTime = LocalDateTime.now() | |||||
| try { | |||||
| val summary = bomM18ShopBulkPushService.pushAllBomsToM18ShopIfAllowed() | |||||
| val status = if (summary.skippedBecauseFeatureDisabled) "SKIPPED" else "SUCCESS" | |||||
| saveSyncLog( | |||||
| type = "M18_BOM_SHOP", | |||||
| status = status, | |||||
| result = | |||||
| SyncResult( | |||||
| totalProcessed = summary.totalProcessed, | |||||
| totalSuccess = summary.synced, | |||||
| totalFail = summary.notSynced, | |||||
| query = summary.toLogQuery(), | |||||
| ), | |||||
| start = currentTime, | |||||
| ) | |||||
| if (summary.skippedBecauseFeatureDisabled) { | |||||
| logger.debug( | |||||
| "M18 BOM Shop bulk skipped ({}) — set {}={} to run pushes", | |||||
| summary.toLogQuery(), | |||||
| SettingNames.M18_BOM_SHOP_SYNC_ENABLED, | |||||
| Settings.VALUE_BOOLEAN_TRUE, | |||||
| ) | |||||
| } else { | |||||
| logger.info("M18 BOM Shop batch done: {}", summary.toLogQuery()) | |||||
| } | |||||
| } catch (e: Exception) { | |||||
| logger.error("M18 BOM Shop batch failed: ${e.message}", e) | |||||
| saveSyncLog( | |||||
| type = "M18_BOM_SHOP", | |||||
| status = "FAILED", | |||||
| error = e.message, | |||||
| start = currentTime, | |||||
| ) | |||||
| } | |||||
| } | |||||
| open fun getPostCompletedDnAndProcessGrn( | open fun getPostCompletedDnAndProcessGrn( | ||||
| receiptDate: java.time.LocalDate? = null, | receiptDate: java.time.LocalDate? = null, | ||||
| skipFirst: Int = 0, | skipFirst: Int = 0, | ||||
| @@ -43,12 +43,35 @@ class SchedulerController( | |||||
| return "M18 DO1 Sync Triggered Successfully" | return "M18 DO1 Sync Triggered Successfully" | ||||
| } | } | ||||
| /** Manual DO1 catch-up for a fixed dDate (production only). Skips existing local DOs by default. */ | |||||
| @GetMapping("/trigger/do1-catchup") | |||||
| fun triggerDo1CatchUp( | |||||
| @RequestParam @DateTimeFormat(iso = DateTimeFormat.ISO.DATE) dDate: LocalDate, | |||||
| @RequestParam(required = false, defaultValue = "true") skipExistingDo: Boolean = true, | |||||
| ): String { | |||||
| schedulerService.runDo1CatchUp(dDate, skipExistingDo = skipExistingDo) | |||||
| return "M18 DO1 catch-up triggered for dDate=$dDate skipExistingDo=$skipExistingDo" | |||||
| } | |||||
| @GetMapping("/trigger/do2") | @GetMapping("/trigger/do2") | ||||
| fun triggerDo2(): String { | fun triggerDo2(): String { | ||||
| schedulerService.getM18Dos2() | schedulerService.getM18Dos2() | ||||
| return "M18 DO2 Sync Triggered Successfully" | return "M18 DO2 Sync Triggered Successfully" | ||||
| } | } | ||||
| /** Manual test: push all FPSMS BOMs to M18 udfBomForShop ([SettingNames.M18_BOM_SHOP_SYNC_ENABLED] must still be true). */ | |||||
| @GetMapping("/trigger/bom-shop-sync-all") | |||||
| fun triggerBomShopSyncAll(): String { | |||||
| schedulerService.getM18BomShopPushAllBoms() | |||||
| return "M18 BOM Shop (all BOMs) sync triggered (see scheduler_sync_log type M18_BOM_SHOP)" | |||||
| } | |||||
| @GetMapping("/updateSetting/bomShopCron") | |||||
| fun scheduleBomShop(@RequestParam @Valid newCron: String) { | |||||
| settingsService.update(SettingNames.SCHEDULE_M18_BOM_SHOP, newCron) | |||||
| schedulerService.scheduleM18BomShop() | |||||
| } | |||||
| @GetMapping("/trigger/master-data") | @GetMapping("/trigger/master-data") | ||||
| fun triggerMasterData(): String { | fun triggerMasterData(): String { | ||||
| schedulerService.getM18MasterData() | schedulerService.getM18MasterData() | ||||
| @@ -88,4 +111,36 @@ class SchedulerController( | |||||
| schedulerService.init() | schedulerService.init() | ||||
| return "Cron Schedules Refreshed from Database" | return "Cron Schedules Refreshed from Database" | ||||
| } | } | ||||
| @GetMapping("/trigger/jo-plan-start") | |||||
| fun triggerJoPlanStart(): String { | |||||
| schedulerService.runJobOrderPlanStartAuto() | |||||
| return "Job order plan-start auto triggered" | |||||
| } | |||||
| /** Run M18 sync health checks now (same as periodic SMS watchdog). Returns alert messages (empty = OK). */ | |||||
| @GetMapping("/trigger/sync-alert-check") | |||||
| fun triggerSyncAlertCheck(): List<String> { | |||||
| return schedulerService.runSyncAlertCheckNow() | |||||
| } | |||||
| /** | |||||
| * Send a Twilio WhatsApp template test (same API as Twilio console curl). | |||||
| * Example: ?var1=12/1&var2=3pm — omit params to use today's date and current time. | |||||
| */ | |||||
| @GetMapping("/trigger/sync-alert-test-whatsapp") | |||||
| fun triggerSyncAlertTestWhatsApp( | |||||
| @RequestParam(required = false) var1: String?, | |||||
| @RequestParam(required = false) var2: String?, | |||||
| ): String { | |||||
| return schedulerService.sendSyncAlertTestWhatsApp(var1, var2) | |||||
| } | |||||
| /** Test Office 365 / SMTP email (uses MAIL.smtp.* from settings). Optional message and subject. */ | |||||
| @GetMapping("/trigger/sync-alert-test-email") | |||||
| fun triggerSyncAlertTestEmail( | |||||
| @RequestParam(required = false) message: String?, | |||||
| @RequestParam(required = false) subject: String?, | |||||
| ): String { | |||||
| return schedulerService.sendSyncAlertTestEmail(message, subject) | |||||
| } | |||||
| } | } | ||||
| @@ -62,4 +62,8 @@ open class DeliveryOrder: BaseEntity<Long>() { | |||||
| @Column(name = "m18BeId") | @Column(name = "m18BeId") | ||||
| open var m18BeId: Long? = null | open var m18BeId: Long? = null | ||||
| /** 加單:由 M18「加單」專用同步標記;一般 DO 為 false */ | |||||
| @Column(name = "isExtra", nullable = false) | |||||
| open var isExtra: Boolean = false | |||||
| } | } | ||||
| @@ -61,6 +61,10 @@ class DeliveryOrderPickOrder { | |||||
| @Column(name = "cartonQty") | @Column(name = "cartonQty") | ||||
| var cartonQty: Int? = null | var cartonQty: Int? = null | ||||
| /** Merge lineage: equals own [id] until soft-deleted into a successor [TI-M] header. */ | |||||
| @Column(name = "relationshipId") | |||||
| var relationshipId: Long? = null | |||||
| @CreationTimestamp | @CreationTimestamp | ||||
| @Column(name = "created") | @Column(name = "created") | ||||
| var created: LocalDateTime? = null | var created: LocalDateTime? = null | ||||
| @@ -15,6 +15,8 @@ import com.ffii.fpsms.modules.deliveryOrder.web.models.* | |||||
| import com.ffii.fpsms.modules.deliveryOrder.entity.models.* | import com.ffii.fpsms.modules.deliveryOrder.entity.models.* | ||||
| @Repository | @Repository | ||||
| interface DeliveryOrderRepository : AbstractRepository<DeliveryOrder, Long> { | interface DeliveryOrderRepository : AbstractRepository<DeliveryOrder, Long> { | ||||
| fun existsByCodeAndDeletedIsFalse(code: String): Boolean | |||||
| @Query(""" | @Query(""" | ||||
| select d from DeliveryOrder d | select d from DeliveryOrder d | ||||
| where d.deleted = false | where d.deleted = false | ||||
| @@ -109,6 +111,7 @@ fun searchDoLite( | |||||
| and (:status is null or d.status = :status) | and (:status is null or d.status = :status) | ||||
| and (:etaStart is null or d.estimatedArrivalDate >= :etaStart) | and (:etaStart is null or d.estimatedArrivalDate >= :etaStart) | ||||
| and (:etaEnd is null or d.estimatedArrivalDate < :etaEnd) | and (:etaEnd is null or d.estimatedArrivalDate < :etaEnd) | ||||
| and (:isExtra is null or d.isExtra = :isExtra) | |||||
| order by d.id desc | order by d.id desc | ||||
| """) | """) | ||||
| fun searchDoLitePage( | fun searchDoLitePage( | ||||
| @@ -117,6 +120,7 @@ fun searchDoLitePage( | |||||
| @Param("status") status: DeliveryOrderStatus?, | @Param("status") status: DeliveryOrderStatus?, | ||||
| @Param("etaStart") etaStart: LocalDateTime?, | @Param("etaStart") etaStart: LocalDateTime?, | ||||
| @Param("etaEnd") etaEnd: LocalDateTime?, | @Param("etaEnd") etaEnd: LocalDateTime?, | ||||
| @Param("isExtra") isExtra: Boolean?, | |||||
| pageable: Pageable | pageable: Pageable | ||||
| ): Page<DeliveryOrderInfoLite> | ): Page<DeliveryOrderInfoLite> | ||||
| @@ -132,6 +136,7 @@ fun searchDoLitePage( | |||||
| and (:status is null or d.status = :status) | and (:status is null or d.status = :status) | ||||
| and (:etaStart is null or d.estimatedArrivalDate >= :etaStart) | and (:etaStart is null or d.estimatedArrivalDate >= :etaStart) | ||||
| and (:etaEnd is null or d.estimatedArrivalDate < :etaEnd) | and (:etaEnd is null or d.estimatedArrivalDate < :etaEnd) | ||||
| and (:isExtra is null or d.isExtra = :isExtra) | |||||
| and d.supplier is not null | and d.supplier is not null | ||||
| and d.supplier.code in :allowedSupplierCodes | and d.supplier.code in :allowedSupplierCodes | ||||
| order by d.id desc | order by d.id desc | ||||
| @@ -143,6 +148,7 @@ fun searchDoLitePageWithSupplierCodes( | |||||
| @Param("status") status: DeliveryOrderStatus?, | @Param("status") status: DeliveryOrderStatus?, | ||||
| @Param("etaStart") etaStart: LocalDateTime?, | @Param("etaStart") etaStart: LocalDateTime?, | ||||
| @Param("etaEnd") etaEnd: LocalDateTime?, | @Param("etaEnd") etaEnd: LocalDateTime?, | ||||
| @Param("isExtra") isExtra: Boolean?, | |||||
| @Param("allowedSupplierCodes") allowedSupplierCodes: List<String>, | @Param("allowedSupplierCodes") allowedSupplierCodes: List<String>, | ||||
| pageable: Pageable, | pageable: Pageable, | ||||
| ): Page<DeliveryOrderInfoLite> | ): Page<DeliveryOrderInfoLite> | ||||
| @@ -16,6 +16,8 @@ import java.time.LocalDate | |||||
| @Repository | @Repository | ||||
| interface DoPickOrderRecordRepository : JpaRepository<DoPickOrderRecord, Long> { | interface DoPickOrderRecordRepository : JpaRepository<DoPickOrderRecord, Long> { | ||||
| fun findByPickOrderId(pickOrderId: Long): List<DoPickOrderRecord> | fun findByPickOrderId(pickOrderId: Long): List<DoPickOrderRecord> | ||||
| fun findByDoOrderIdAndDeletedFalse(doOrderId: Long): List<DoPickOrderRecord> | |||||
| fun findByTicketNoStartingWith(ticketPrefix: String): List<DoPickOrderRecord> | fun findByTicketNoStartingWith(ticketPrefix: String): List<DoPickOrderRecord> | ||||
| @Query(""" | @Query(""" | ||||
| @@ -21,6 +21,8 @@ interface DoPickOrderRepository : JpaRepository<DoPickOrder, Long> { | |||||
| ): List<DoPickOrder> | ): List<DoPickOrder> | ||||
| fun findByPickOrderId(pickOrderId: Long): List<DoPickOrder> | fun findByPickOrderId(pickOrderId: Long): List<DoPickOrder> | ||||
| fun findByDoOrderIdAndDeletedFalse(doOrderId: Long): List<DoPickOrder> | |||||
| fun findByTicketStatusIn(statuses: List<DoPickOrderStatus>): List<DoPickOrder> | fun findByTicketStatusIn(statuses: List<DoPickOrderStatus>): List<DoPickOrder> | ||||
| // 在 DoPickOrderRepository 中添加这个方法 | // 在 DoPickOrderRepository 中添加这个方法 | ||||
| fun findByHandledByAndTicketStatusIn(handledBy: Long, status: List<DoPickOrderStatus>): List<DoPickOrder> | fun findByHandledByAndTicketStatusIn(handledBy: Long, status: List<DoPickOrderStatus>): List<DoPickOrder> | ||||
| @@ -0,0 +1,106 @@ | |||||
| package com.ffii.fpsms.modules.deliveryOrder.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 | |||||
| import java.math.BigDecimal | |||||
| import java.time.LocalDate | |||||
| @Entity | |||||
| @Table(name = "do_replenishment") | |||||
| open class DoReplenishment : BaseEntity<Long>() { | |||||
| @Size(max = 100) | |||||
| @NotNull | |||||
| @Column(name = "code", nullable = false, length = 100) | |||||
| open var code: String? = null | |||||
| @NotNull | |||||
| @Column(name = "deliveryDate", nullable = false) | |||||
| open var deliveryDate: LocalDate? = null | |||||
| @NotNull | |||||
| @Column(name = "sourceDoId", nullable = false) | |||||
| open var sourceDoId: Long? = null | |||||
| @Size(max = 100) | |||||
| @Column(name = "sourceDoCode", length = 100) | |||||
| open var sourceDoCode: String? = null | |||||
| @NotNull | |||||
| @Column(name = "sourceDoLineId", nullable = false) | |||||
| open var sourceDoLineId: Long? = null | |||||
| @NotNull | |||||
| @Column(name = "sourceM18DataLogId", nullable = false) | |||||
| open var sourceM18DataLogId: Long? = null | |||||
| @NotNull | |||||
| @Column(name = "sourceM18Id", nullable = false) | |||||
| open var sourceM18Id: Long? = null | |||||
| @NotNull | |||||
| @Column(name = "itemId", nullable = false) | |||||
| open var itemId: Long? = null | |||||
| @Size(max = 100) | |||||
| @Column(name = "itemNo", length = 100) | |||||
| open var itemNo: String? = null | |||||
| @Size(max = 255) | |||||
| @Column(name = "itemName", length = 255) | |||||
| open var itemName: String? = null | |||||
| @NotNull | |||||
| @Column(name = "replenishQty", nullable = false, precision = 14, scale = 2) | |||||
| open var replenishQty: BigDecimal? = null | |||||
| @Column(name = "uomId") | |||||
| open var uomId: Long? = null | |||||
| @Column(name = "shopId") | |||||
| open var shopId: Long? = null | |||||
| @Size(max = 50) | |||||
| @Column(name = "shopCode", length = 50) | |||||
| open var shopCode: String? = null | |||||
| @Size(max = 255) | |||||
| @Column(name = "shopName", length = 255) | |||||
| open var shopName: String? = null | |||||
| @Size(max = 100) | |||||
| @Column(name = "truckLaneCode", length = 100) | |||||
| open var truckLaneCode: String? = null | |||||
| @Column(name = "targetDoId") | |||||
| open var targetDoId: Long? = null | |||||
| @Size(max = 100) | |||||
| @Column(name = "targetDoCode", length = 100) | |||||
| open var targetDoCode: String? = null | |||||
| @Column(name = "pickOrderLineId") | |||||
| open var pickOrderLineId: Long? = null | |||||
| @Column(name = "deliveryOrderPickOrderId") | |||||
| open var deliveryOrderPickOrderId: Long? = null | |||||
| @NotNull | |||||
| @Size(max = 20) | |||||
| @Column(name = "status", nullable = false, length = 20) | |||||
| open var status: String = STATUS_PENDING | |||||
| @Size(max = 500) | |||||
| @Column(name = "reason", length = 500) | |||||
| open var reason: String? = null | |||||
| companion object { | |||||
| const val STATUS_PENDING = "pending" | |||||
| const val STATUS_PROCESSING = "processing" | |||||
| const val STATUS_COMPLETED = "completed" | |||||
| } | |||||
| } | |||||
| @@ -0,0 +1,89 @@ | |||||
| package com.ffii.fpsms.modules.deliveryOrder.entity | |||||
| import com.ffii.core.support.AbstractRepository | |||||
| import org.springframework.data.jpa.repository.Query | |||||
| import org.springframework.data.repository.query.Param | |||||
| import org.springframework.stereotype.Repository | |||||
| import java.time.LocalDate | |||||
| @Repository | |||||
| interface DoReplenishmentRepository : AbstractRepository<DoReplenishment, Long> { | |||||
| fun findByCodeAndDeletedIsFalse(code: String): DoReplenishment? | |||||
| fun existsBySourceDoLineIdAndStatusAndDeletedIsFalse(sourceDoLineId: Long, status: String): Boolean | |||||
| fun findFirstBySourceDoLineIdAndStatusAndDeletedIsFalse( | |||||
| sourceDoLineId: Long, | |||||
| status: String, | |||||
| ): DoReplenishment? | |||||
| fun findByTargetDoIdInAndDeletedIsFalse(targetDoIds: Collection<Long>): List<DoReplenishment> | |||||
| fun findFirstByPickOrderLineIdAndStatusAndDeletedIsFalse( | |||||
| pickOrderLineId: Long, | |||||
| status: String, | |||||
| ): DoReplenishment? | |||||
| fun findByDeliveryOrderPickOrderIdAndStatusAndDeletedIsFalse( | |||||
| deliveryOrderPickOrderId: Long, | |||||
| status: String, | |||||
| ): List<DoReplenishment> | |||||
| @Query( | |||||
| """ | |||||
| SELECT r FROM DoReplenishment r | |||||
| LEFT JOIN DeliveryOrderPickOrder dopo ON dopo.id = r.deliveryOrderPickOrderId AND dopo.deleted = false | |||||
| WHERE r.deleted = false | |||||
| AND ( | |||||
| :deliveryDate IS NULL | |||||
| OR ( | |||||
| dopo.id IS NOT NULL | |||||
| AND dopo.requiredDeliveryDate = :deliveryDate | |||||
| ) | |||||
| OR ( | |||||
| dopo.id IS NULL | |||||
| AND r.deliveryDate = :deliveryDate | |||||
| ) | |||||
| ) | |||||
| AND (:status IS NULL OR r.status = :status) | |||||
| ORDER BY r.created DESC, r.id DESC | |||||
| """, | |||||
| ) | |||||
| fun search( | |||||
| @Param("deliveryDate") deliveryDate: LocalDate?, | |||||
| @Param("status") status: String?, | |||||
| ): List<DoReplenishment> | |||||
| @Query( | |||||
| """ | |||||
| SELECT r FROM DoReplenishment r | |||||
| WHERE r.deleted = false | |||||
| AND r.status = :status | |||||
| AND ( | |||||
| :truckLaneCode IS NULL OR :truckLaneCode = '' | |||||
| OR LOWER(COALESCE(r.truckLaneCode, '')) LIKE LOWER(CONCAT('%', :truckLaneCode, '%')) | |||||
| ) | |||||
| AND ( | |||||
| :shopName IS NULL OR :shopName = '' | |||||
| OR LOWER(COALESCE(r.shopName, '')) LIKE LOWER(CONCAT('%', :shopName, '%')) | |||||
| OR LOWER(COALESCE(r.shopCode, '')) LIKE LOWER(CONCAT('%', :shopName, '%')) | |||||
| ) | |||||
| ORDER BY r.shopName, r.shopCode, r.code | |||||
| """, | |||||
| ) | |||||
| fun searchForBatchRelease( | |||||
| @Param("status") status: String, | |||||
| @Param("truckLaneCode") truckLaneCode: String?, | |||||
| @Param("shopName") shopName: String?, | |||||
| ): List<DoReplenishment> | |||||
| @Query( | |||||
| """ | |||||
| SELECT r.code FROM DoReplenishment r | |||||
| WHERE r.deleted = false | |||||
| AND r.code LIKE CONCAT(:codePrefix, '%') | |||||
| """, | |||||
| ) | |||||
| fun findCodesByPrefix(@Param("codePrefix") codePrefix: String): List<String> | |||||
| } | |||||
| @@ -47,6 +47,9 @@ interface DeliveryOrderInfoLite { | |||||
| val supplierCode: String? | val supplierCode: String? | ||||
| @get:Value("#{target.shop?.addr3}") | @get:Value("#{target.shop?.addr3}") | ||||
| val shopAddress: String? | val shopAddress: String? | ||||
| @get:Value("#{target.isExtra}") | |||||
| val isExtra: Boolean | |||||
| } | } | ||||
| data class DeliveryOrderInfoLiteDto( | data class DeliveryOrderInfoLiteDto( | ||||
| val id: Long, | val id: Long, | ||||
| @@ -57,5 +60,6 @@ data class DeliveryOrderInfoLiteDto( | |||||
| val shopName: String?, | val shopName: String?, | ||||
| val supplierName: String?, | val supplierName: String?, | ||||
| val shopAddress: String?, | val shopAddress: String?, | ||||
| val truckLanceCode: String? | |||||
| val truckLanceCode: String?, | |||||
| val isExtra: Boolean = false, | |||||
| ) | ) | ||||
| @@ -17,4 +17,6 @@ interface DeliveryOrderLineInfo { | |||||
| @get:Value("#{target.item?.id}") | @get:Value("#{target.item?.id}") | ||||
| val itemId: Long? | val itemId: Long? | ||||
| @get:Value("#{target.deleted}") | |||||
| val deleted: Boolean? | |||||
| } | } | ||||
| @@ -0,0 +1,117 @@ | |||||
| package com.ffii.fpsms.modules.deliveryOrder.service | |||||
| import com.ffii.fpsms.modules.settings.entity.SettingsRepository | |||||
| import org.springframework.stereotype.Service | |||||
| import java.util.Locale | |||||
| /** 供 DO 搜索/車線/報表 SQL 等共用的 2F/4F 供應商代碼(來自 `settings` CSV)。 */ | |||||
| @Service | |||||
| open class DoFloorSupplierSettingsService( | |||||
| private val settingsRepository: SettingsRepository, | |||||
| ) { | |||||
| companion object { | |||||
| private const val SETTING_DO_FLOOR_SUPPLIERS_2F = "DO.floor.suppliers.2F" | |||||
| private const val SETTING_DO_FLOOR_SUPPLIERS_4F = "DO.floor.suppliers.4F" | |||||
| private val DEFAULT_SUPPLIERS_2F = listOf("P07", "P06D", "P06Y") | |||||
| private val DEFAULT_SUPPLIERS_4F = listOf("P06B") | |||||
| } | |||||
| open fun supplierCodesFromSetting(settingName: String, defaultList: List<String>): List<String> { | |||||
| val raw = settingsRepository.findByName(settingName).map { it.value }.orElse(null) | |||||
| ?.trim() | |||||
| .orEmpty() | |||||
| if (raw.isEmpty()) return defaultList | |||||
| val parsed = raw.split(",").map { it.trim() }.filter { it.isNotEmpty() }.distinct() | |||||
| return parsed.ifEmpty { defaultList } | |||||
| } | |||||
| open fun loadDoFloorSupplierLists(): Pair<List<String>, List<String>> { | |||||
| val suppliers2F = supplierCodesFromSetting(SETTING_DO_FLOOR_SUPPLIERS_2F, DEFAULT_SUPPLIERS_2F) | |||||
| val suppliers4F = supplierCodesFromSetting(SETTING_DO_FLOOR_SUPPLIERS_4F, DEFAULT_SUPPLIERS_4F) | |||||
| return suppliers2F to suppliers4F | |||||
| } | |||||
| open fun allowedSupplierCodesForFloor(floor: String?): List<String> { | |||||
| val f = floor?.trim()?.uppercase(Locale.ROOT).orEmpty() | |||||
| val (codes2F, codes4F) = loadDoFloorSupplierLists() | |||||
| return when { | |||||
| f.isEmpty() || f == "ALL" || f == "All" -> (codes2F + codes4F).distinct() | |||||
| f == "2F" -> codes2F | |||||
| f == "4F" -> codes4F | |||||
| else -> (codes2F + codes4F).distinct() | |||||
| } | |||||
| } | |||||
| /** 4F 清單優先;其餘預設 2F(與既有 DO 車線邏輯一致)。 */ | |||||
| open fun preferredStoreFloorForSupplier( | |||||
| supplierCode: String?, | |||||
| suppliers2F: List<String>, | |||||
| suppliers4F: List<String>, | |||||
| ): String { | |||||
| val code = supplierCode?.trim().orEmpty() | |||||
| if (code.isEmpty()) return "2F" | |||||
| if (suppliers4F.contains(code)) return "4F" | |||||
| if (suppliers2F.contains(code)) return "2F" | |||||
| return "2F" | |||||
| } | |||||
| /** DO 揀貨建議:名單外供應商不限制 2F/4F。 */ | |||||
| open fun preferredFloorForPickLotOrNull( | |||||
| supplierCode: String?, | |||||
| suppliers2F: List<String>, | |||||
| suppliers4F: List<String>, | |||||
| ): String? { | |||||
| val code = supplierCode?.trim().orEmpty() | |||||
| if (code.isEmpty()) return null | |||||
| if (suppliers4F.contains(code)) return "4F" | |||||
| if (suppliers2F.contains(code)) return "2F" | |||||
| 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, | |||||
| val storeIdNumericCase: String, | |||||
| ) | |||||
| /** | |||||
| * 依目前 settings 產生原生 SQL CASE(供 JDBC 字串拼接)。 | |||||
| * @param codeExpr 已加別名的欄位,如 `s.code`、`supplier.code` | |||||
| */ | |||||
| open fun sqlPreferredFloorCases(codeExpr: String = "s.code"): SqlPreferredFloorCases { | |||||
| val (s2f, s4f) = loadDoFloorSupplierLists() | |||||
| val in4 = joinSqlInList(s4f) | |||||
| val in2 = joinSqlInList(s2f) | |||||
| val floor = | |||||
| "CASE WHEN $codeExpr IN ($in4) THEN '4F' WHEN $codeExpr IN ($in2) THEN '2F' ELSE NULL END" | |||||
| val storeId = | |||||
| "CASE WHEN $codeExpr IN ($in4) THEN 4 WHEN $codeExpr IN ($in2) THEN 2 ELSE NULL END" | |||||
| return SqlPreferredFloorCases(floorStringCase = floor, storeIdNumericCase = storeId) | |||||
| } | |||||
| private fun joinSqlInList(codes: List<String>): String = | |||||
| codes.joinToString(", ") { "'" + it.replace("'", "''") + "'" } | |||||
| } | |||||
| @@ -847,139 +847,90 @@ open class DoPickOrderService( | |||||
| * Groups DoPickOrder and DoPickOrderRecord data to provide summary statistics. | * Groups DoPickOrder and DoPickOrderRecord data to provide summary statistics. | ||||
| */ | */ | ||||
| open fun getTruckScheduleDashboard(targetDate: LocalDate): List<TruckScheduleDashboardResponse> { | open fun getTruckScheduleDashboard(targetDate: LocalDate): List<TruckScheduleDashboardResponse> { | ||||
| // Fetch all active DoPickOrders for the target date | |||||
| val doPickOrders = doPickOrderRepository.findByStoreIdAndRequiredDeliveryDateAndTicketStatusIn( | |||||
| "2/F", targetDate, listOf(DoPickOrderStatus.pending, DoPickOrderStatus.released, DoPickOrderStatus.completed) | |||||
| ) + doPickOrderRepository.findByStoreIdAndRequiredDeliveryDateAndTicketStatusIn( | |||||
| "4/F", targetDate, listOf(DoPickOrderStatus.pending, DoPickOrderStatus.released, DoPickOrderStatus.completed) | |||||
| ) | |||||
| // Fetch all DoPickOrderRecords for the target date (completed records) | |||||
| val doPickOrderRecords = doPickOrderRecordRepository.findByStoreIdAndRequiredDeliveryDateAndTicketStatusIn( | |||||
| "2/F", targetDate, listOf(DoPickOrderStatus.completed) | |||||
| ) + doPickOrderRecordRepository.findByStoreIdAndRequiredDeliveryDateAndTicketStatusIn( | |||||
| "4/F", targetDate, listOf(DoPickOrderStatus.completed) | |||||
| ) | |||||
| // Combine both types into a unified data structure for aggregation | |||||
| data class TicketData( | |||||
| val storeId: String?, | |||||
| val truckId: Long?, | |||||
| val truckLanceCode: String?, | |||||
| val truckDepartureTime: java.time.LocalTime?, | |||||
| val shopId: Long?, | |||||
| val shopCode: String?, | |||||
| val ticketNo: String?, | |||||
| val ticketReleaseTime: LocalDateTime?, | |||||
| val ticketCompleteDateTime: LocalDateTime?, | |||||
| val ticketStatus: DoPickOrderStatus?, | |||||
| val doPickOrderId: Long?, | |||||
| val isRecord: Boolean | |||||
| ) | |||||
| val allTickets = mutableListOf<TicketData>() | |||||
| doPickOrders.forEach { dpo -> | |||||
| allTickets.add(TicketData( | |||||
| storeId = dpo.storeId, | |||||
| truckId = dpo.truckId, | |||||
| truckLanceCode = dpo.truckLanceCode, | |||||
| truckDepartureTime = dpo.truckDepartureTime, | |||||
| shopId = dpo.shopId, | |||||
| shopCode = dpo.shopCode, | |||||
| ticketNo = dpo.ticketNo, | |||||
| ticketReleaseTime = dpo.ticketReleaseTime, | |||||
| ticketCompleteDateTime = dpo.ticketCompleteDateTime, | |||||
| ticketStatus = dpo.ticketStatus, | |||||
| doPickOrderId = dpo.id, | |||||
| isRecord = false | |||||
| )) | |||||
| } | |||||
| doPickOrderRecords.forEach { record -> | |||||
| allTickets.add(TicketData( | |||||
| storeId = record.storeId, | |||||
| truckId = record.truckId, | |||||
| truckLanceCode = record.truckLanceCode, | |||||
| truckDepartureTime = record.truckDepartureTime, | |||||
| shopId = record.shopId, | |||||
| shopCode = record.shopCode, | |||||
| ticketNo = record.ticketNo, | |||||
| ticketReleaseTime = record.ticketReleaseTime, | |||||
| ticketCompleteDateTime = record.ticketCompleteDateTime, | |||||
| ticketStatus = record.ticketStatus, | |||||
| doPickOrderId = record.recordId, | |||||
| isRecord = true | |||||
| )) | |||||
| } | |||||
| // Group by storeId, truckLanceCode, truckDepartureTime | |||||
| val grouped = allTickets.groupBy { | |||||
| Triple(it.storeId, it.truckLanceCode, it.truckDepartureTime) | |||||
| } | |||||
| return grouped.map { (key, tickets) -> | |||||
| val (storeId, truckLanceCode, truckDepartureTime) = key | |||||
| // Count distinct shops | |||||
| val distinctShops = tickets.mapNotNull { it.shopId ?: it.shopCode?.hashCode()?.toLong() }.distinct().size | |||||
| // Count distinct tickets | |||||
| val distinctTickets = tickets.mapNotNull { it.ticketNo }.distinct().size | |||||
| // Calculate total items to pick | |||||
| var totalItems = 0 | |||||
| tickets.forEach { ticket -> | |||||
| if (ticket.doPickOrderId != null) { | |||||
| if (ticket.isRecord) { | |||||
| totalItems += countFGItemsFromRecordById(ticket.doPickOrderId) | |||||
| } else { | |||||
| totalItems += countFGItemsById(ticket.doPickOrderId) | |||||
| } | |||||
| } | |||||
| // Source of truth: delivery_order_pick_order (+ linked pick_order / pick_order_line) | |||||
| // | |||||
| // NOTE: delivery_order_pick_order 沒有 truckId 欄位;dashboard 的 truckId 目前僅作為展示/鍵值用途, | |||||
| // 回傳 null 讓前端保持相容即可。 | |||||
| val sql = """ | |||||
| SELECT | |||||
| dop.storeId AS storeId, | |||||
| dop.truckLanceCode AS truckLanceCode, | |||||
| dop.truckDepartureTime AS truckDepartureTime, | |||||
| COUNT(DISTINCT dop.shopCode) AS numberOfShopsToServe, | |||||
| COUNT(DISTINCT dop.ticketNo) AS numberOfPickTickets, | |||||
| COALESCE(SUM(pol_cnt.cnt), 0) AS totalItemsToPick, | |||||
| SUM(CASE WHEN dop.ticketReleaseTime IS NOT NULL THEN 1 ELSE 0 END) AS numberOfTicketsReleased, | |||||
| MIN(dop.ticketReleaseTime) AS firstTicketStartTime, | |||||
| SUM(CASE WHEN dop.ticketCompleteDateTime IS NOT NULL THEN 1 ELSE 0 END) AS numberOfTicketsCompleted, | |||||
| MAX(dop.ticketCompleteDateTime) AS lastTicketEndTime | |||||
| FROM fpsmsdb.delivery_order_pick_order dop | |||||
| LEFT JOIN ( | |||||
| SELECT | |||||
| po.deliveryOrderPickOrderId AS dopId, | |||||
| COUNT(pol.id) AS cnt | |||||
| FROM fpsmsdb.pick_order po | |||||
| INNER JOIN fpsmsdb.pick_order_line pol | |||||
| ON pol.poId = po.id | |||||
| AND pol.deleted = 0 | |||||
| WHERE po.deleted = 0 | |||||
| AND po.deliveryOrderPickOrderId IS NOT NULL | |||||
| GROUP BY po.deliveryOrderPickOrderId | |||||
| ) pol_cnt | |||||
| ON pol_cnt.dopId = dop.id | |||||
| WHERE dop.deleted = 0 | |||||
| AND dop.requiredDeliveryDate = :targetDate | |||||
| AND dop.ticketStatus IN ('pending', 'released', 'completed') | |||||
| GROUP BY dop.storeId, dop.truckLanceCode, dop.truckDepartureTime | |||||
| ORDER BY dop.storeId, dop.truckDepartureTime | |||||
| """.trimIndent() | |||||
| val rows = jdbcDao.queryForList(sql, mapOf("targetDate" to targetDate)) | |||||
| fun str(row: Map<String, Any?>, key: String): String? = row[key]?.toString() | |||||
| fun intVal(row: Map<String, Any?>, key: String): Int = | |||||
| when (val v = row[key]) { | |||||
| null -> 0 | |||||
| is Number -> v.toInt() | |||||
| else -> v.toString().toBigDecimalOrNull()?.toInt() ?: 0 | |||||
| } | } | ||||
| // Count released tickets (ticketReleaseTime is not null) | |||||
| val releasedTickets = tickets.count { it.ticketReleaseTime != null } | |||||
| // Find first ticket start time (earliest ticketReleaseTime) | |||||
| val firstTicketStartTime = tickets | |||||
| .mapNotNull { it.ticketReleaseTime } | |||||
| .minOrNull() | |||||
| // Count completed tickets (ticketCompleteDateTime is not null) | |||||
| val completedTickets = tickets.count { it.ticketCompleteDateTime != null } | |||||
| // Find last ticket end time (latest ticketCompleteDateTime) | |||||
| val lastTicketEndTime = tickets | |||||
| .mapNotNull { it.ticketCompleteDateTime } | |||||
| .maxOrNull() | |||||
| // Calculate pick time taken in minutes | |||||
| val pickTimeTakenMinutes = if (firstTicketStartTime != null && lastTicketEndTime != null) { | |||||
| ChronoUnit.MINUTES.between(firstTicketStartTime, lastTicketEndTime) | |||||
| } else { | |||||
| null | |||||
| fun timeVal(row: Map<String, Any?>, key: String): java.time.LocalTime? = | |||||
| when (val v = row[key]) { | |||||
| null -> null | |||||
| is java.time.LocalTime -> v | |||||
| is java.sql.Time -> v.toLocalTime() | |||||
| is java.time.OffsetTime -> v.toLocalTime() | |||||
| is String -> runCatching { java.time.LocalTime.parse(v) }.getOrNull() | |||||
| else -> null | |||||
| } | } | ||||
| // Get truck ID (use first non-null) | |||||
| val truckId = tickets.firstOrNull { it.truckId != null }?.truckId | |||||
| fun dtVal(row: Map<String, Any?>, key: String): LocalDateTime? = | |||||
| when (val v = row[key]) { | |||||
| null -> null | |||||
| is LocalDateTime -> v | |||||
| is java.sql.Timestamp -> v.toLocalDateTime() | |||||
| is String -> runCatching { LocalDateTime.parse(v) }.getOrNull() | |||||
| else -> null | |||||
| } | |||||
| return rows.map { row -> | |||||
| val first = dtVal(row, "firstTicketStartTime") | |||||
| val last = dtVal(row, "lastTicketEndTime") | |||||
| val minutes = if (first != null && last != null) ChronoUnit.MINUTES.between(first, last) else null | |||||
| TruckScheduleDashboardResponse( | TruckScheduleDashboardResponse( | ||||
| storeId = storeId, | |||||
| truckId = truckId, | |||||
| truckLanceCode = truckLanceCode, | |||||
| truckDepartureTime = truckDepartureTime, | |||||
| numberOfShopsToServe = distinctShops, | |||||
| numberOfPickTickets = distinctTickets, | |||||
| totalItemsToPick = totalItems, | |||||
| numberOfTicketsReleased = releasedTickets, | |||||
| firstTicketStartTime = firstTicketStartTime, | |||||
| numberOfTicketsCompleted = completedTickets, | |||||
| lastTicketEndTime = lastTicketEndTime, | |||||
| pickTimeTakenMinutes = pickTimeTakenMinutes | |||||
| storeId = str(row, "storeId"), | |||||
| truckId = null, | |||||
| truckLanceCode = str(row, "truckLanceCode"), | |||||
| truckDepartureTime = timeVal(row, "truckDepartureTime"), | |||||
| numberOfShopsToServe = intVal(row, "numberOfShopsToServe"), | |||||
| numberOfPickTickets = intVal(row, "numberOfPickTickets"), | |||||
| totalItemsToPick = intVal(row, "totalItemsToPick"), | |||||
| numberOfTicketsReleased = intVal(row, "numberOfTicketsReleased"), | |||||
| firstTicketStartTime = first, | |||||
| numberOfTicketsCompleted = intVal(row, "numberOfTicketsCompleted"), | |||||
| lastTicketEndTime = last, | |||||
| pickTimeTakenMinutes = minutes, | |||||
| ) | ) | ||||
| }.sortedWith(compareBy({ it.storeId }, { it.truckDepartureTime })) | |||||
| } | |||||
| } | } | ||||
| private fun countFGItemsById(doPickOrderId: Long): Int { | private fun countFGItemsById(doPickOrderId: Long): Int { | ||||
| @@ -103,6 +103,7 @@ class DoReleaseCoordinatorService( | |||||
| private val userRepository: UserRepository, | private val userRepository: UserRepository, | ||||
| private val pickOrderRepository: PickOrderRepository, | private val pickOrderRepository: PickOrderRepository, | ||||
| private val doPickOrderRecordRepository: DoPickOrderRecordRepository, | private val doPickOrderRecordRepository: DoPickOrderRecordRepository, | ||||
| private val doFloorSupplierSettingsService: DoFloorSupplierSettingsService, | |||||
| ) { | ) { | ||||
| private val poolSize = Runtime.getRuntime().availableProcessors() | private val poolSize = Runtime.getRuntime().availableProcessors() | ||||
| private val executor = Executors.newFixedThreadPool(min(poolSize, 4)) | private val executor = Executors.newFixedThreadPool(min(poolSize, 4)) | ||||
| @@ -140,22 +141,15 @@ class DoReleaseCoordinatorService( | |||||
| private fun updateBatchTicketNumbers() { | private fun updateBatchTicketNumbers() { | ||||
| try { | try { | ||||
| val dayOfWeekSql = getDayOfWeekAbbrSql("do.estimatedArrivalDate") | val dayOfWeekSql = getDayOfWeekAbbrSql("do.estimatedArrivalDate") | ||||
| val pfCases = doFloorSupplierSettingsService.sqlPreferredFloorCases("s.code") | |||||
| val updateSql = """ | val updateSql = """ | ||||
| UPDATE fpsmsdb.do_pick_order dpo | UPDATE fpsmsdb.do_pick_order dpo | ||||
| INNER JOIN ( | INNER JOIN ( | ||||
| WITH PreferredFloor AS ( | WITH PreferredFloor AS ( | ||||
| SELECT | SELECT | ||||
| do.id AS deliveryOrderId, | do.id AS deliveryOrderId, | ||||
| CASE | |||||
| WHEN s.code = 'P06B' THEN '4F' | |||||
| WHEN s.code = 'P07' OR s.code = 'P06D' THEN '2F' | |||||
| ELSE NULL | |||||
| END AS preferred_floor, | |||||
| CASE | |||||
| WHEN s.code = 'P06B' THEN 4 | |||||
| WHEN s.code = 'P07' OR s.code = 'P06D' THEN 2 | |||||
| ELSE NULL | |||||
| END AS preferred_store_id | |||||
| ${pfCases.floorStringCase} AS preferred_floor, | |||||
| ${pfCases.storeIdNumericCase} AS preferred_store_id | |||||
| FROM fpsmsdb.delivery_order do | FROM fpsmsdb.delivery_order do | ||||
| LEFT JOIN fpsmsdb.shop s ON s.id = do.supplierId AND s.deleted = 0 | LEFT JOIN fpsmsdb.shop s ON s.id = do.supplierId AND s.deleted = 0 | ||||
| WHERE do.deleted = 0 | WHERE do.deleted = 0 | ||||
| @@ -307,20 +301,13 @@ class DoReleaseCoordinatorService( | |||||
| println(" DEBUG: Getting ordered IDs for ${ids.size} orders") | println(" DEBUG: Getting ordered IDs for ${ids.size} orders") | ||||
| println(" DEBUG: First 5 IDs: ${ids.take(5)}") | println(" DEBUG: First 5 IDs: ${ids.take(5)}") | ||||
| val dayOfWeekSql = getDayOfWeekAbbrSql("do.estimatedArrivalDate") | val dayOfWeekSql = getDayOfWeekAbbrSql("do.estimatedArrivalDate") | ||||
| val pfCases = doFloorSupplierSettingsService.sqlPreferredFloorCases("s.code") | |||||
| val sql = """ | val sql = """ | ||||
| WITH PreferredFloor AS ( | WITH PreferredFloor AS ( | ||||
| SELECT | SELECT | ||||
| do.id AS deliveryOrderId, | do.id AS deliveryOrderId, | ||||
| CASE | |||||
| WHEN s.code = 'P06B' THEN '4F' | |||||
| WHEN s.code = 'P07' OR s.code = 'P06D' THEN '2F' | |||||
| ELSE NULL | |||||
| END AS preferred_floor, | |||||
| CASE | |||||
| WHEN s.code = 'P06B' THEN 4 | |||||
| WHEN s.code = 'P07' OR s.code = 'P06D' THEN 2 | |||||
| ELSE NULL | |||||
| END AS preferred_store_id | |||||
| ${pfCases.floorStringCase} AS preferred_floor, | |||||
| ${pfCases.storeIdNumericCase} AS preferred_store_id | |||||
| FROM fpsmsdb.delivery_order do | FROM fpsmsdb.delivery_order do | ||||
| LEFT JOIN fpsmsdb.shop s ON s.id = do.supplierId AND s.deleted = 0 | LEFT JOIN fpsmsdb.shop s ON s.id = do.supplierId AND s.deleted = 0 | ||||
| WHERE do.id IN (${ids.joinToString(",")}) | WHERE do.id IN (${ids.joinToString(",")}) | ||||
| @@ -719,8 +706,11 @@ class DoReleaseCoordinatorService( | |||||
| val existing = candidates.firstOrNull { c -> | val existing = candidates.firstOrNull { c -> | ||||
| c.truckId == first.truckId && | c.truckId == first.truckId && | ||||
| c.truckDepartureTime == first.truckDepartureTime && | 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) { | if (existing != null) { | ||||
| println( | println( | ||||
| " DEBUG: Merging batch into existing DoPickOrder id=${existing.id}, ticket=${existing.ticketNo} " + | " DEBUG: Merging batch into existing DoPickOrder id=${existing.id}, ticket=${existing.ticketNo} " + | ||||
| @@ -767,6 +757,33 @@ class DoReleaseCoordinatorService( | |||||
| println(" DEBUG: Created ${results.size} DoPickOrderLine records") | 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 { | private fun checkPickOrderHasStockIssues(doPickOrderId: Long): Boolean { | ||||
| try { | try { | ||||
| // 1. 获取 do_pick_order 的所有 pick orders 数量 | // 1. 获取 do_pick_order 的所有 pick orders 数量 | ||||
| @@ -871,7 +888,8 @@ class DoReleaseCoordinatorService( | |||||
| .firstOrNull { | .firstOrNull { | ||||
| it.requiredDeliveryDate == result.estimatedArrivalDate && | it.requiredDeliveryDate == result.estimatedArrivalDate && | ||||
| it.truckDepartureTime == result.truckDepartureTime && | it.truckDepartureTime == result.truckDepartureTime && | ||||
| it.truckLanceCode == result.truckLanceCode | |||||
| it.truckLanceCode == result.truckLanceCode && | |||||
| (storeId != null || doPickOrderMatchesTruckXPreferredFloor(it, result.preferredFloor)) | |||||
| } | } | ||||
| if (existingDoPickOrder != null) { | if (existingDoPickOrder != null) { | ||||
| @@ -0,0 +1,583 @@ | |||||
| package com.ffii.fpsms.modules.deliveryOrder.service | |||||
| import com.ffii.core.support.JdbcDao | |||||
| 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.entity.DoReplenishment | |||||
| import com.ffii.fpsms.modules.deliveryOrder.entity.DoReplenishmentRepository | |||||
| import com.ffii.fpsms.modules.deliveryOrder.entity.DeliveryOrder | |||||
| import com.ffii.fpsms.modules.deliveryOrder.entity.DeliveryOrderLineRepository | |||||
| import com.ffii.fpsms.modules.deliveryOrder.entity.DeliveryOrderPickOrderRepository | |||||
| import com.ffii.fpsms.modules.deliveryOrder.entity.DeliveryOrderRepository | |||||
| import com.ffii.fpsms.modules.deliveryOrder.enums.DeliveryOrderStatus | |||||
| import com.ffii.fpsms.modules.deliveryOrder.web.models.DoReplenishmentResponse | |||||
| import com.ffii.fpsms.modules.deliveryOrder.web.models.ReleaseDoResult | |||||
| import com.ffii.fpsms.modules.deliveryOrder.web.models.SubmitDoReplenishmentLineRequest | |||||
| import com.ffii.fpsms.modules.deliveryOrder.web.models.SubmitDoReplenishmentRequest | |||||
| import com.ffii.fpsms.modules.master.entity.ItemsRepository | |||||
| import com.ffii.fpsms.modules.master.entity.UomConversionRepository | |||||
| import com.ffii.fpsms.modules.pickOrder.entity.PickOrderLine | |||||
| import com.ffii.fpsms.modules.pickOrder.entity.PickOrderLineRepository | |||||
| import com.ffii.fpsms.modules.pickOrder.entity.PickOrderRepository | |||||
| import com.ffii.fpsms.modules.stock.entity.StockOutLIneRepository | |||||
| import com.ffii.fpsms.modules.stock.service.WorkbenchStockOutLinePickProgress | |||||
| import com.ffii.fpsms.modules.pickOrder.enums.PickOrderLineStatus | |||||
| import org.springframework.stereotype.Service | |||||
| import org.springframework.transaction.annotation.Transactional | |||||
| import java.math.BigDecimal | |||||
| import java.time.LocalDate | |||||
| import java.time.format.DateTimeFormatter | |||||
| @Service | |||||
| open class DoReplenishmentService( | |||||
| private val doReplenishmentRepository: DoReplenishmentRepository, | |||||
| private val deliveryOrderRepository: DeliveryOrderRepository, | |||||
| private val deliveryOrderPickOrderRepository: DeliveryOrderPickOrderRepository, | |||||
| private val deliveryOrderLineRepository: DeliveryOrderLineRepository, | |||||
| private val doPickOrderRepository: DoPickOrderRepository, | |||||
| private val doPickOrderLineRepository: DoPickOrderLineRepository, | |||||
| private val doPickOrderRecordRepository: DoPickOrderRecordRepository, | |||||
| private val stockOutLIneRepository: StockOutLIneRepository, | |||||
| private val uomConversionRepository: UomConversionRepository, | |||||
| private val pickOrderRepository: PickOrderRepository, | |||||
| private val pickOrderLineRepository: PickOrderLineRepository, | |||||
| private val itemsRepository: ItemsRepository, | |||||
| private val jdbcDao: JdbcDao, | |||||
| ) { | |||||
| @Transactional | |||||
| open fun submit(request: SubmitDoReplenishmentRequest): List<DoReplenishmentResponse> { | |||||
| if (request.lines.isEmpty()) { | |||||
| throw IllegalArgumentException("No replenishment lines to submit") | |||||
| } | |||||
| val nextSeqByDate = mutableMapOf<LocalDate, Int>() | |||||
| val created = mutableListOf<DoReplenishment>() | |||||
| val mergedLines = mergeSubmitLines(request.lines) | |||||
| for (lineReq in mergedLines) { | |||||
| val deliveryOrder = deliveryOrderRepository.findByIdAndDeletedIsFalse(lineReq.sourceDoId) | |||||
| ?: throw IllegalArgumentException("Source delivery order not found: ${lineReq.sourceDoId}") | |||||
| if (deliveryOrder.status != DeliveryOrderStatus.COMPLETED) { | |||||
| throw IllegalArgumentException("Source delivery order must be completed: ${deliveryOrder.code}") | |||||
| } | |||||
| val doLine = deliveryOrderLineRepository.findById(lineReq.sourceDoLineId).orElse(null) | |||||
| ?: throw IllegalArgumentException("Source delivery order line not found: ${lineReq.sourceDoLineId}") | |||||
| if (doLine.deleted == true || doLine.deliveryOrder?.id != lineReq.sourceDoId) { | |||||
| throw IllegalArgumentException("Source line does not belong to delivery order ${lineReq.sourceDoId}") | |||||
| } | |||||
| val existingPending = doReplenishmentRepository.findFirstBySourceDoLineIdAndStatusAndDeletedIsFalse( | |||||
| lineReq.sourceDoLineId, | |||||
| DoReplenishment.STATUS_PENDING, | |||||
| ) | |||||
| if (existingPending != null) { | |||||
| existingPending.replenishQty = | |||||
| (existingPending.replenishQty ?: BigDecimal.ZERO).add(lineReq.replenishQty) | |||||
| if (existingPending.truckLaneCode.isNullOrBlank()) { | |||||
| existingPending.truckLaneCode = | |||||
| resolveSourceDoTruckLaneCode(deliveryOrder, lineReq.truckLaneCode) | |||||
| } | |||||
| lineReq.reason?.trim()?.takeIf { it.isNotEmpty() }?.let { existingPending.reason = it } | |||||
| created += doReplenishmentRepository.save(existingPending) | |||||
| continue | |||||
| } | |||||
| val m18DataLog = doLine.m18DataLog | |||||
| ?: throw IllegalArgumentException("Source line missing M18 data log") | |||||
| val m18Id = m18DataLog.m18Id | |||||
| ?: throw IllegalArgumentException("Source line missing M18 id") | |||||
| val item = doLine.item | |||||
| ?: throw IllegalArgumentException("Source line missing item") | |||||
| val seq = nextSeqByDate.getOrPut(lineReq.deliveryDate) { | |||||
| nextCodeSequence(lineReq.deliveryDate) | |||||
| } | |||||
| nextSeqByDate[lineReq.deliveryDate] = seq + 1 | |||||
| val code = formatReplenishmentCode(lineReq.deliveryDate, seq) | |||||
| val shop = deliveryOrder.shop | |||||
| val entity = DoReplenishment().apply { | |||||
| this.code = code | |||||
| deliveryDate = lineReq.deliveryDate | |||||
| sourceDoId = lineReq.sourceDoId | |||||
| sourceDoCode = deliveryOrder.code | |||||
| sourceDoLineId = lineReq.sourceDoLineId | |||||
| sourceM18DataLogId = m18DataLog.id | |||||
| sourceM18Id = m18Id | |||||
| itemId = item.id | |||||
| itemNo = doLine.itemNo ?: item.code | |||||
| itemName = item.name | |||||
| replenishQty = lineReq.replenishQty | |||||
| uomId = doLine.uom?.id | |||||
| shopId = shop?.id | |||||
| shopCode = shop?.code | |||||
| shopName = shop?.name | |||||
| truckLaneCode = resolveSourceDoTruckLaneCode(deliveryOrder, lineReq.truckLaneCode) | |||||
| status = DoReplenishment.STATUS_PENDING | |||||
| reason = lineReq.reason?.trim()?.takeIf { it.isNotEmpty() } | |||||
| } | |||||
| created += doReplenishmentRepository.save(entity) | |||||
| } | |||||
| return toResponses(created) | |||||
| } | |||||
| open fun list(deliveryDate: LocalDate?, status: String?): List<DoReplenishmentResponse> { | |||||
| val normalizedStatus = status?.trim()?.takeIf { it.isNotEmpty() && it != "all" } | |||||
| val rows = doReplenishmentRepository.search(deliveryDate, normalizedStatus) | |||||
| return toResponses(rows) | |||||
| } | |||||
| open fun listForBatchRelease( | |||||
| truckLaneCode: String?, | |||||
| shopName: String?, | |||||
| ): List<DoReplenishmentResponse> { | |||||
| val truck = truckLaneCode?.trim()?.takeIf { it.isNotEmpty() } | |||||
| val shop = shopName?.trim()?.takeIf { it.isNotEmpty() } | |||||
| if (truck == null && shop == null) { | |||||
| return emptyList() | |||||
| } | |||||
| val rows = doReplenishmentRepository.searchForBatchRelease( | |||||
| status = DoReplenishment.STATUS_PENDING, | |||||
| truckLaneCode = truck, | |||||
| shopName = shop, | |||||
| ) | |||||
| return toResponses(rows) | |||||
| } | |||||
| open fun findReplenishmentsByTargetDoIds(targetDoIds: Collection<Long>): List<DoReplenishment> { | |||||
| if (targetDoIds.isEmpty()) return emptyList() | |||||
| return doReplenishmentRepository.findByTargetDoIdInAndDeletedIsFalse(targetDoIds) | |||||
| } | |||||
| data class ReplenishPdfIndex( | |||||
| private val targetDoItemKeys: Set<Pair<Long, Long>>, | |||||
| private val pickOrderLineIds: Set<Long>, | |||||
| ) { | |||||
| fun matches(deliveryOrderId: Long, itemId: Long?, pickOrderLineId: Long?): Boolean { | |||||
| if (pickOrderLineId != null && pickOrderLineId in pickOrderLineIds) return true | |||||
| val resolvedItemId = itemId ?: return false | |||||
| return deliveryOrderId to resolvedItemId in targetDoItemKeys | |||||
| } | |||||
| companion object { | |||||
| val EMPTY = ReplenishPdfIndex(emptySet(), emptySet()) | |||||
| } | |||||
| } | |||||
| open fun buildReplenishPdfIndex(deliveryOrderIds: Collection<Long>): ReplenishPdfIndex { | |||||
| if (deliveryOrderIds.isEmpty()) return ReplenishPdfIndex.EMPTY | |||||
| val records = doReplenishmentRepository.findByTargetDoIdInAndDeletedIsFalse(deliveryOrderIds) | |||||
| .filter { it.pickOrderLineId != null } | |||||
| if (records.isEmpty()) return ReplenishPdfIndex.EMPTY | |||||
| return ReplenishPdfIndex( | |||||
| targetDoItemKeys = records.mapNotNull { row -> | |||||
| val targetDoId = row.targetDoId | |||||
| val itemId = row.itemId | |||||
| if (targetDoId != null && itemId != null) targetDoId to itemId else null | |||||
| }.toSet(), | |||||
| pickOrderLineIds = records.mapNotNull { it.pickOrderLineId }.toSet(), | |||||
| ) | |||||
| } | |||||
| /** Replenishment POL rows with no matching DOL on the target DO — append as extra DN lines. */ | |||||
| open fun replenishmentsWithoutDeliveryOrderLine( | |||||
| deliveryOrderIds: Collection<Long>, | |||||
| exportLines: List<DeliveryOrderService.DeliveryNoteExportLine>, | |||||
| ): List<DoReplenishment> { | |||||
| if (deliveryOrderIds.isEmpty()) return emptyList() | |||||
| val dolItemKeys = exportLines.mapNotNull { row -> | |||||
| row.line.itemId?.let { row.deliveryOrderId to it } | |||||
| }.toSet() | |||||
| return doReplenishmentRepository.findByTargetDoIdInAndDeletedIsFalse(deliveryOrderIds) | |||||
| .filter { replenishment -> | |||||
| val polId = replenishment.pickOrderLineId ?: return@filter false | |||||
| val targetDoId = replenishment.targetDoId ?: return@filter false | |||||
| val itemId = replenishment.itemId ?: return@filter false | |||||
| polId > 0 && (targetDoId to itemId) !in dolItemKeys | |||||
| } | |||||
| } | |||||
| @Transactional | |||||
| open fun completeByPickOrderLineId(pickOrderLineId: Long) { | |||||
| val row = doReplenishmentRepository.findFirstByPickOrderLineIdAndStatusAndDeletedIsFalse( | |||||
| pickOrderLineId, | |||||
| DoReplenishment.STATUS_PROCESSING, | |||||
| ) ?: return | |||||
| row.status = DoReplenishment.STATUS_COMPLETED | |||||
| doReplenishmentRepository.save(row) | |||||
| } | |||||
| @Transactional | |||||
| open fun completeProcessingByDeliveryOrderPickOrderId(deliveryOrderPickOrderId: Long) { | |||||
| if (deliveryOrderPickOrderId <= 0L) return | |||||
| val rows = doReplenishmentRepository.findByDeliveryOrderPickOrderIdAndStatusAndDeletedIsFalse( | |||||
| deliveryOrderPickOrderId, | |||||
| DoReplenishment.STATUS_PROCESSING, | |||||
| ) | |||||
| for (row in rows) { | |||||
| row.pickOrderLineId?.let { completeByPickOrderLineId(it) } | |||||
| } | |||||
| } | |||||
| /** | |||||
| * After workbench batch release links pick orders to a ticket, create replenishment POL rows | |||||
| * (no DOL), assign target DO / ticket FKs, and move status pending → processing. | |||||
| * | |||||
| * @return pick order ids that received new replenishment lines (for V1 downstream rebuild) | |||||
| */ | |||||
| @Transactional(rollbackFor = [Exception::class]) | |||||
| open fun releasePendingReplenishmentsForWorkbenchBatch( | |||||
| releasedResults: List<ReleaseDoResult>, | |||||
| ): Set<Long> { | |||||
| if (releasedResults.isEmpty()) return emptySet() | |||||
| val pending = findPendingReplenishmentsForReleasedResults(releasedResults) | |||||
| if (pending.isEmpty()) return emptySet() | |||||
| val affectedPickOrderIds = mutableSetOf<Long>() | |||||
| for (replenishment in pending) { | |||||
| val matchingResults = releasedResults.filter { replenishmentMatchesResult(replenishment, it) } | |||||
| if (matchingResults.isEmpty()) continue | |||||
| val targetResult = matchingResults.minByOrNull { it.deliveryOrderId } | |||||
| ?: continue | |||||
| val dopoId = resolveDeliveryOrderPickOrderId(targetResult.pickOrderId) | |||||
| ?: continue | |||||
| val ticketPickOrderIds = pickOrderRepository.findIdsByDeliveryOrderPickOrderId(dopoId) | |||||
| if (ticketPickOrderIds.isEmpty()) continue | |||||
| val itemId = replenishment.itemId ?: continue | |||||
| val targetPickOrderId = resolveTargetPickOrderId(ticketPickOrderIds, itemId) | |||||
| val pickOrder = pickOrderRepository.findById(targetPickOrderId).orElse(null) ?: continue | |||||
| val item = itemsRepository.findById(itemId).orElse(null) ?: continue | |||||
| val uom = replenishment.uomId?.let { uomConversionRepository.findById(it).orElse(null) } | |||||
| ?: continue | |||||
| val pol = PickOrderLine().apply { | |||||
| this.pickOrder = pickOrder | |||||
| this.item = item | |||||
| this.qty = replenishment.replenishQty | |||||
| this.uom = uom | |||||
| this.status = PickOrderLineStatus.PENDING | |||||
| } | |||||
| val savedPol = pickOrderLineRepository.save(pol) | |||||
| pickOrder.totalLines = (pickOrder.totalLines ?: 0) + 1 | |||||
| pickOrderRepository.save(pickOrder) | |||||
| replenishment.targetDoId = targetResult.deliveryOrderId | |||||
| replenishment.targetDoCode = targetResult.deliveryOrderCode | |||||
| replenishment.pickOrderLineId = savedPol.id | |||||
| replenishment.deliveryOrderPickOrderId = dopoId | |||||
| replenishment.status = DoReplenishment.STATUS_PROCESSING | |||||
| doReplenishmentRepository.save(replenishment) | |||||
| affectedPickOrderIds += targetPickOrderId | |||||
| } | |||||
| return affectedPickOrderIds | |||||
| } | |||||
| private fun findPendingReplenishmentsForReleasedResults( | |||||
| releasedResults: List<ReleaseDoResult>, | |||||
| ): List<DoReplenishment> { | |||||
| val pairs = releasedResults | |||||
| .map { result -> | |||||
| (result.shopName?.trim()?.takeIf { it.isNotEmpty() } | |||||
| ?: result.shopCode?.trim()?.takeIf { it.isNotEmpty() } | |||||
| ?: "") to (result.truckLanceCode?.trim()?.takeIf { it.isNotEmpty() } ?: "") | |||||
| } | |||||
| .distinct() | |||||
| .filter { (shop, truck) -> shop.isNotEmpty() || truck.isNotEmpty() } | |||||
| if (pairs.isEmpty()) return emptyList() | |||||
| val byId = linkedMapOf<Long, DoReplenishment>() | |||||
| for ((shop, truck) in pairs) { | |||||
| val rows = doReplenishmentRepository.searchForBatchRelease( | |||||
| status = DoReplenishment.STATUS_PENDING, | |||||
| truckLaneCode = truck.takeIf { it.isNotEmpty() }, | |||||
| shopName = shop.takeIf { it.isNotEmpty() }, | |||||
| ) | |||||
| for (row in rows) { | |||||
| val id = row.id ?: continue | |||||
| if (releasedResults.any { replenishmentMatchesResult(row, it) }) { | |||||
| byId[id] = row | |||||
| } | |||||
| } | |||||
| } | |||||
| return byId.values.toList() | |||||
| } | |||||
| private fun replenishmentMatchesResult( | |||||
| replenishment: DoReplenishment, | |||||
| result: ReleaseDoResult, | |||||
| ): Boolean { | |||||
| val doTruck = normalizeText(result.truckLanceCode) | |||||
| val recordTruck = normalizeText(replenishment.truckLaneCode) | |||||
| if (doTruck.isNotEmpty()) { | |||||
| if (recordTruck.isEmpty() || recordTruck != doTruck) return false | |||||
| } | |||||
| val doShopToken = shopTokenFromResult(result) | |||||
| if (doShopToken.isEmpty()) return false | |||||
| val recordShopCode = normalizeText(replenishment.shopCode) | |||||
| val recordShopName = normalizeText(replenishment.shopName) | |||||
| return recordShopCode == doShopToken || | |||||
| recordShopName.startsWith(doShopToken) || | |||||
| (recordShopCode.isNotEmpty() && doShopToken.startsWith(recordShopCode)) || | |||||
| (recordShopCode.isNotEmpty() && recordShopCode.startsWith(doShopToken)) | |||||
| } | |||||
| private fun shopTokenFromResult(result: ReleaseDoResult): String { | |||||
| val raw = result.shopCode?.trim()?.takeIf { it.isNotEmpty() } | |||||
| ?: result.shopName?.trim() | |||||
| ?: "" | |||||
| if (raw.isEmpty()) return "" | |||||
| return normalizeText(raw.split(" - ").firstOrNull() ?: raw) | |||||
| } | |||||
| private fun normalizeText(value: String?): String = value?.trim()?.lowercase() ?: "" | |||||
| private fun resolveDeliveryOrderPickOrderId(pickOrderId: Long): Long? { | |||||
| return jdbcDao.queryForList( | |||||
| """ | |||||
| SELECT deliveryOrderPickOrderId AS dopoId | |||||
| FROM fpsmsdb.pick_order | |||||
| WHERE id = :pickOrderId AND deleted = 0 | |||||
| """.trimIndent(), | |||||
| mapOf("pickOrderId" to pickOrderId), | |||||
| ).firstOrNull() | |||||
| ?.get("dopoId") | |||||
| ?.let { (it as Number).toLong() } | |||||
| } | |||||
| /** Prefer pick_order that already has the same item on this ticket; else smallest pick_order.id. */ | |||||
| private fun resolveTargetPickOrderId(ticketPickOrderIds: List<Long>, itemId: Long): Long { | |||||
| val sortedIds = ticketPickOrderIds.sorted() | |||||
| val lines = pickOrderLineRepository.findAllByPickOrderIdInAndDeletedFalse(sortedIds) | |||||
| val pickOrderWithItem = lines | |||||
| .filter { it.item?.id == itemId } | |||||
| .mapNotNull { it.pickOrder?.id } | |||||
| .minOrNull() | |||||
| return pickOrderWithItem ?: sortedIds.first() | |||||
| } | |||||
| private fun nextCodeSequence(deliveryDate: LocalDate): Int { | |||||
| val prefix = codePrefix(deliveryDate) | |||||
| val suffixPattern = Regex("^${Regex.escape(prefix)}(\\d+)$") | |||||
| var maxSeq = 0 | |||||
| for (code in doReplenishmentRepository.findCodesByPrefix(prefix)) { | |||||
| suffixPattern.find(code)?.groupValues?.getOrNull(1)?.toIntOrNull()?.let { n -> | |||||
| if (n > maxSeq) maxSeq = n | |||||
| } | |||||
| } | |||||
| return maxSeq + 1 | |||||
| } | |||||
| private fun formatReplenishmentCode(deliveryDate: LocalDate, sequence: Int): String { | |||||
| return "${codePrefix(deliveryDate)}${sequence.toString().padStart(3, '0')}" | |||||
| } | |||||
| private fun codePrefix(deliveryDate: LocalDate): String { | |||||
| val ymd = deliveryDate.format(DateTimeFormatter.BASIC_ISO_DATE) | |||||
| return "RP-$ymd-" | |||||
| } | |||||
| /** 同一批次內相同來源行合併補貨數量。 */ | |||||
| private fun mergeSubmitLines( | |||||
| lines: List<SubmitDoReplenishmentLineRequest>, | |||||
| ): List<SubmitDoReplenishmentLineRequest> { | |||||
| if (lines.size <= 1) return lines | |||||
| val merged = linkedMapOf<String, SubmitDoReplenishmentLineRequest>() | |||||
| for (line in lines) { | |||||
| val key = "${line.sourceDoId}:${line.sourceDoLineId}" | |||||
| val existing = merged[key] | |||||
| if (existing == null) { | |||||
| merged[key] = line | |||||
| } else { | |||||
| merged[key] = existing.copy( | |||||
| replenishQty = existing.replenishQty.add(line.replenishQty), | |||||
| truckLaneCode = existing.truckLaneCode?.takeIf { it.isNotBlank() } ?: line.truckLaneCode, | |||||
| reason = existing.reason?.takeIf { it.isNotBlank() } ?: line.reason, | |||||
| ) | |||||
| } | |||||
| } | |||||
| return merged.values.toList() | |||||
| } | |||||
| /** 來源 DO 車線:優先 do_pick_order / do_pick_order_record,其次請求帶入值。 */ | |||||
| private fun resolveSourceDoTruckLaneCode( | |||||
| deliveryOrder: DeliveryOrder, | |||||
| requestTruckLaneCode: String?, | |||||
| ): String? { | |||||
| val sourceDoId = deliveryOrder.id ?: return requestTruckLaneCode?.trim()?.takeIf { it.isNotEmpty() } | |||||
| doPickOrderRepository.findByDoOrderIdAndDeletedFalse(sourceDoId) | |||||
| .mapNotNull { it.truckLanceCode?.trim()?.takeIf { code -> code.isNotEmpty() } } | |||||
| .firstOrNull() | |||||
| ?.let { return it } | |||||
| doPickOrderRecordRepository.findByDoOrderIdAndDeletedFalse(sourceDoId) | |||||
| .mapNotNull { it.truckLanceCode?.trim()?.takeIf { code -> code.isNotEmpty() } } | |||||
| .firstOrNull() | |||||
| ?.let { return it } | |||||
| return requestTruckLaneCode?.trim()?.takeIf { it.isNotEmpty() } | |||||
| } | |||||
| /** | |||||
| * Actual shipped qty per item on a completed source DO: sum of [stock_out_line.qty] | |||||
| * for the linked pick order line. Falls back to [fallbackQtyByItemId] when no pick link exists | |||||
| * (same rule as delivery note PDF). | |||||
| */ | |||||
| open fun resolveActualShippedQtyForDeliveryOrder( | |||||
| doId: Long, | |||||
| fallbackQtyByItemId: Map<Long, BigDecimal> = emptyMap(), | |||||
| ): Map<Long, BigDecimal> { | |||||
| val keys = fallbackQtyByItemId.keys.map { doId to it } | |||||
| if (keys.isEmpty()) { | |||||
| return emptyMap() | |||||
| } | |||||
| return resolveActualShippedQtyBySourceKeys(keys, keys.associateWith { fallbackQtyByItemId[it.second]!! }) | |||||
| .mapKeys { it.key.second } | |||||
| } | |||||
| private fun resolveActualShippedQtyBySourceKeys( | |||||
| keys: List<Pair<Long, Long>>, | |||||
| fallbackQtyByKey: Map<Pair<Long, Long>, BigDecimal>, | |||||
| ): Map<Pair<Long, Long>, BigDecimal> { | |||||
| if (keys.isEmpty()) { | |||||
| return emptyMap() | |||||
| } | |||||
| val doIds = keys.map { it.first }.distinct() | |||||
| val pickOrderIdByDoId = doIds.mapNotNull { doId -> | |||||
| resolvePickOrderIdForDo(doId)?.let { doId to it } | |||||
| }.toMap() | |||||
| val pickOrderIds = pickOrderIdByDoId.values.distinct() | |||||
| val pickOrderLines = if (pickOrderIds.isEmpty()) { | |||||
| emptyList() | |||||
| } else { | |||||
| pickOrderLineRepository.findAllByPickOrderIdInAndDeletedFalse(pickOrderIds) | |||||
| } | |||||
| val pickOrderLinesByPoId = pickOrderLines.groupBy { it.pickOrder?.id } | |||||
| val polIds = pickOrderLines.mapNotNull { it.id } | |||||
| val stockOutQtyByPolId = if (polIds.isEmpty()) { | |||||
| emptyMap() | |||||
| } else { | |||||
| stockOutLIneRepository.findAllByPickOrderLineIdInAndDeletedFalse(polIds) | |||||
| .groupBy { it.pickOrderLine?.id } | |||||
| .mapValues { (_, lines) -> | |||||
| lines.fold(BigDecimal.ZERO) { acc, line -> | |||||
| acc.add(BigDecimal.valueOf(line.qty ?: 0.0)) | |||||
| } | |||||
| } | |||||
| } | |||||
| return keys.associateWith { (doId, itemId) -> | |||||
| val pickOrderId = pickOrderIdByDoId[doId] | |||||
| val polId = pickOrderId?.let { poId -> | |||||
| pickOrderLinesByPoId[poId]?.firstOrNull { it.item?.id == itemId }?.id | |||||
| } | |||||
| if (polId != null) { | |||||
| stockOutQtyByPolId[polId] ?: BigDecimal.ZERO | |||||
| } else { | |||||
| fallbackQtyByKey[doId to itemId] ?: BigDecimal.ZERO | |||||
| } | |||||
| } | |||||
| } | |||||
| private fun resolvePickOrderIdForDo(doId: Long): Long? { | |||||
| pickOrderRepository.findByDeliveryOrderId(doId).firstOrNull()?.id?.let { return it } | |||||
| return doPickOrderLineRepository.findByDoOrderIdAndDeletedFalse(doId) | |||||
| .mapNotNull { it.pickOrderId } | |||||
| .firstOrNull() | |||||
| } | |||||
| private fun toResponses(entities: List<DoReplenishment>): List<DoReplenishmentResponse> { | |||||
| val uomIds = entities.mapNotNull { it.uomId }.distinct() | |||||
| val shortUomById = if (uomIds.isEmpty()) { | |||||
| emptyMap() | |||||
| } else { | |||||
| uomConversionRepository.findAllById(uomIds).associate { uom -> | |||||
| uom.id!! to (uom.udfShortDesc?.takeIf { it.isNotBlank() } ?: uom.code) | |||||
| } | |||||
| } | |||||
| val sourceLineIds = entities.mapNotNull { it.sourceDoLineId }.distinct() | |||||
| val sourceLineQtyById = if (sourceLineIds.isEmpty()) { | |||||
| emptyMap() | |||||
| } else { | |||||
| deliveryOrderLineRepository.findAllById(sourceLineIds).associate { line -> | |||||
| line.id!! to line.qty | |||||
| } | |||||
| } | |||||
| val dopoIds = entities.mapNotNull { it.deliveryOrderPickOrderId }.distinct() | |||||
| val dopoById = if (dopoIds.isEmpty()) { | |||||
| emptyMap() | |||||
| } else { | |||||
| deliveryOrderPickOrderRepository.findAllById(dopoIds).associateBy { it.id!! } | |||||
| } | |||||
| val sourceKeys = entities.map { it.sourceDoId!! to it.itemId!! }.distinct() | |||||
| val fallbackQtyBySourceKey = entities.associate { row -> | |||||
| (row.sourceDoId!! to row.itemId!!) to ( | |||||
| row.sourceDoLineId?.let { sourceLineQtyById[it] } ?: BigDecimal.ZERO | |||||
| ) | |||||
| } | |||||
| val actualShippedQtyBySourceKey = resolveActualShippedQtyBySourceKeys( | |||||
| keys = sourceKeys, | |||||
| fallbackQtyByKey = fallbackQtyBySourceKey, | |||||
| ) | |||||
| val polIds = entities.mapNotNull { it.pickOrderLineId }.distinct() | |||||
| val stockOutLinesByPolId = if (polIds.isEmpty()) { | |||||
| emptyMap() | |||||
| } else { | |||||
| stockOutLIneRepository.findAllInfoByPickOrderLineIdInAndDeletedFalse(polIds) | |||||
| .groupBy { it.pickOrderLineId } | |||||
| } | |||||
| return entities.map { row -> | |||||
| DoReplenishmentResponse( | |||||
| id = row.id!!, | |||||
| code = row.code!!, | |||||
| deliveryDate = row.deliveryDate!!, | |||||
| sourceDoId = row.sourceDoId!!, | |||||
| sourceDoCode = row.sourceDoCode, | |||||
| sourceDoLineId = row.sourceDoLineId!!, | |||||
| itemId = row.itemId!!, | |||||
| itemNo = row.itemNo, | |||||
| itemName = row.itemName, | |||||
| originalQty = actualShippedQtyBySourceKey[row.sourceDoId!! to row.itemId!!], | |||||
| actualPickQty = row.pickOrderLineId?.let { polId -> | |||||
| WorkbenchStockOutLinePickProgress.sumPickedQty( | |||||
| stockOutLinesByPolId[polId].orEmpty(), | |||||
| ) | |||||
| }, | |||||
| replenishQty = row.replenishQty!!, | |||||
| shortUom = row.uomId?.let { shortUomById[it] }, | |||||
| shopCode = row.shopCode, | |||||
| shopName = row.shopName, | |||||
| truckLaneCode = row.truckLaneCode, | |||||
| targetDoId = row.targetDoId, | |||||
| targetDoCode = row.targetDoCode, | |||||
| targetDoEstimatedArrivalDate = row.deliveryOrderPickOrderId?.let { dopoById[it]?.requiredDeliveryDate }, | |||||
| pickOrderLineId = row.pickOrderLineId, | |||||
| deliveryOrderPickOrderId = row.deliveryOrderPickOrderId, | |||||
| relateTicketNo = row.deliveryOrderPickOrderId?.let { dopoById[it]?.ticketNo }, | |||||
| status = row.status, | |||||
| reason = row.reason, | |||||
| created = row.created, | |||||
| ) | |||||
| } | |||||
| } | |||||
| } | |||||
| @@ -23,6 +23,7 @@ open class DoWorkbenchDopoAssignmentService( | |||||
| private val pickOrderRepository: PickOrderRepository, | private val pickOrderRepository: PickOrderRepository, | ||||
| private val suggestedPickLotWorkbenchService: SuggestedPickLotWorkbenchService, | private val suggestedPickLotWorkbenchService: SuggestedPickLotWorkbenchService, | ||||
| private val stockOutLineWorkbenchService: StockOutLineWorkbenchService, | private val stockOutLineWorkbenchService: StockOutLineWorkbenchService, | ||||
| private val doFloorSupplierSettingsService: DoFloorSupplierSettingsService, | |||||
| ) { | ) { | ||||
| @Transactional | @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 | * Same UX as [DoPickOrderAssignmentService.assignByLane] but candidates come from | ||||
| * [delivery_order_pick_order] (+ unassigned [pick_order]), not [do_pick_order]. | * [delivery_order_pick_order] (+ unassigned [pick_order]), not [do_pick_order]. | ||||
| */ | */ | ||||
| @@ -110,7 +112,7 @@ open class DoWorkbenchDopoAssignmentService( | |||||
| "4/F" -> "4/F" | "4/F" -> "4/F" | ||||
| else -> request.storeId | else -> request.storeId | ||||
| } | } | ||||
| println(" DEBUG: assignByLaneForWorkbench storeId=$actualStoreId date=${request.requiredDate} lane=${request.truckLanceCode} dep=${request.truckDepartureTime}") | |||||
| println(" DEBUG: assignByLaneForWorkbench storeId=$actualStoreId date=${request.requiredDate} lane=${request.truckLanceCode} dep=${request.truckDepartureTime} seq=${request.loadingSequence}") | |||||
| val params = mutableMapOf<String, Any>( | val params = mutableMapOf<String, Any>( | ||||
| "storeId" to actualStoreId, | "storeId" to actualStoreId, | ||||
| @@ -140,12 +142,26 @@ open class DoWorkbenchDopoAssignmentService( | |||||
| sql.append(" AND dop.truckDepartureTime = :depTime ") | sql.append(" AND dop.truckDepartureTime = :depTime ") | ||||
| params["depTime"] = depSqlTime | params["depTime"] = depSqlTime | ||||
| } | } | ||||
| if (request.loadingSequence != null) { | |||||
| sql.append(" AND dop.loadingSequence = :loadingSequence ") | |||||
| params["loadingSequence"] = request.loadingSequence | |||||
| } | |||||
| if (isisExtraReleaseType(request.releaseType)) { | |||||
| sql.append(WorkbenchReleaseTypeSupport.etraFamilySql()) | |||||
| } else { | |||||
| sql.append(WorkbenchReleaseTypeSupport.assignFilterSql(request.releaseType)) | |||||
| } | |||||
| // Fetch a batch of candidates and try atomic-assign sequentially. | // Fetch a batch of candidates and try atomic-assign sequentially. | ||||
| // This avoids forcing the frontend to refresh when a single picked candidate is concurrently assigned. | // This avoids forcing the frontend to refresh when a single picked candidate is concurrently assigned. | ||||
| val candidateLimit = 50 | val candidateLimit = 50 | ||||
| val maxRounds = 3 | val maxRounds = 3 | ||||
| sql.append(" ORDER BY dop.requiredDeliveryDate ASC, dop.truckDepartureTime ASC, dop.id ASC LIMIT $candidateLimit ") | |||||
| val shouldOrderBySequence = actualStoreId == "2/F" && request.loadingSequence == null | |||||
| if (shouldOrderBySequence) { | |||||
| sql.append(" ORDER BY dop.requiredDeliveryDate ASC, dop.truckDepartureTime ASC, dop.loadingSequence ASC, dop.id ASC LIMIT $candidateLimit ") | |||||
| } else { | |||||
| sql.append(" ORDER BY dop.requiredDeliveryDate ASC, dop.truckDepartureTime ASC, dop.id ASC LIMIT $candidateLimit ") | |||||
| } | |||||
| fun extractIds(rows: List<Map<String, Any?>>): List<Long> { | fun extractIds(rows: List<Map<String, Any?>>): List<Long> { | ||||
| if (rows.isEmpty()) return emptyList() | if (rows.isEmpty()) return emptyList() | ||||
| @@ -192,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 | @Transactional | ||||
| open fun assignByLaneForWorkbenchV1(request: AssignByLaneRequest): MessageResponse { | open fun assignByLaneForWorkbenchV1(request: AssignByLaneRequest): MessageResponse { | ||||
| val user = userRepository.findById(request.userId).orElse(null) | val user = userRepository.findById(request.userId).orElse(null) | ||||
| @@ -205,7 +224,7 @@ open class DoWorkbenchDopoAssignmentService( | |||||
| "4/F" -> "4/F" | "4/F" -> "4/F" | ||||
| else -> request.storeId | else -> request.storeId | ||||
| } | } | ||||
| println(" DEBUG: assignByLaneForWorkbenchV1 storeId=$actualStoreId date=${request.requiredDate} lane=${request.truckLanceCode} dep=${request.truckDepartureTime}") | |||||
| println(" DEBUG: assignByLaneForWorkbenchV1 storeId=$actualStoreId date=${request.requiredDate} lane=${request.truckLanceCode} dep=${request.truckDepartureTime} seq=${request.loadingSequence}") | |||||
| val params = mutableMapOf<String, Any>( | val params = mutableMapOf<String, Any>( | ||||
| "storeId" to actualStoreId, | "storeId" to actualStoreId, | ||||
| @@ -234,7 +253,21 @@ open class DoWorkbenchDopoAssignmentService( | |||||
| sql.append(" AND dop.truckDepartureTime = :depTime ") | sql.append(" AND dop.truckDepartureTime = :depTime ") | ||||
| params["depTime"] = depSqlTime | params["depTime"] = depSqlTime | ||||
| } | } | ||||
| sql.append(" ORDER BY dop.requiredDeliveryDate ASC, dop.truckDepartureTime ASC, dop.id ASC LIMIT 1 ") | |||||
| if (request.loadingSequence != null) { | |||||
| sql.append(" AND dop.loadingSequence = :loadingSequence ") | |||||
| params["loadingSequence"] = request.loadingSequence | |||||
| } | |||||
| if (isisExtraReleaseType(request.releaseType)) { | |||||
| sql.append(WorkbenchReleaseTypeSupport.etraFamilySql()) | |||||
| } else { | |||||
| sql.append(WorkbenchReleaseTypeSupport.assignFilterSql(request.releaseType)) | |||||
| } | |||||
| val shouldOrderBySequenceV1 = actualStoreId == "2/F" && request.loadingSequence == null | |||||
| if (shouldOrderBySequenceV1) { | |||||
| sql.append(" ORDER BY dop.requiredDeliveryDate ASC, dop.truckDepartureTime ASC, dop.loadingSequence ASC, dop.id ASC LIMIT 1 ") | |||||
| } else { | |||||
| sql.append(" ORDER BY dop.requiredDeliveryDate ASC, dop.truckDepartureTime ASC, dop.id ASC LIMIT 1 ") | |||||
| } | |||||
| val candidates = try { | val candidates = try { | ||||
| jdbcDao.queryForList(sql.toString(), params) | jdbcDao.queryForList(sql.toString(), params) | ||||
| @@ -283,6 +316,11 @@ open class DoWorkbenchDopoAssignmentService( | |||||
| } else null | } else null | ||||
| } | } | ||||
| private fun isisExtraReleaseType(releaseType: String?): Boolean { | |||||
| val n = releaseType?.trim()?.lowercase().orEmpty() | |||||
| return n == "isExtra" | |||||
| } | |||||
| private fun parseDepartureTimeToSql(raw: String?): Time? { | private fun parseDepartureTimeToSql(raw: String?): Time? { | ||||
| if (raw.isNullOrBlank()) return null | if (raw.isNullOrBlank()) return null | ||||
| val s = raw.trim() | val s = raw.trim() | ||||
| @@ -362,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( | val storeIdRow = jdbcDao.queryForList( | ||||
| "SELECT storeId FROM fpsmsdb.delivery_order_pick_order WHERE id = :id AND deleted = 0", | "SELECT storeId FROM fpsmsdb.delivery_order_pick_order WHERE id = :id AND deleted = 0", | ||||
| mapOf("id" to dopoId), | mapOf("id" to dopoId), | ||||
| ).firstOrNull() | ).firstOrNull() | ||||
| val storeIdKey = storeIdRow?.keys?.find { it.equals("storeId", true) } | 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) { | for (poId in poIds) { | ||||
| suggestedPickLotWorkbenchService.primeNextSingleLotSuggestionsForPickOrder( | suggestedPickLotWorkbenchService.primeNextSingleLotSuggestionsForPickOrder( | ||||
| pickOrderId = poId, | pickOrderId = poId, | ||||
| storeId = storeId, | |||||
| storeId = suggestionStoreId, | |||||
| excludeWarehouseCodes = null, | excludeWarehouseCodes = null, | ||||
| ) | ) | ||||
| stockOutLineWorkbenchService.ensureStockOutLinesForPickOrderNoHold(poId, userId) | stockOutLineWorkbenchService.ensureStockOutLinesForPickOrderNoHold(poId, userId) | ||||
| @@ -401,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) | |||||
| } | |||||
| } | } | ||||
| @@ -0,0 +1,55 @@ | |||||
| package com.ffii.fpsms.modules.deliveryOrder.service | |||||
| import java.util.Locale | |||||
| /** | |||||
| * 車線搜索正規化(search-do-lite-v2)。 | |||||
| * 實際 [com.ffii.fpsms.modules.pickOrder.entity.Truck] 編碼主要為 `車線-…` 或 `P06B_…`;未指派預設列為 `車線-X`(shopId 可為 null)。 | |||||
| */ | |||||
| object TruckLaneSearchSpec { | |||||
| const val UNASSIGNED_LANE_LABEL: String = "車線-X" | |||||
| sealed interface Mode { | |||||
| data object NoFilter : Mode | |||||
| /** 僅未指派:推算為 null/空白/字面量 車線-X(與預設車列一致) */ | |||||
| data object UnassignedOnly : Mode | |||||
| /** | |||||
| * 一般關鍵字:以 [needleLower](trim + [Locale.ROOT] lowercase)對推算車線做 [String.contains]。 | |||||
| * 不再因「`車線-` 開頭」額外併入未指派,避免搜 `車線-待1` 卻出現 `車線-X`;廣義條件(無車線欄位)仍由 [NoFilter] 帶出含 X 的列。 | |||||
| */ | |||||
| data class Keyword( | |||||
| val needleLower: String, | |||||
| ) : Mode | |||||
| } | |||||
| fun parse(raw: String?): Mode { | |||||
| val trimmed = raw?.trim().orEmpty() | |||||
| if (trimmed.isEmpty()) return Mode.NoFilter | |||||
| if (isUnassignedSearchToken(trimmed)) return Mode.UnassignedOnly | |||||
| return Mode.Keyword( | |||||
| needleLower = trimmed.lowercase(Locale.ROOT), | |||||
| ) | |||||
| } | |||||
| private fun isUnassignedSearchToken(trimmed: String): Boolean { | |||||
| if (trimmed.length == 1 && trimmed.equals("x", ignoreCase = true)) return true | |||||
| val normalized = trimmed.lowercase(Locale.ROOT).replace("车线", "車線") | |||||
| return normalized == "車線-x" | |||||
| } | |||||
| fun isUnassignedResolvedLane(calculated: String?): Boolean { | |||||
| if (calculated.isNullOrBlank()) return true | |||||
| return calculated.trim().equals(UNASSIGNED_LANE_LABEL, ignoreCase = true) | |||||
| } | |||||
| fun matches(mode: Mode, resolvedTruckLanceCode: String?): Boolean { | |||||
| when (mode) { | |||||
| Mode.NoFilter -> return true | |||||
| Mode.UnassignedOnly -> return isUnassignedResolvedLane(resolvedTruckLanceCode) | |||||
| is Mode.Keyword -> { | |||||
| val lane = resolvedTruckLanceCode?.trim()?.lowercase(Locale.ROOT).orEmpty() | |||||
| return lane.contains(mode.needleLower) | |||||
| } | |||||
| } | |||||
| } | |||||
| } | |||||
| @@ -0,0 +1,75 @@ | |||||
| package com.ffii.fpsms.modules.deliveryOrder.service | |||||
| /** | |||||
| * Workbench [delivery_order_pick_order.releaseType] values and SQL filters. | |||||
| * Etra UI uses [etraFamilySql] (standalone `isExtra` + merged `isExtrabatch` / `isExtrasingle`). | |||||
| */ | |||||
| object WorkbenchReleaseTypeSupport { | |||||
| const val BATCH = "batch" | |||||
| const val SINGLE = "single" | |||||
| const val IS_EXTRA_BATCH = "isExtrabatch" | |||||
| const val IS_EXTRA_SINGLE = "isExtrasingle" | |||||
| const val LEGACY_IS_EXTRA = "isExtra" | |||||
| fun batchFamilyTypes(): List<String> = listOf(BATCH, IS_EXTRA_BATCH) | |||||
| 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) | |||||
| // 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", "etra", "etrafamily" -> etraFamilySql(column) | |||||
| else -> "" | |||||
| } | |||||
| } | |||||
| fun batchFamilySql(column: String = "dop.releaseType"): String = | |||||
| " AND LOWER(COALESCE($column, '')) IN ('batch', 'isextrabatch') " | |||||
| fun singleFamilySql(column: String = "dop.releaseType"): String = | |||||
| " AND LOWER(COALESCE($column, '')) IN ('single', 'isextrasingle') " | |||||
| 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 | |||||
| isSingleRelease -> SINGLE | |||||
| else -> BATCH | |||||
| } | |||||
| /** [TI-M] merged workbench ticket release type (batch-family merge). */ | |||||
| fun mergeTicketReleaseType(isSingleRelease: Boolean): String = | |||||
| if (isSingleRelease) IS_EXTRA_SINGLE else IS_EXTRA_BATCH | |||||
| fun upgradedReleaseTypeIfNeeded(currentType: String?, isExtraRelease: Boolean, isSingleRelease: Boolean): String? { | |||||
| if (!isExtraRelease) return null | |||||
| val cur = currentType?.trim()?.lowercase().orEmpty() | |||||
| return when { | |||||
| isSingleRelease && cur == SINGLE.lowercase() -> IS_EXTRA_SINGLE | |||||
| !isSingleRelease && cur == BATCH.lowercase() -> IS_EXTRA_BATCH | |||||
| else -> null | |||||
| } | |||||
| } | |||||
| } | |||||
| @@ -4,6 +4,7 @@ import com.ffii.fpsms.modules.deliveryOrder.entity.DeliveryOrder | |||||
| import com.ffii.fpsms.modules.deliveryOrder.entity.models.DeliveryOrderInfo | import com.ffii.fpsms.modules.deliveryOrder.entity.models.DeliveryOrderInfo | ||||
| import com.ffii.fpsms.modules.deliveryOrder.enums.DeliveryOrderStatus | import com.ffii.fpsms.modules.deliveryOrder.enums.DeliveryOrderStatus | ||||
| import com.ffii.fpsms.modules.deliveryOrder.service.DeliveryOrderService | import com.ffii.fpsms.modules.deliveryOrder.service.DeliveryOrderService | ||||
| import com.ffii.fpsms.modules.deliveryOrder.service.DoReplenishmentService | |||||
| import com.ffii.fpsms.modules.deliveryOrder.web.models.SaveDeliveryOrderRequest | import com.ffii.fpsms.modules.deliveryOrder.web.models.SaveDeliveryOrderRequest | ||||
| import com.ffii.fpsms.modules.deliveryOrder.web.models.SaveDeliveryOrderResponse | import com.ffii.fpsms.modules.deliveryOrder.web.models.SaveDeliveryOrderResponse | ||||
| import com.ffii.fpsms.modules.deliveryOrder.web.models.SaveDeliveryOrderStatusRequest | import com.ffii.fpsms.modules.deliveryOrder.web.models.SaveDeliveryOrderStatusRequest | ||||
| @@ -44,7 +45,10 @@ import com.ffii.fpsms.modules.deliveryOrder.web.models.Check4FTruckBatchResponse | |||||
| import com.ffii.fpsms.modules.deliveryOrder.web.models.DoSearchRowResponse | import com.ffii.fpsms.modules.deliveryOrder.web.models.DoSearchRowResponse | ||||
| import com.ffii.fpsms.modules.deliveryOrder.entity.models.DeliveryOrderInfoLite | import com.ffii.fpsms.modules.deliveryOrder.entity.models.DeliveryOrderInfoLite | ||||
| import com.ffii.fpsms.modules.deliveryOrder.entity.models.DeliveryOrderInfoLiteDto | import com.ffii.fpsms.modules.deliveryOrder.entity.models.DeliveryOrderInfoLiteDto | ||||
| import com.ffii.fpsms.modules.deliveryOrder.web.models.DoReplenishmentResponse | |||||
| import com.ffii.fpsms.modules.deliveryOrder.web.models.SubmitDoReplenishmentRequest | |||||
| import org.slf4j.LoggerFactory | import org.slf4j.LoggerFactory | ||||
| import java.time.LocalDate | |||||
| @RequestMapping("/do") | @RequestMapping("/do") | ||||
| @RestController | @RestController | ||||
| @@ -52,7 +56,7 @@ class DeliveryOrderController( | |||||
| private val deliveryOrderService: DeliveryOrderService, | private val deliveryOrderService: DeliveryOrderService, | ||||
| private val stockInLineService: StockInLineService, | private val stockInLineService: StockInLineService, | ||||
| private val doPickOrderService: DoPickOrderService, | private val doPickOrderService: DoPickOrderService, | ||||
| private val doReplenishmentService: DoReplenishmentService, | |||||
| ) { | ) { | ||||
| private val log = LoggerFactory.getLogger(javaClass) | private val log = LoggerFactory.getLogger(javaClass) | ||||
| @@ -70,7 +74,9 @@ class DeliveryOrderController( | |||||
| estimatedArrivalDate = request.estimatedArrivalDate, | estimatedArrivalDate = request.estimatedArrivalDate, | ||||
| pageNum = request.pageNum, | pageNum = request.pageNum, | ||||
| pageSize = request.pageSize, | pageSize = request.pageSize, | ||||
| truckLanceCode = request.truckLanceCode | |||||
| truckLanceCode = request.truckLanceCode, | |||||
| floor = request.floor, | |||||
| isExtra = request.isExtra, | |||||
| ) | ) | ||||
| } | } | ||||
| @@ -86,6 +92,27 @@ class DeliveryOrderController( | |||||
| estimatedArrivalDate = request.estimatedArrivalDate, | estimatedArrivalDate = request.estimatedArrivalDate, | ||||
| pageNum = request.pageNum, | pageNum = request.pageNum, | ||||
| pageSize = request.pageSize, | pageSize = request.pageSize, | ||||
| floor = request.floor, | |||||
| isExtra = request.isExtra, | |||||
| ) | |||||
| } | |||||
| /** | |||||
| * DO 輕量搜索 v2:車線關鍵字正規化(`車線-X`/`x`/`車線-` 前綴併入未指派)、 | |||||
| * 允許供應商條件下分批掃描,避免單次載入過大;請求體同 [searchDoLite]。 | |||||
| */ | |||||
| @PostMapping("/search-do-lite-v2") | |||||
| fun searchDoLiteV2(@RequestBody request: SearchDeliveryOrderInfoRequest): RecordsRes<DeliveryOrderInfoLiteDto> { | |||||
| return deliveryOrderService.searchDoLiteByPageV2( | |||||
| code = request.code, | |||||
| shopName = request.shopName, | |||||
| status = request.status, | |||||
| estimatedArrivalDate = request.estimatedArrivalDate, | |||||
| pageNum = request.pageNum, | |||||
| pageSize = request.pageSize, | |||||
| truckLanceCode = request.truckLanceCode, | |||||
| floor = request.floor, | |||||
| isExtra = request.isExtra, | |||||
| ) | ) | ||||
| } | } | ||||
| @@ -99,6 +126,29 @@ class DeliveryOrderController( | |||||
| return deliveryOrderService.getDetailedDo(id); | return deliveryOrderService.getDetailedDo(id); | ||||
| } | } | ||||
| @PostMapping("/replenishment") | |||||
| fun submitReplenishment( | |||||
| @Valid @RequestBody request: SubmitDoReplenishmentRequest, | |||||
| ): List<DoReplenishmentResponse> { | |||||
| return doReplenishmentService.submit(request) | |||||
| } | |||||
| @GetMapping("/replenishment") | |||||
| fun listReplenishment( | |||||
| @RequestParam(required = false) deliveryDate: LocalDate?, | |||||
| @RequestParam(required = false) status: String?, | |||||
| ): List<DoReplenishmentResponse> { | |||||
| return doReplenishmentService.list(deliveryDate, status) | |||||
| } | |||||
| @GetMapping("/replenishment/for-batch-release") | |||||
| fun listReplenishmentForBatchRelease( | |||||
| @RequestParam(required = false) truckLaneCode: String?, | |||||
| @RequestParam(required = false) shopName: String?, | |||||
| ): List<DoReplenishmentResponse> { | |||||
| return doReplenishmentService.listForBatchRelease(truckLaneCode, shopName) | |||||
| } | |||||
| @GetMapping("/search-code/{code}") | @GetMapping("/search-code/{code}") | ||||
| fun searchByCode(@PathVariable code: String): List<DeliveryOrderInfo> { | fun searchByCode(@PathVariable code: String): List<DeliveryOrderInfo> { | ||||
| return deliveryOrderService.searchByCode(code); | return deliveryOrderService.searchByCode(code); | ||||
| @@ -24,6 +24,11 @@ import org.springframework.http.HttpStatus | |||||
| import org.springframework.http.MediaType | import org.springframework.http.MediaType | ||||
| import org.springframework.http.ResponseEntity | import org.springframework.http.ResponseEntity | ||||
| import java.time.format.DateTimeFormatter | import java.time.format.DateTimeFormatter | ||||
| import jakarta.servlet.http.HttpServletResponse | |||||
| import jakarta.validation.Valid | |||||
| import net.sf.jasperreports.engine.JasperExportManager | |||||
| import net.sf.jasperreports.engine.JasperPrint | |||||
| import java.io.OutputStream | |||||
| @RestController | @RestController | ||||
| @RequestMapping("/doPickOrder/workbench") | @RequestMapping("/doPickOrder/workbench") | ||||
| class DoWorkbenchController( | class DoWorkbenchController( | ||||
| @@ -96,23 +101,50 @@ class DoWorkbenchController( | |||||
| ) | ) | ||||
| } | } | ||||
| /** All Etra workbench tickets for a day, grouped by shop → truck (see [DoWorkbenchMainService.getWorkbenchEtraLaneSummary]). */ | |||||
| @GetMapping("/summary-is-etra") | |||||
| fun getWorkbenchEtraSummary( | |||||
| @RequestParam(required = false) @DateTimeFormat(iso = DateTimeFormat.ISO.DATE) requiredDate: LocalDate?, | |||||
| ): List<WorkbenchEtraShopLaneGroup> = | |||||
| doWorkbenchMainService.getWorkbenchEtraLaneSummary(requiredDate) | |||||
| /** Past-date backlog tickets from `delivery_order_pick_order` (not `do_pick_order`). */ | /** Past-date backlog tickets from `delivery_order_pick_order` (not `do_pick_order`). */ | ||||
| @GetMapping("/released") | @GetMapping("/released") | ||||
| fun getWorkbenchReleasedDoPickOrders( | fun getWorkbenchReleasedDoPickOrders( | ||||
| @RequestParam(required = false) shopName: String?, | @RequestParam(required = false) shopName: String?, | ||||
| @RequestParam(required = false) storeId: String?, | @RequestParam(required = false) storeId: String?, | ||||
| @RequestParam(required = false) truck: 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> { | ): List<ReleasedDoPickOrderListItem> { | ||||
| return doWorkbenchMainService.findWorkbenchReleasedDeliveryOrderPickOrdersForSelection(shopName, storeId, truck) | |||||
| return doWorkbenchMainService.findWorkbenchReleasedDeliveryOrderPickOrdersForSelection( | |||||
| shopName, | |||||
| storeId, | |||||
| truck, | |||||
| releaseTypeFilter = releaseType, | |||||
| floor = floor, | |||||
| ) | |||||
| } | } | ||||
| @GetMapping("/released-today") | @GetMapping("/released-today") | ||||
| fun getWorkbenchReleasedDoPickOrdersToday( | fun getWorkbenchReleasedDoPickOrdersToday( | ||||
| @RequestParam(required = false) shopName: String?, | @RequestParam(required = false) shopName: String?, | ||||
| @RequestParam(required = false) storeId: String?, | @RequestParam(required = false) storeId: String?, | ||||
| @RequestParam(required = false) truck: String? | |||||
| @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> { | ): List<ReleasedDoPickOrderListItem> { | ||||
| return doWorkbenchMainService.findWorkbenchReleasedDeliveryOrderPickOrdersForSelectionToday(shopName, storeId, truck) | |||||
| return doWorkbenchMainService.findWorkbenchReleasedDeliveryOrderPickOrdersForSelectionToday( | |||||
| shopName, | |||||
| storeId, | |||||
| truck, | |||||
| requiredDeliveryDate = requiredDate, | |||||
| releaseTypeFilter = releaseType, | |||||
| floor = floor, | |||||
| ) | |||||
| } | } | ||||
| @PostMapping("/assign-by-delivery-order-pick-order-id") | @PostMapping("/assign-by-delivery-order-pick-order-id") | ||||
| @@ -152,19 +184,40 @@ class DoWorkbenchController( | |||||
| */ | */ | ||||
| @PostMapping("/batch-release/async-v2") | @PostMapping("/batch-release/async-v2") | ||||
| fun startWorkbenchBatchReleaseAsyncV2( | fun startWorkbenchBatchReleaseAsyncV2( | ||||
| @RequestBody ids: List<Long>, | |||||
| @RequestBody request: WorkbenchBatchReleaseRequest, | |||||
| @RequestParam(defaultValue = "1") userId: Long | |||||
| ): MessageResponse { | |||||
| return doWorkbenchReleaseService.startBatchReleaseAsyncV2( | |||||
| request.ids, | |||||
| userId, | |||||
| request.mergeExtraIntoLaneTicket, | |||||
| ) | |||||
| } | |||||
| /** | |||||
| * One delivery order, same release pipeline as [startWorkbenchBatchReleaseAsyncV2], but | |||||
| * [delivery_order_pick_order.releaseType] = `single` and ticket prefix `TI-S-` (not batch / `TI-B-`). | |||||
| * Body: JSON number (mirrors [DoPickOrderController.startBatchReleaseAsyncSingle]). | |||||
| */ | |||||
| @PostMapping("/batch-release/async-single-v2") | |||||
| fun startWorkbenchBatchReleaseAsyncSingleV2( | |||||
| @RequestBody doId: Long, | |||||
| @RequestParam(defaultValue = "1") userId: Long | @RequestParam(defaultValue = "1") userId: Long | ||||
| ): MessageResponse { | ): MessageResponse { | ||||
| return doWorkbenchReleaseService.startBatchReleaseAsyncV2(ids, userId) | |||||
| return doWorkbenchReleaseService.startBatchReleaseAsyncSingleV2(listOf(doId), userId) | |||||
| } | } | ||||
| /** Synchronous batch release V2 (same semantics as async-v2; for tools / tests). */ | /** Synchronous batch release V2 (same semantics as async-v2; for tools / tests). */ | ||||
| @PostMapping("/batch-release/sync-v2") | @PostMapping("/batch-release/sync-v2") | ||||
| fun workbenchBatchReleaseSyncV2( | fun workbenchBatchReleaseSyncV2( | ||||
| @RequestBody ids: List<Long>, | |||||
| @RequestBody request: WorkbenchBatchReleaseRequest, | |||||
| @RequestParam(defaultValue = "1") userId: Long | @RequestParam(defaultValue = "1") userId: Long | ||||
| ): MessageResponse { | ): MessageResponse { | ||||
| return doWorkbenchReleaseService.releaseBatchV2(ids, userId) | |||||
| return doWorkbenchReleaseService.releaseBatchV2( | |||||
| request.ids, | |||||
| userId, | |||||
| request.mergeExtraIntoLaneTicket, | |||||
| ) | |||||
| } | } | ||||
| @GetMapping("/batch-release/progress/{jobId}") | @GetMapping("/batch-release/progress/{jobId}") | ||||
| @@ -172,6 +225,22 @@ class DoWorkbenchController( | |||||
| return doWorkbenchReleaseService.getBatchReleaseProgress(jobId) | return doWorkbenchReleaseService.getBatchReleaseProgress(jobId) | ||||
| } | } | ||||
| /** Case 3: unassigned plain batch/single + isExtra tickets on the same lane (for merge UI). */ | |||||
| @GetMapping("/merge-ticket-candidates") | |||||
| fun getWorkbenchMergeTicketCandidates( | |||||
| @RequestParam @DateTimeFormat(iso = DateTimeFormat.ISO.DATE) requiredDate: LocalDate, | |||||
| @RequestParam(required = false) shopSearch: String?, | |||||
| ): WorkbenchMergeTicketCandidatesResponse = | |||||
| doWorkbenchReleaseService.getMergeTicketCandidates(requiredDate, shopSearch) | |||||
| /** Case 3 / 3b: merge batch/single or existing [TI-M] + isExtra into [TI-M]. */ | |||||
| @PostMapping("/merge-tickets") | |||||
| fun mergeWorkbenchTickets(@RequestBody request: WorkbenchMergeTicketsRequest): MessageResponse = | |||||
| doWorkbenchReleaseService.mergeTicketsCase3( | |||||
| request.batchOrSingleDopoId, | |||||
| request.isExtraDopoId, | |||||
| ) | |||||
| @GetMapping("/ticket-release-table/{startDate}&{endDate}") | @GetMapping("/ticket-release-table/{startDate}&{endDate}") | ||||
| fun getWorkbenchTicketReleaseTable( | fun getWorkbenchTicketReleaseTable( | ||||
| @PathVariable startDate: LocalDate, | @PathVariable startDate: LocalDate, | ||||
| @@ -203,6 +272,20 @@ class DoWorkbenchController( | |||||
| doWorkbenchMainService.printDeliveryNoteWorkbench(request) | doWorkbenchMainService.printDeliveryNoteWorkbench(request) | ||||
| } | } | ||||
| @PostMapping("/DN") | |||||
| fun downloadWorkbenchDN( | |||||
| @Valid @RequestBody request: ExportDeliveryNoteRequest, | |||||
| response: HttpServletResponse, | |||||
| ) { | |||||
| response.characterEncoding = "utf-8" | |||||
| response.contentType = "application/pdf" | |||||
| val out: OutputStream = response.outputStream | |||||
| val pdf = doWorkbenchMainService.exportDeliveryNoteWorkbench(request) | |||||
| val jasperPrint = pdf["report"] as JasperPrint | |||||
| response.addHeader("filename", "${pdf["filename"]}.pdf") | |||||
| out.write(JasperExportManager.exportReportToPdf(jasperPrint)) | |||||
| } | |||||
| @GetMapping("/print-DNLabels") | @GetMapping("/print-DNLabels") | ||||
| fun printWorkbenchDNLabels(@ModelAttribute request: PrintDNLabelsRequest) { | fun printWorkbenchDNLabels(@ModelAttribute request: PrintDNLabelsRequest) { | ||||
| doWorkbenchMainService.printDNLabelsWorkbench(request) | doWorkbenchMainService.printDNLabelsWorkbench(request) | ||||
| @@ -18,6 +18,12 @@ data class DoDetailResponse( | |||||
| @JsonFormat(pattern = "yyyy-MM-dd") | @JsonFormat(pattern = "yyyy-MM-dd") | ||||
| val completeDate: LocalDateTime?, | val completeDate: LocalDateTime?, | ||||
| val status: String?, | val status: String?, | ||||
| /** 加單 DO(M18 加單專用同步) */ | |||||
| val isExtra: Boolean = false, | |||||
| /** 揀貨員名稱(來源:delivery_order_pick_order.handlerName) */ | |||||
| val handlerName: String? = null, | |||||
| /** 來源 DO 車線(do_pick_order / delivery_order_pick_order) */ | |||||
| val truckLaneCode: String? = null, | |||||
| val deliveryOrderLines: List<DoDetailLineResponse> | val deliveryOrderLines: List<DoDetailLineResponse> | ||||
| ) | ) | ||||
| @@ -25,12 +31,18 @@ data class DoDetailLineResponse( | |||||
| val id: Long, | val id: Long, | ||||
| val itemNo: String?, | val itemNo: String?, | ||||
| val qty: java.math.BigDecimal?, | val qty: java.math.BigDecimal?, | ||||
| /** Sum of stock_out_line.qty for the linked pick order line; falls back to [qty] when unavailable. */ | |||||
| val actualShippedQty: java.math.BigDecimal?, | |||||
| val price: java.math.BigDecimal?, | val price: java.math.BigDecimal?, | ||||
| val status: String?, | val status: String?, | ||||
| val itemName: String?, | val itemName: String?, | ||||
| val uom: String?, | val uom: String?, | ||||
| val uomCode: String?, | val uomCode: String?, | ||||
| val shortUom: String?, | val shortUom: String?, | ||||
| /** Sum of (inQty - outQty - holdQty) on AVAILABLE lot lines for this item. */ | |||||
| val stockQty: java.math.BigDecimal?, | |||||
| /** `available` when stockQty >= qty, else `insufficient`. */ | |||||
| val availableStatus: String?, | |||||
| ) | ) | ||||
| data class StoreLaneSummary( | data class StoreLaneSummary( | ||||
| val storeId: String, | val storeId: String, | ||||
| @@ -49,7 +61,18 @@ data class LaneBtn( | |||||
| val unassigned: Int, | val unassigned: Int, | ||||
| val total: Int, | val total: Int, | ||||
| // 同一 truckLanceCode + loadingSequence 的 handler 去重后逗号拼接 | // 同一 truckLanceCode + loadingSequence 的 handler 去重后逗号拼接 | ||||
| val handlerName: String? = null | |||||
| val handlerName: String? = null, | |||||
| /** Workbench Etra lane: `delivery_order_pick_order.storeId` (2/F, 4/F, …) for assign / modal scope */ | |||||
| val storeId: String? = null, | |||||
| /** Workbench Etra / lane row: `truckDepartureTime` as ISO local time string for assign-by-lane */ | |||||
| val truckDepartureTime: String? = null, | |||||
| ) | |||||
| /** Etra family tickets (`isExtra` / `isExtrabatch` / `isExtrasingle`) for a day, grouped by shop then truck. */ | |||||
| data class WorkbenchEtraShopLaneGroup( | |||||
| val shopCode: String?, | |||||
| val shopName: String?, | |||||
| val lanes: List<LaneBtn>, | |||||
| ) | ) | ||||
| data class AssignByLaneRequest( | data class AssignByLaneRequest( | ||||
| val userId: Long, | val userId: Long, | ||||
| @@ -57,9 +80,12 @@ data class AssignByLaneRequest( | |||||
| val truckDepartureTime: String?, // 可选:限定出车时间 | val truckDepartureTime: String?, // 可选:限定出车时间 | ||||
| val truckLanceCode: String , | val truckLanceCode: String , | ||||
| val loadingSequence: Int? = null, | val loadingSequence: Int? = null, | ||||
| val requiredDate: LocalDate? // 必填:车道编号 | |||||
| val requiredDate: LocalDate?, // 必填:车道编号 | |||||
| /** When `isExtra`, assignment candidates are limited to `releaseType = isExtra` rows. */ | |||||
| val releaseType: String? = null, | |||||
| ) | ) | ||||
| data class DoPickOrderSummaryItem( | data class DoPickOrderSummaryItem( | ||||
| @JsonFormat(pattern = "HH:mm") | |||||
| val truckDepartureTime: java.time.LocalTime?, | val truckDepartureTime: java.time.LocalTime?, | ||||
| val truckLanceCode: String?, | val truckLanceCode: String?, | ||||
| val loadingSequence: Int?, | val loadingSequence: Int?, | ||||
| @@ -101,11 +127,13 @@ interface DoSearchRowProjection { | |||||
| } | } | ||||
| data class ReleasedDoPickOrderListItem( | data class ReleasedDoPickOrderListItem( | ||||
| val id: Long, // doPickOrderId,用於 assign | val id: Long, // doPickOrderId,用於 assign | ||||
| @JsonFormat(pattern = "yyyy-MM-dd") | |||||
| val requiredDeliveryDate: LocalDate?, // Date 欄 | val requiredDeliveryDate: LocalDate?, // Date 欄 | ||||
| val shopCode: String?, // Shop | val shopCode: String?, // Shop | ||||
| val shopName: String?, // Shop | val shopName: String?, // Shop | ||||
| val storeId: String?, // 2/F or 4/F | val storeId: String?, // 2/F or 4/F | ||||
| val truckLanceCode: String?, // Truck (Lane) | val truckLanceCode: String?, // Truck (Lane) | ||||
| @JsonFormat(pattern = "HH:mm") | |||||
| val truckDepartureTime: LocalTime?, // Truck 時間 | val truckDepartureTime: LocalTime?, // Truck 時間 | ||||
| val deliveryOrderCodes: List<String> // 多個 DO code,前端換行顯示 | val deliveryOrderCodes: List<String> // 多個 DO code,前端換行顯示 | ||||
| ) | ) | ||||
| @@ -0,0 +1,62 @@ | |||||
| package com.ffii.fpsms.modules.deliveryOrder.web.models | |||||
| import com.fasterxml.jackson.annotation.JsonFormat | |||||
| import jakarta.validation.Valid | |||||
| import jakarta.validation.constraints.NotEmpty | |||||
| import jakarta.validation.constraints.NotNull | |||||
| import jakarta.validation.constraints.Positive | |||||
| import java.math.BigDecimal | |||||
| import java.time.LocalDate | |||||
| import java.time.LocalDateTime | |||||
| data class SubmitDoReplenishmentLineRequest( | |||||
| @field:NotNull | |||||
| @field:JsonFormat(pattern = "yyyy-MM-dd") | |||||
| val deliveryDate: LocalDate, | |||||
| @field:NotNull | |||||
| val sourceDoId: Long, | |||||
| @field:NotNull | |||||
| val sourceDoLineId: Long, | |||||
| @field:NotNull | |||||
| @field:Positive | |||||
| val replenishQty: BigDecimal, | |||||
| val truckLaneCode: String? = null, | |||||
| val reason: String? = null, | |||||
| ) | |||||
| data class SubmitDoReplenishmentRequest( | |||||
| @field:NotEmpty | |||||
| @field:Valid | |||||
| val lines: List<SubmitDoReplenishmentLineRequest>, | |||||
| ) | |||||
| data class DoReplenishmentResponse( | |||||
| val id: Long, | |||||
| val code: String, | |||||
| @JsonFormat(pattern = "yyyy-MM-dd") | |||||
| val deliveryDate: LocalDate, | |||||
| val sourceDoId: Long, | |||||
| val sourceDoCode: String?, | |||||
| val sourceDoLineId: Long, | |||||
| val itemId: Long, | |||||
| val itemNo: String?, | |||||
| val itemName: String?, | |||||
| val originalQty: BigDecimal?, | |||||
| val actualPickQty: BigDecimal?, | |||||
| val replenishQty: BigDecimal, | |||||
| val shortUom: String?, | |||||
| val shopCode: String?, | |||||
| val shopName: String?, | |||||
| val truckLaneCode: String?, | |||||
| val targetDoId: Long?, | |||||
| val targetDoCode: String?, | |||||
| @JsonFormat(pattern = "yyyy-MM-dd") | |||||
| val targetDoEstimatedArrivalDate: LocalDate?, | |||||
| val pickOrderLineId: Long?, | |||||
| val deliveryOrderPickOrderId: Long?, | |||||
| val relateTicketNo: String?, | |||||
| val status: String, | |||||
| val reason: String?, | |||||
| @JsonFormat(pattern = "yyyy-MM-dd'T'HH:mm:ss") | |||||
| val created: LocalDateTime?, | |||||
| ) | |||||
| @@ -3,4 +3,5 @@ package com.ffii.fpsms.modules.deliveryOrder.web.models | |||||
| data class ExportDNLabelsRequest ( | data class ExportDNLabelsRequest ( | ||||
| val doPickOrderId: Long, | val doPickOrderId: Long, | ||||
| val numOfCarton: Int, | val numOfCarton: Int, | ||||
| val blankCartonNumber: Boolean? = false, | |||||
| ) | ) | ||||
| @@ -4,5 +4,6 @@ data class PrintDNLabelsRequest ( | |||||
| val doPickOrderId: Long, | val doPickOrderId: Long, | ||||
| val printerId: Long, | val printerId: Long, | ||||
| val printQty: Int?, | val printQty: Int?, | ||||
| val numOfCarton: Int | |||||
| val numOfCarton: Int, | |||||
| val blankCartonNumber: Boolean? = false, | |||||
| ) | ) | ||||
| @@ -21,7 +21,8 @@ data class ReleaseDoResult( | |||||
| val truckDepartureTime: LocalTime?, | val truckDepartureTime: LocalTime?, | ||||
| val truckLanceCode: String?, | val truckLanceCode: String?, | ||||
| val loadingSequence: Int? | |||||
| val loadingSequence: Int?, | |||||
| val isExtra: Boolean = false, | |||||
| ) | ) | ||||
| data class SearchDeliveryOrderInfoRequest( | data class SearchDeliveryOrderInfoRequest( | ||||
| val code: String?, | val code: String?, | ||||
| @@ -30,5 +31,9 @@ data class SearchDeliveryOrderInfoRequest( | |||||
| val estimatedArrivalDate: LocalDateTime?, | val estimatedArrivalDate: LocalDateTime?, | ||||
| val pageSize: Int?, | val pageSize: Int?, | ||||
| val pageNum: Int?, | val pageNum: Int?, | ||||
| val truckLanceCode: String? | |||||
| val truckLanceCode: String?, | |||||
| /** `ALL`/`All`/null:P06B+P07+P06D+P06Y;`2F`:P07+P06D+P06Y ;`4F`:P06B。車線-X 亦依供應商歸屬出現在對應樓層。 */ | |||||
| val floor: String? = null, | |||||
| /** null:不篩 isExtra;true/false:只顯示加單或非加單 DO */ | |||||
| val isExtra: Boolean? = null, | |||||
| ) | ) | ||||