| @@ -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,643 @@ | |||
| --- | |||
| name: fp-mtms-version-checklist | |||
| description: >- | |||
| Updates the FP-MTMS Version Programs and Functions Checklist Excel on each | |||
| developer's synced SharePoint folder. Use only when the user explicitly | |||
| invokes this skill or asks to update the FP-MTMS version control checklist, | |||
| page/program version sheet, or function version sheet. | |||
| 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 **F — Name of Function within | |||
| Program** (English symbol + source path only; see Column F 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 I / history | |||
| L,O,R) must also list **file + line range + short per-line explanation** — | |||
| see **Major Changes: file, lines, explanation** below. | |||
| Prefer EN/ZH pairs from `src/i18n/en/*` and `src/i18n/zh/*` for menu and | |||
| feature terms. Enable Excel wrap text when a cell has multiple lines. | |||
| ## 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.** | |||
| Do not edit `.url` shortcuts or Downloads copies unless the user explicitly asks. | |||
| Use `openpyxl` to read/write. If the 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). | |||
| 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). | |||
| 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 | → **deleted** (dropped) | | |||
| Then write the new change into **Latest** (version/date, highlights, Developed By). | |||
| ### 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 drop old 3rd; write new into D/F/G. | |||
| Leave identity columns A–C and purpose E unchanged unless the user asked to | |||
| change them (purpose may be refreshed if the preview says so). | |||
| ### Functions in Page&Program — fields that shift together | |||
| - **Latest**: H (Function Version & Date), I (Major Changes), J (Developed By) | |||
| - **1st**: K, L, M | |||
| - **2nd**: N, O, P | |||
| - **3rd**: Q, R, S | |||
| Shift: `(H,I,J) → (K,L,M) → (N,O,P) → (Q,R,S)` then drop old 3rd; write new into H/I/J. | |||
| Leave A–G (Ref, subsystem, page, page version/purpose, function name/highlight) | |||
| unchanged unless the preview explicitly updates them. | |||
| Always show the shift in the preview (old Latest → new 1st, etc.). | |||
| ### Function history only when that function changed (required) | |||
| **Do not** push or invent a Functions-sheet history slot for a commit / release | |||
| that did **not** change the symbols listed in column F for that row. | |||
| - **Page&Program Name** may still advance Latest / history for a page-level | |||
| release (e.g. backend-only fix under the same page). | |||
| - **Functions in Page&Program**: shift H→K→N→Q **only** when this row’s | |||
| function(s) actually changed (diff touches the verified F symbol(s) / paths). | |||
| - If a commit updates the **page** but not this function: | |||
| - You may refresh column **D** (Latest Page Version) to match the page row. | |||
| - Leave **H/I/J** and previous function history **unchanged**. | |||
| - 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 H = v1.0.2; optional D = v1.0.3; no fake | |||
| v1.0.3 function history slot. | |||
| ### Major Changes: file, lines, explanation (required) | |||
| For every **Major Changes Highlights** cell written into Excel (Page sheet | |||
| Latest F and history I/L/O; Functions sheet Latest I and history L/O/R), | |||
| 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 F, plus closely | |||
| related hunks in those files for this change). | |||
| - Page-sheet Major Changes may summarize all files for that page release; | |||
| Functions-sheet Major Changes stay scoped to that function row’s F symbols | |||
| 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 | Name of Subsystem / Module / Menu Selection | | |||
| | C | System Page / Program Name | | |||
| | D | Latest Page Version & Date | | |||
| | E | Page Purpose Highlights | | |||
| | F | Name of Function within Program | | |||
| | G | Functions Highlight | | |||
| | H | Latest Function Version & Date | | |||
| | I | Major Changes Highlights | | |||
| | J | Developed By | | |||
| | K | 1st Previous Page Version & Date | | |||
| | L | 1st Major Changes Highlights | | |||
| | M | Developed By | | |||
| | N | 2nd Previous Page Version & Date | | |||
| | O | 2nd Major Changes Highlights | | |||
| | P | Developed By | | |||
| | Q | 3rd Previous Page Version & Date | | |||
| | R | 3rd Major Changes Highlights | | |||
| | S | Developed By | | |||
| #### Column F — English symbol + source file (required) | |||
| **Name of Function within Program (F)** must use the real English | |||
| identifier from code (component / class / function / endpoint name), | |||
| **and** state which file(s) it comes from. Do **not** put only a | |||
| localized UI label in F. | |||
| 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), F 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 (G)** and **Major Changes (I)** must be 中英對照 | |||
| (Chinese then English on the next line). Prefer i18n terms. Page / | |||
| subsystem names (B/C) use `中文 / English`. | |||
| #### Column E — Page Purpose + where the function is used (required) | |||
| On **Functions in Page&Program**, **Page Purpose Highlights (E)** 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 E as only the generic page blurb when the function was | |||
| added/changed for another page’s flow (e.g. refs that support 批號追溯 | |||
| but sit under 工單提料/提料單/成品出倉). | |||
| #### Fact-check column F before preview (required) | |||
| 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 F. Omit the row or ask the user; never write an unverified F. | |||
| In the preview, mark verified rows (optional short note), e.g. | |||
| `F verified: symbol+path OK`. If any F failed fact-check, fix before asking | |||
| for confirmation. | |||
| ## Preview format (required before save) | |||
| Show a clear preview, for example: | |||
| ```text | |||
| Excel: <resolved path> | |||
| Sheet: Functions in Page&Program | |||
| Row 12 (UPDATE existing | Ref 7 | Page: 批號追溯 | Function: ItemTracingFlowGraphSearch — src/components/ItemTracing/ItemTracingFlowGraphSearch.tsx) | |||
| History shift: | |||
| old Latest (H/I/J) → 1st | |||
| old 1st → 2nd | |||
| old 2nd → 3rd | |||
| old 3rd → DROP | |||
| New Latest: | |||
| H: v1.2.0 2026-07-14 | |||
| I: 新增流程圖節點搜尋。 | |||
| Add flow-graph node search. | |||
| ItemTracingFlowGraphSearch.tsx | |||
| L42–88: 搜尋框與節點高亮。 | |||
| L42–88: search box and node highlight. | |||
| J: <developer> | |||
| Row NEW (append | next Ref 15 | Page: 批號追溯 / Item Tracing | Function: ItemTracingScanBar — src/components/ItemTracing/ItemTracingScanBar.tsx) | |||
| A: 15 | |||
| B: 倉庫管理 / Store Management | |||
| C: 批號追溯 / Item Tracing | |||
| E: …頁面目的(中文)… | |||
| …page purpose (English)… | |||
| 使用/呼叫來源:本頁(批號追溯 / Item Tracing)。 | |||
| Used / called from: this page (批號追溯 / Item Tracing). | |||
| F: ItemTracingScanBar — src/components/ItemTracing/ItemTracingScanBar.tsx | |||
| G: 相機掃碼/手動查詢 | |||
| Camera scan / manual search | |||
| ... | |||
| H/I/J: <new latest> | |||
| history slots: empty | |||
| Row NEW (… | Page: 工單提料 / Job Order Pick Execution | Function: CompleteJobOrderRecord — …) | |||
| C: 工單提料 / Job Order Pick Execution | |||
| E: 工單提料執行頁面(/jodetail)。 | |||
| Job Order Pick Execution page (/jodetail). | |||
| 使用/呼叫來源:批號追溯(單據深連結)。 | |||
| Used / called from: Item Tracing (doc deep-link). | |||
| F: 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 Excel path exists (and is writable when saving). | |||
| 2. Infer target sheets and rows from the user’s change description / diff. | |||
| 3. **Pre-check** existing program/page/function rows in Excel. | |||
| 4. For each Functions row: **fact-check** English symbol + every source path | |||
| against the codebase (see Fact-check column F). Correct or drop failures. | |||
| 5. For each Functions row: set **E** with page purpose **and** usage/呼叫來源 | |||
| (owning page vs caller page; see Column E rule). | |||
| 6. For each existing match: plan history shift + new Latest values | |||
| (**only** shift function history when that function’s F symbols changed). | |||
| 7. For each Major Changes cell (page + functions, Latest and any filled history | |||
| slots): resolve **file path + line range(s) + short per-hunk explanation** | |||
| from git diff / current sources (see Major Changes: file, lines, explanation). | |||
| 8. For each new function/page: plan a new Ref. No. row (next integer after max used). | |||
| 9. Present the **full preview** (verified F + file/line Major Changes) → ask once | |||
| if correct. | |||
| 10. On approval: apply shifts + writes with `openpyxl`, save, report sheet names, | |||
| row numbers, Ref. Nos., and key Latest fields. | |||
| 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 F for each affected Functions-sheet row. | |||
| ### Comment contents (required fields) | |||
| Must include: | |||
| 1. **Ref. No.** — Functions sheet Ref. No. for that row | |||
| 2. **Version** — Latest Function Version (column H), e.g. `v1.0.0` | |||
| 3. **Update date** — the date from column H, e.g. `2026-07-14` | |||
| ### Canonical format | |||
| Parse column H `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 F. | |||
| - **Service / controller**: above **each** listed member function | |||
| (e.g. both `trace` and `traceLocation`). | |||
| - Multi-line F (one symbol per line): comment **each** symbol in its file. | |||
| - 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 I and history slots)**: summary ZH/EN | |||
| **plus** each changed file, line range(s), and short per-hunk explanation | |||
| (see Major Changes: file, lines, explanation). Resolve lines from git diff | |||
| + current file; do not omit locations. | |||
| - **Subsystem / Page names**: `中文 / English` from i18n EN+ZH. | |||
| - **Function name (col F)**: English code symbol + source file path only | |||
| (no 中英對照). **Fact-check** symbol+path before preview. | |||
| - **Page Purpose (E)**: 中英對照 + usage/呼叫來源 lines (ZH then EN). | |||
| - **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 | |||
| - Push or invent **function** history (H–S) for a commit that did not change | |||
| that row’s column F symbols — no “page align / no code change” filler slots | |||
| - Write Major Changes as summary-only without **file path + line range(s) + | |||
| short per-hunk explanation** (Page F / Functions I and matching history cols) | |||
| - Write Chinese-only or English-only prose in bilingual-required columns | |||
| (everything except Function name F, versions, Developed By, Ref. No., and | |||
| the path/line tokens inside Major Changes) | |||
| - Put only a Chinese UI label in **Name of Function within Program** without | |||
| the English symbol and source file | |||
| - Bilingualize column F (keep symbol + path English-only) | |||
| - Omit usage/呼叫來源 from **Page Purpose Highlights (E)** when the function | |||
| is invoked from another page (e.g. 批號追溯 deep-link into 工單提料) | |||
| - Cite a `*Service` / `*Controller` class in F without its relevant member | |||
| function names (e.g. write `ItemLotTraceService.trace / .traceLocation`, | |||
| not only `ItemLotTraceService`) | |||
| - Write an F value whose symbol or path was not verified in the codebase | |||
| - Guess file paths or rename symbols to “look right” without opening the file | |||
| - Put two different 功能 on the same Ref. No. row | |||
| - Create a function row when the parent program/page was neither found nor | |||
| included in the same approved preview as a new page row | |||
| - Clear history columns without showing that drop in the preview | |||
| - 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 ### | |||
| .vscode/ | |||
| ### Cursor (local-only rules) ### | |||
| .cursor/rules/local/ | |||
| package-lock.json | |||
| python/Bag3.spec | |||
| python/dist/Bag3.exe | |||
| python/dist | |||
| @@ -16,6 +16,7 @@ Bag2 is kept as a separate legacy v2.x line; do not assume Bag2 matches Bag3. | |||
| Run: python Bag3.py | |||
| """ | |||
| import errno | |||
| import json | |||
| import os | |||
| import select | |||
| @@ -25,6 +26,7 @@ import tempfile | |||
| import threading | |||
| import time | |||
| import tkinter as tk | |||
| from dataclasses import dataclass | |||
| from datetime import date, datetime, timedelta | |||
| from tkinter import messagebox, ttk | |||
| from typing import Callable, Optional, Tuple | |||
| @@ -344,6 +346,25 @@ DATAFLEX_UI_PROGRESS_EVERY = max( | |||
| DATAFLEX_SINGLE_TCP_JOB = _dataflex_bool_env( | |||
| "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 | |||
| DATAFLEX_POST_FULL_RECOVERY_DELAY_SEC = 1.2 | |||
| # 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("^", "\\^") | |||
| 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: | |||
| """UTF-8 ZPL with one trailing CRLF so the printer sees a clear job boundary.""" | |||
| s = (zpl or "").rstrip("\r\n") | |||
| 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( | |||
| batch_no: str, | |||
| item_code: str, | |||
| @@ -377,6 +442,7 @@ def generate_zpl_dataflex( | |||
| item_id: Optional[int] = None, | |||
| stock_in_line_id: Optional[int] = None, | |||
| lot_no: Optional[str] = None, | |||
| job_order_id: Optional[int] = None, | |||
| font_regular: str = "E:STXihei.ttf", | |||
| font_bold: str = "E:STXihei.ttf", | |||
| ) -> str: | |||
| @@ -398,11 +464,12 @@ def generate_zpl_dataflex( | |||
| qr_value = _zpl_escape(qr_payload) | |||
| # 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. | |||
| return f"""^XA | |||
| host_id = _dataflex_host_identification_sgd_prefix(job_order_id) | |||
| return host_id + f"""^XA | |||
| ^PQ1,0,1,N | |||
| ^CI28 | |||
| ^PW700 | |||
| ^LL500 | |||
| ^PW{DATAFLEX_LABEL_PW} | |||
| ^LL{DATAFLEX_LABEL_LL} | |||
| ^PO N | |||
| ^FO10,20 | |||
| ^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.sendall(DATAFLEX_PREPRINT_BYTES) | |||
| time.sleep(DATAFLEX_POST_PREPRINT_DELAY_SEC) | |||
| try: | |||
| sock.shutdown(socket.SHUT_WR) | |||
| except OSError: | |||
| pass | |||
| _dataflex_shutdown_write_maybe(sock) | |||
| finally: | |||
| sock.close() | |||
| @@ -472,10 +536,7 @@ def send_dataflex_job_counter_reset(ip: str, port: int, *, force: bool = False) | |||
| sock.connect((ip, port)) | |||
| sock.sendall(_dataflex_full_recovery_payload()) | |||
| time.sleep(DATAFLEX_POST_FULL_RECOVERY_DELAY_SEC) | |||
| try: | |||
| sock.shutdown(socket.SHUT_WR) | |||
| except OSError: | |||
| pass | |||
| _dataflex_shutdown_write_maybe(sock) | |||
| finally: | |||
| sock.close() | |||
| @@ -527,10 +588,7 @@ def send_dataflex_reset_and_labels( | |||
| time.sleep(DATAFLEX_POST_LABEL_SETTLE_SEC) | |||
| if i < copies - 1: | |||
| time.sleep(delay_sec) | |||
| try: | |||
| sock.shutdown(socket.SHUT_WR) | |||
| except OSError: | |||
| pass | |||
| _dataflex_shutdown_write_maybe(sock) | |||
| finally: | |||
| sock.close() | |||
| @@ -879,10 +937,7 @@ def send_zpl_to_dataflex(ip: str, port: int, zpl: str) -> None: | |||
| sock.connect((ip, port)) | |||
| sock.sendall(_dataflex_zpl_bytes(zpl)) | |||
| time.sleep(DATAFLEX_POST_LABEL_SETTLE_SEC) | |||
| try: | |||
| sock.shutdown(socket.SHUT_WR) | |||
| except OSError: | |||
| pass | |||
| _dataflex_shutdown_write_maybe(sock) | |||
| finally: | |||
| sock.close() | |||
| @@ -907,6 +962,10 @@ def query_dataflex_host_status(ip: str, port: int) -> str: | |||
| data = sock.recv(4096) | |||
| except socket.timeout: | |||
| break | |||
| except OSError as ex: | |||
| if _dataflex_is_benign_tcp_reset(ex): | |||
| break | |||
| raise | |||
| if not data: | |||
| break | |||
| chunks.append(data) | |||
| @@ -1829,6 +1888,204 @@ def ask_bag_count(parent: tk.Tk) -> Optional[Tuple[int, bool]]: | |||
| 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: | |||
| """Sleep up to total_sec but return early if stop_event is set.""" | |||
| end = time.perf_counter() + total_sec | |||
| @@ -1845,16 +2102,18 @@ def open_dataflex_stop_window( | |||
| parent: tk.Tk, | |||
| stop_event: threading.Event, | |||
| stop_win_ref: list, | |||
| session: DataflexPrintSession, | |||
| ) -> tk.Toplevel: | |||
| """ | |||
| 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, | |||
| 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.title("打袋機連續列印") | |||
| win.geometry("420x170") | |||
| win.geometry("480x240") | |||
| # On Windows, transient(root) can hide this Toplevel when the menubutton / printer row | |||
| # updates (e.g. switching to 激光機); keep transient only on non-Windows. | |||
| if os.name != "nt": | |||
| @@ -1869,11 +2128,28 @@ def open_dataflex_stop_window( | |||
| tk.Label( | |||
| 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), | |||
| 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: | |||
| if os.name == "nt": | |||
| @@ -1985,6 +2261,8 @@ def main() -> None: | |||
| label_busy_ref: list = [False] | |||
| # DataFlex continuous: stop Toplevel ref so we can lift it after other dialogs | |||
| 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: | |||
| """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) | |||
| 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: | |||
| set_row_highlight(selected_row_holder[0], False) | |||
| set_row_highlight(r, True) | |||
| @@ -2451,161 +2743,47 @@ def main() -> None: | |||
| item_id=item_id, | |||
| stock_in_line_id=stock_in_line_id, | |||
| lot_no=lot_no, | |||
| job_order_id=j.get("id"), | |||
| ) | |||
| label_text = (lot_no or b).strip() | |||
| 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_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: | |||
| run_dataflex_fixed_qty_thread( | |||
| root=root, | |||
| @@ -2803,5 +2981,41 @@ def main() -> None: | |||
| 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__": | |||
| 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 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. | |||
| @@ -91,6 +91,28 @@ public class SecurityConfig { | |||
| .hasAnyAuthority("TESTING", "ADMIN", "STOCK") | |||
| .requestMatchers(HttpMethod.GET, "/product-process/Demo/Process/alerts/fg-qc-putaway") | |||
| .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") | |||
| .hasAnyAuthority("ADMIN", "INVENTORY_ADJUST") | |||
| .anyRequest().authenticated()) | |||
| .httpBasic(httpBasic -> httpBasic.authenticationEntryPoint( | |||
| (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.fpsms.m18.enums.M18DataLogStatus | |||
| import org.springframework.stereotype.Repository | |||
| import java.time.LocalDateTime | |||
| @Repository | |||
| 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) | |||
| 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 | |||
| import com.ffii.core.support.AbstractRepository | |||
| import com.ffii.fpsms.m18.enums.M18DataLogStatus | |||
| import org.springframework.stereotype.Repository | |||
| import java.time.LocalDateTime | |||
| @Repository | |||
| interface SchedulerSyncLogRepository : AbstractRepository<SchedulerSyncLog, Long> { | |||
| 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,697 @@ | |||
| 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.bomKind, | |||
| flowTypeId = flowTypeId, | |||
| headerM18IdOverride = headerM18IdOverride, | |||
| bomM18Id = bom.m18Id?.takeIf { it > 0 }, | |||
| ) | |||
| val header = M18MainUdfBomForShopValue( | |||
| id = headerM18IdForRequest?.toString(), | |||
| code = headerCode, | |||
| beId = bomShopMainBeId, | |||
| desc = bom.name ?: bom.bomKind, | |||
| descEn = bom.name ?: bom.bomKind, | |||
| 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 | |||
| } | |||
| // outputQty is stored in base unit; harvest = base qty, unit = base suffix or uom code tail. | |||
| val baseUom = bom.uom ?: itemUomService.findBaseUnitByItemId(itemId)?.uom | |||
| val harvestUnit = bom.outputQtyUom?.trim()?.takeIf { it.isNotEmpty() } | |||
| ?: baseUom?.udfShortDesc?.trim()?.takeIf { it.isNotEmpty() } | |||
| ?: baseUom?.code?.trim()?.takeIf { it.isNotEmpty() } | |||
| return outputQty.stripTrailingZeros().toPlainString() to harvestUnit | |||
| } | |||
| 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.service.DeliveryOrderLineService | |||
| 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.SaveDeliveryOrderRequest | |||
| 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.LoggerFactory | |||
| import org.springframework.stereotype.Service | |||
| import java.sql.SQLException | |||
| import java.time.LocalDateTime | |||
| import java.time.format.DateTimeFormatter | |||
| import kotlin.reflect.full.memberProperties | |||
| @@ -35,6 +35,7 @@ open class M18DeliveryOrderService( | |||
| val apiCallerService: ApiCallerService, | |||
| val m18DataLogService: M18DataLogService, | |||
| val deliveryOrderService: DeliveryOrderService, | |||
| val deliveryOrderRepository: DeliveryOrderRepository, | |||
| val deliveryOrderLineService: DeliveryOrderLineService, | |||
| val itemsService: ItemsService, | |||
| val shopService: ShopService, | |||
| @@ -106,7 +107,6 @@ open class M18DeliveryOrderService( | |||
| if (request.dDateEqual != null) { | |||
| shopPoConds += "=and=(${dDateEqualConds})" | |||
| } | |||
| logger.info("shopPoConds: ${shopPoConds}") | |||
| val shopPoParams = M18PurchaseOrderListRequest( | |||
| @@ -151,20 +151,41 @@ open class M18DeliveryOrderService( | |||
| return deliveryOrder | |||
| } | |||
| open fun saveDeliveryOrders(request: M18CommonRequest): SyncResult { | |||
| open fun saveDeliveryOrders(request: M18CommonRequest, skipExistingDo: Boolean = false): SyncResult { | |||
| 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 | |||
| * [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( | |||
| stSearch = "po", | |||
| params = null, | |||
| conds = "(code=equal=$code)" | |||
| conds = conds | |||
| ) | |||
| val doListResponse = try { | |||
| apiCallerService.get<M18PurchaseOrderListResponse, M18PurchaseOrderListRequest>( | |||
| @@ -183,30 +204,36 @@ open class M18DeliveryOrderService( | |||
| totalProcessed = 1, | |||
| totalSuccess = 0, | |||
| totalFail = 1, | |||
| query = "code=equal=$code" | |||
| query = conds | |||
| ) | |||
| } | |||
| val prepared = M18PurchaseOrderListResponseWithType( | |||
| 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( | |||
| deliveryOrdersWithType: M18PurchaseOrderListResponseWithType? | |||
| deliveryOrdersWithType: M18PurchaseOrderListResponseWithType?, | |||
| syncisExtra: Boolean = false, | |||
| skipExistingDo: Boolean = false, | |||
| ): SyncResult { | |||
| 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 skippedList = mutableListOf<Long>() | |||
| val successDetailList = mutableListOf<Long>() | |||
| val failList = mutableListOf<Long>() | |||
| val failDetailList = mutableListOf<Long>() | |||
| val failItemDetailList = mutableListOf<Long>() | |||
| val uomByM18IdCache = mutableMapOf<Long, ItemUom?>() | |||
| val itemIdCache = mutableMapOf<Long, Long?>() | |||
| val itemIdCache = mutableMapOf<Long, Long>() | |||
| val stockUomIdCache = mutableMapOf<Pair<Long, Long>, Long?>() | |||
| val doRefType = "Delivery Order" | |||
| @@ -223,6 +250,22 @@ open class M18DeliveryOrderService( | |||
| if (deliveryOrdersValues != null) { | |||
| 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) | |||
| var deliveryOrderId: Long? = null //FP-MTMS | |||
| @@ -236,6 +279,14 @@ open class M18DeliveryOrderService( | |||
| // delivery_order + m18_data_log table | |||
| 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 | |||
| // logger.info("${doRefType}: Finding For Latest M18 Data Log...") | |||
| val latestDeliveryOrderLog = | |||
| @@ -283,7 +334,8 @@ open class M18DeliveryOrderService( | |||
| m18DataLogId = saveM18DeliveryOrderLog.id, | |||
| handlerId = null, | |||
| m18BeId = mainpo.beId, | |||
| deleted = mainpo.udfIsVoid == true | |||
| deleted = mainpo.udfIsVoid == true, | |||
| isExtra = syncisExtra, | |||
| ) | |||
| val saveDeliveryOrderResponse = | |||
| @@ -354,14 +406,10 @@ open class M18DeliveryOrderService( | |||
| // logger.info("${doLineRefType}: Saved M18 Data Log. ID: ${saveM18DeliveryOrderLineLog.id}") | |||
| // 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 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}") | |||
| 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 { | |||
| // Find the delivery_order_line if exist | |||
| // logger.info("${doLineRefType}: Finding exising delivery order line...") | |||
| @@ -387,14 +452,27 @@ open class M18DeliveryOrderService( | |||
| 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( | |||
| id = existingDeliveryOrderLine?.id, | |||
| itemId = itemId, | |||
| uomIdM18 = itemUom?.uom?.id, | |||
| uomIdM18 = m18UomId, | |||
| uomId= stockUomId, | |||
| deliveryOrderId = deliveryOrderId, | |||
| qtyM18 = line.qty, | |||
| qty = itemUomService.convertQtyToStockQty(itemId?:0, itemUom?.uom?.id?: 0, line.qty), | |||
| qtyM18 = sourceQty, | |||
| qty = stockQty, | |||
| up = line.up, | |||
| price = line.amt, | |||
| // m18CurrencyId = mainpo.curId, | |||
| @@ -421,7 +499,7 @@ open class M18DeliveryOrderService( | |||
| successDetailList.add(line.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}") | |||
| } catch (e: SQLException) { | |||
| } catch (e: Exception) { | |||
| failDetailList.add(line.id) | |||
| failItemDetailList.add(line.proId) | |||
| // logger.error("${doLineRefType}: Saving Failure!") | |||
| @@ -528,6 +606,9 @@ open class M18DeliveryOrderService( | |||
| // End of save. Check result | |||
| logger.info("Total Success (${doRefType}) (${successList.size})") | |||
| 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.error("Total Fail (${doLineRefType}) (${failDetailList.size}): $failDetailList") | |||
| @@ -540,11 +621,12 @@ open class M18DeliveryOrderService( | |||
| logger.info("--------------------------------------------End - Saving M18 Delivery Order--------------------------------------------") | |||
| val skippedSuffix = if (skippedList.isNotEmpty()) " | skipped=${skippedList.size}" else "" | |||
| return SyncResult( | |||
| totalProcessed = successList.size + failList.size, | |||
| totalProcessed = successList.size + failList.size + skippedList.size, | |||
| totalSuccess = successList.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? { | |||
| try { | |||
| ensureCunitSeededForAllIfEmpty() | |||
| @@ -231,9 +238,18 @@ open class M18MasterDataService( | |||
| ) | |||
| 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...") | |||
| // 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() | |||
| // Delete the item uom | |||
| @@ -267,7 +283,7 @@ open class M18MasterDataService( | |||
| ) | |||
| val itemUomRequest = ItemUomRequest( | |||
| m18UomId = it.unitId, | |||
| itemId = savedItem.id, | |||
| itemId = localItemId, | |||
| baseUnit = it.basicUnit, | |||
| stockUnit = it.stkUnit, | |||
| pickingUnit = it.pickUnit, | |||
| @@ -284,12 +300,11 @@ open class M18MasterDataService( | |||
| deleted = it.expired || endInstant.isBefore(now) | |||
| ) | |||
| // logger.info("saved item id: ${savedItem.id}") | |||
| itemUomService.saveItemUom(itemUomRequest) | |||
| } | |||
| logger.info("Success (M18 Item): ${id} | ${pro.code} | ${pro.desc}") | |||
| return savedItem | |||
| return savedItem.copy(id = localItemId) | |||
| } else { | |||
| logger.error("Fail Message: ${itemDetail?.messages?.get(0)?.msgDetail}") | |||
| logger.error("Fail: Item ID - ${id} Not Found") | |||
| @@ -404,11 +419,20 @@ open class M18MasterDataService( | |||
| ) | |||
| 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...") | |||
| // 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() | |||
| @@ -442,7 +466,7 @@ open class M18MasterDataService( | |||
| val itemUomRequest = ItemUomRequest( | |||
| m18UomId = it.unitId, | |||
| itemId = savedItem.id, | |||
| itemId = localItemId, | |||
| baseUnit = it.basicUnit, | |||
| stockUnit = it.stkUnit, | |||
| pickingUnit = it.pickUnit, | |||
| @@ -315,6 +315,19 @@ open class M18PurchaseOrderService( | |||
| val latestPurchaseOrderLog = | |||
| 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()) | |||
| // Save to m18_data_log table | |||
| // 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}") | |||
| 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...") | |||
| val existingPurchaseOrder = | |||
| latestPurchaseOrderLog?.id?.let { purchaseOrderService.findByM18DataLogId(it) } | |||
| val existingPurchaseOrder = existingPurchaseOrderForSync | |||
| // logger.info("${poRefType}: Exising purchase order ID: ${existingPurchaseOrder?.id}") | |||
| // 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}: 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}") | |||
| 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 { | |||
| // Find the purchase_order_line if exist (stable key: PO + M18 line id) | |||
| // 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.model.SyncResult | |||
| 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.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.master.entity.ItemUom | |||
| import com.ffii.fpsms.modules.master.entity.Items | |||
| @@ -35,6 +36,7 @@ class M18TestController ( | |||
| private val m18DeliveryOrderService: M18DeliveryOrderService, | |||
| val schedulerService: SchedulerService, | |||
| private val settingsService: SettingsService, | |||
| private val bomService: BomService, | |||
| ) { | |||
| var logger: Logger = LoggerFactory.getLogger(JwtTokenUtil::class.java) | |||
| @@ -65,6 +67,14 @@ class M18TestController ( | |||
| 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") | |||
| fun testSyncPoByCode(@RequestParam code: String): SyncResult { | |||
| return m18PurchaseOrderService.savePurchaseOrderByCode(code) | |||
| @@ -72,7 +82,14 @@ class M18TestController ( | |||
| @GetMapping("/test/do-by-code") | |||
| 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") | |||
| @@ -29,7 +29,7 @@ open class BagService( | |||
| ) { | |||
| open fun createBagLotLinesByBagId(request: CreateBagLotLineRequest): MessageResponse { | |||
| 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 baseRatioN = BaseUnitOfMeasure?.ratioN ?: BigDecimal.ONE | |||
| println("baseRatioN: $baseRatioN") | |||
| @@ -1,38 +1,21 @@ | |||
| package com.ffii.fpsms.modules.bag.web | |||
| import com.ffii.core.response.RecordsRes | |||
| 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.ModelAttribute | |||
| import org.springframework.web.bind.annotation.PathVariable | |||
| 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.RequestMapping | |||
| 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 | |||
| @RequestMapping("/bag") | |||
| class BagController( | |||
| @@ -43,14 +26,17 @@ class BagController( | |||
| fun getBagInfo(): List<BagInfo> { | |||
| return bagService.getAllBagInfo() | |||
| } | |||
| @PostMapping("/createJoBagConsumption") | |||
| fun createJoBagConsumption(@RequestBody request: CreateJoBagConsumptionRequest): MessageResponse { | |||
| return bagService.createJoBagConsumption(request) | |||
| } | |||
| @GetMapping("/bagUsageRecords") | |||
| fun getBagUsageRecords(): List<BagUsageRecordResponse> { | |||
| return bagService.getAllBagUsageRecords() | |||
| } | |||
| @GetMapping("/bags") | |||
| fun getBags(): List<BagSummaryResponse> = | |||
| bagService.getBagSummaries() | |||
| @@ -66,4 +52,4 @@ class BagController( | |||
| @PutMapping("/by-item/{itemId}/soft-delete") | |||
| fun softDeleteBagByItemId(@PathVariable itemId: Long): MessageResponse = | |||
| bagService.softDeleteBagByItemId(itemId) | |||
| } | |||
| } | |||
| @@ -3,6 +3,7 @@ package com.ffii.fpsms.modules.chart.service | |||
| import com.ffii.core.support.JdbcDao | |||
| import org.springframework.stereotype.Service | |||
| import java.time.LocalDate | |||
| import java.time.LocalDateTime | |||
| @Service | |||
| open class ChartService( | |||
| @@ -15,52 +16,40 @@ open class ChartService( | |||
| */ | |||
| fun getStockTransactionsByDate(startDate: LocalDate?, endDate: LocalDate?): List<Map<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 = """ | |||
| SELECT | |||
| DATE_FORMAT(sl.date, '%Y-%m-%d') AS date, | |||
| COALESCE(SUM(sl.inQty), 0) AS inQty, | |||
| COALESCE(SUM(sl.outQty), 0) AS outQty, | |||
| 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 | |||
| $startSql $endSql | |||
| GROUP BY sl.date | |||
| ORDER BY sl.date | |||
| $rangeSql | |||
| GROUP BY DATE_FORMAT(sl.date, '%Y-%m-%d') | |||
| ORDER BY date | |||
| """.trimIndent() | |||
| return jdbcDao.queryForList(sql, args) | |||
| } | |||
| /** | |||
| * 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>> { | |||
| 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 = """ | |||
| 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, | |||
| COALESCE(SUM(dol.qty), 0) AS totalQty | |||
| FROM delivery_order do | |||
| 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 | |||
| """.trimIndent() | |||
| return jdbcDao.queryForList(sql, args) | |||
| @@ -529,37 +518,45 @@ open class ChartService( | |||
| * Stock in vs stock out by date. | |||
| * 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. | |||
| * | |||
| * 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>> { | |||
| 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 = """ | |||
| 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.outQty), 0) AS outQty | |||
| 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 | |||
| 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 | |||
| 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 | |||
| 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 | |||
| WHERE 1=1 $startSql $endSql | |||
| GROUP BY u.dt | |||
| ORDER BY u.dt | |||
| """.trimIndent() | |||
| @@ -568,23 +565,19 @@ open class ChartService( | |||
| /** | |||
| * 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>> { | |||
| 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 = """ | |||
| 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 | |||
| """.trimIndent() | |||
| 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). | |||
| * 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( | |||
| startDate: LocalDate?, | |||
| @@ -600,14 +595,7 @@ open class ChartService( | |||
| itemCodes: List<String>? | |||
| ): List<Map<String, Any>> { | |||
| 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 codes = itemCodes.map { it.trim() }.filter { it.isNotBlank() } | |||
| if (codes.isEmpty()) "" else { | |||
| @@ -620,10 +608,11 @@ open class ChartService( | |||
| it.code AS itemCode, | |||
| it.name AS itemName, | |||
| 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 | |||
| ORDER BY totalQty DESC | |||
| LIMIT :limit | |||
| @@ -641,26 +630,26 @@ open class ChartService( | |||
| itemCode: String? | |||
| ): List<Map<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%" | |||
| "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 = """ | |||
| SELECT | |||
| DATE_FORMAT(sl.date, '%Y-%m-%d') AS date, | |||
| 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() | |||
| return jdbcDao.queryForList(sql, args) | |||
| } | |||
| @@ -677,27 +666,35 @@ open class ChartService( | |||
| ): List<Map<String, Any>> { | |||
| val args = mutableMapOf<String, Any>() | |||
| 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 "" | |||
| 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%" | |||
| "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 = """ | |||
| SELECT | |||
| DATE_FORMAT(sl.date, '%Y-%m') AS month, | |||
| 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') | |||
| ORDER BY month | |||
| """.trimIndent() | |||
| @@ -721,23 +718,29 @@ open class ChartService( | |||
| /** | |||
| * 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). | |||
| * 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( | |||
| startDate: LocalDate?, | |||
| endDate: LocalDate?, | |||
| staffNos: List<String>? | |||
| staffNos: List<String>?, | |||
| storeId: String?, | |||
| storeIdNull: Boolean?, | |||
| ): List<Map<String, Any>> { | |||
| val args = mutableMapOf<String, Any>() | |||
| val startSql = if (startDate != null) { | |||
| args["startDate"] = startDate.toString() | |||
| "AND DATE(dpor.ticketCompleteDateTime) >= :startDate" | |||
| args["startDate"] = startDate.atStartOfDay() | |||
| "AND dop.ticketCompleteDateTime >= :startDate" | |||
| } else "" | |||
| 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 "" | |||
| val staffSql = if (!staffNos.isNullOrEmpty()) { | |||
| val nos = staffNos.map { it.trim() }.filter { it.isNotBlank() } | |||
| @@ -746,25 +749,40 @@ open class ChartService( | |||
| "AND u.staffNo IN (:staffNos)" | |||
| } | |||
| } 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 = """ | |||
| 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( | |||
| 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 | |||
| END | |||
| ), 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 | |||
| """.trimIndent() | |||
| return jdbcDao.queryForList(sql, args) | |||
| @@ -1479,7 +1497,7 @@ open class ChartService( | |||
| FROM stock_in_line sil | |||
| INNER JOIN job_order joSil ON joSil.id = sil.jobOrderId AND joSil.deleted = 0 | |||
| INNER JOIN bom b ON b.id = joSil.bomId AND b.deleted = 0 | |||
| AND UPPER(TRIM(COALESCE(b.description, ''))) IN ('FG', 'WIP') | |||
| AND UPPER(TRIM(COALESCE(b.bomKind, ''))) IN ('FG', 'WIP') | |||
| WHERE sil.deleted = 0 AND sil.jobOrderId IS NOT NULL | |||
| GROUP BY sil.jobOrderId | |||
| ) silAgg ON silAgg.joId = jo.id | |||
| @@ -1572,4 +1590,56 @@ open class ChartService( | |||
| """.trimIndent() | |||
| 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= | |||
| * Returns [{ date, orderCount, totalQty }] | |||
| * Returns [{ date, orderCount, totalQty }]. Date axis: delivery_order.estimatedArrivalDate only (null ETA excluded). | |||
| */ | |||
| @GetMapping("/delivery-order-by-date") | |||
| fun getDeliveryOrderByDate( | |||
| @@ -129,7 +129,7 @@ class ChartController( | |||
| /** | |||
| * 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") | |||
| fun getStockInOutByDate( | |||
| @@ -140,6 +140,7 @@ class ChartController( | |||
| /** | |||
| * GET /chart/top-delivery-items-item-options?startDate=&endDate= | |||
| * 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") | |||
| fun getTopDeliveryItemsItemOptions( | |||
| @@ -150,6 +151,7 @@ class ChartController( | |||
| /** | |||
| * 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). | |||
| * Period: delivery_order.estimatedArrivalDate only (null ETA excluded). | |||
| */ | |||
| @GetMapping("/top-delivery-items") | |||
| fun getTopDeliveryItems( | |||
| @@ -192,16 +194,20 @@ class ChartController( | |||
| 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") | |||
| fun getStaffDeliveryPerformance( | |||
| @RequestParam(required = false) @DateTimeFormat(iso = DateTimeFormat.ISO.DATE) startDate: LocalDate?, | |||
| @RequestParam(required = false) @DateTimeFormat(iso = DateTimeFormat.ISO.DATE) endDate: LocalDate?, | |||
| @RequestParam(required = false) staffNo: List<String>?, | |||
| @RequestParam(required = false) storeId: String?, | |||
| @RequestParam(required = false) storeIdNull: Boolean?, | |||
| ): List<Map<String, Any>> = | |||
| chartService.getStaffDeliveryPerformance(startDate, endDate, staffNo) | |||
| chartService.getStaffDeliveryPerformance(startDate, endDate, staffNo, storeId, storeIdNull) | |||
| // ---------- Job order reports ---------- | |||
| @@ -23,12 +23,11 @@ open class MailSMTP(settingsService: SettingsService) { | |||
| if (other == null || other !is MailSMTP) return false | |||
| 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.password, o.password) | |||
| StringUtils.equals(this.password, o.password) && | |||
| this.auth == o.auth | |||
| ) { | |||
| return true | |||
| } | |||
| @@ -28,8 +28,13 @@ public abstract class SettingNames { | |||
| 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. */ | |||
| 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"; | |||
| /** 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"; | |||
| /** 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"; | |||
| /** | |||
| * 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) */ | |||
| 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_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 | |||
| */ | |||
| @@ -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. vluk@2fi-solutions.com.hk,ops@example.com */ | |||
| 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. vluk@2fi-solutions.com.hk)" | |||
| } | |||
| 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 (vluk@2fi-solutions.com.hk)\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 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) { | |||
| props["mail.smtp.timeout"] = "20000" | |||
| 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.required"] = "true" | |||
| props["mail.smtp.ssl.protocols"] = "TLSv1.2" | |||
| sender.host = config.host | |||
| sender.port = config.port!! | |||
| if (auth) { | |||
| if (auth && hasCredentials) { | |||
| sender.username = config.username | |||
| 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.MailTemplateRequest | |||
| 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.service.InventoryLotService | |||
| import com.itextpdf.html2pdf.ConverterProperties | |||
| @@ -14,6 +15,7 @@ import org.jsoup.Jsoup | |||
| import org.springframework.stereotype.Service | |||
| import java.io.ByteArrayOutputStream | |||
| import java.math.BigDecimal | |||
| import java.time.LocalDateTime | |||
| import java.time.format.DateTimeFormatter | |||
| import kotlin.jvm.optionals.getOrNull | |||
| @@ -33,6 +35,19 @@ open class MailTemplateService( | |||
| 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> { | |||
| return mailTemplateRepository.findAllByDeletedIsFalse(); | |||
| } | |||
| @@ -166,19 +181,21 @@ open class MailTemplateService( | |||
| val filteredResult = qcResults | |||
| .groupBy { Pair(it.stockInLineId, it.qcItemId) } | |||
| .mapValues { (_, group) -> | |||
| group.maxByOrNull { it.recordDate } | |||
| group.maxByOrNull { it.recordDate ?: LocalDateTime.MIN } | |||
| } | |||
| .values | |||
| .filterNotNull() | |||
| .filter { !it.qcPassed } | |||
| 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 element = tempDoc.appendElement("ul") | |||
| for (result in filteredResult) { | |||
| element.appendElement("li") | |||
| .text("${result.code} - ${result.description}") | |||
| .text(formatFailedQcResultLine(result)) | |||
| } | |||
| tempDoc.outerHtml() | |||
| } else { | |||
| @@ -1,6 +1,5 @@ | |||
| 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.M18GrnCodeSyncService | |||
| 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.model.SyncResult | |||
| 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.stock.service.SearchCompletedDnService | |||
| import com.ffii.fpsms.modules.stock.service.InventoryLotLineService | |||
| @@ -25,6 +27,7 @@ import org.springframework.stereotype.Service | |||
| import java.time.DayOfWeek | |||
| import java.time.LocalDate | |||
| import java.time.LocalDateTime | |||
| import java.time.ZoneId | |||
| import java.time.format.DateTimeFormatter | |||
| import java.util.HashMap | |||
| import java.util.concurrent.ScheduledFuture | |||
| @@ -42,6 +45,15 @@ open class SchedulerService( | |||
| @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. */ | |||
| @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, | |||
| /** | |||
| * 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 m18GrnCodeSyncService: M18GrnCodeSyncService, | |||
| 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 dateTimeStringFormat = DateTimeFormatter.ofPattern("yyyy-MM-dd HH:mm:ss") | |||
| val defaultCronExpression = "0 0 2 31 2 *"; | |||
| @@ -70,6 +99,8 @@ open class SchedulerService( | |||
| var scheduledM18Do1Sat: ScheduledFuture<*>? = null | |||
| var scheduledM18Do2: ScheduledFuture<*>? = null | |||
| var scheduledM18BomShop: ScheduledFuture<*>? = null | |||
| @Volatile | |||
| var scheduledM18Master: ScheduledFuture<*>? = null | |||
| @@ -80,6 +111,13 @@ open class SchedulerService( | |||
| var scheduledGrnCodeSync: ScheduledFuture<*>? = null | |||
| var scheduledInventoryLotExpiry: ScheduledFuture<*>? = null | |||
| var scheduledJobOrderPlanStart: ScheduledFuture<*>? = null | |||
| var scheduledDo1CatchUp: ScheduledFuture<*>? = null | |||
| var scheduledDo1CatchUp2: ScheduledFuture<*>? = null | |||
| var scheduledSyncAlert: ScheduledFuture<*>? = null | |||
| //@Volatile | |||
| //var scheduledRoughProd: ScheduledFuture<*>? = null | |||
| @@ -165,14 +203,198 @@ open class SchedulerService( | |||
| scheduleM18Po(); | |||
| scheduleM18Do1(); | |||
| scheduleM18Do2(); | |||
| scheduleM18BomShop(); | |||
| scheduleM18MasterData(); | |||
| schedulePostCompletedDnGrn(); | |||
| scheduleGrnCodeSync(); | |||
| scheduleInventoryLotExpiry(); | |||
| scheduleJobOrderPlanStartAuto(); | |||
| scheduleDo1CatchUpOnce(); | |||
| scheduleSyncAlertWatchdog(); | |||
| //scheduleRoughProd(); | |||
| //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 | |||
| // --------------------------- FP-MTMS --------------------------- // | |||
| //fun scheduleRoughProd() { | |||
| @@ -206,7 +428,19 @@ open class SchedulerService( | |||
| logger.info("M18 DO2 scheduler disabled (scheduler.m18Sync.enabled=false)") | |||
| 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() { | |||
| @@ -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. */ | |||
| fun scheduleInventoryLotExpiry() { | |||
| if (!inventoryLotExpiryEnabled) { | |||
| @@ -410,24 +680,42 @@ open class SchedulerService( | |||
| open fun getM18Dos1() { | |||
| 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) { | |||
| @@ -455,7 +743,7 @@ open class SchedulerService( | |||
| val ysd = today.minusDays(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 | |||
| // (otherwise Sat 03:00–18:59 would be skipped until a much later sync). | |||
| val isSundayDo2 = runDate.dayOfWeek == DayOfWeek.SUNDAY | |||
| @@ -465,21 +753,21 @@ open class SchedulerService( | |||
| 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( | |||
| "DO2 modifiedDateFrom={} ({}), modifiedDateTo={}", | |||
| modifiedFromStart.format(dateTimeStringFormat), | |||
| if (isSundayDo2) "Sunday window from Sat 03:00" else "from yesterday 19:00", | |||
| todayEleven.format(dateTimeStringFormat), | |||
| modifiedDateToEnd.format(dateTimeStringFormat), | |||
| ) | |||
| val requestDO = M18CommonRequest( | |||
| // These will now produce "yyyy-MM-dd HH:mm:ss" | |||
| dDateTo = 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), | |||
| ) | |||
| @@ -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( | |||
| receiptDate: java.time.LocalDate? = null, | |||
| skipFirst: Int = 0, | |||
| @@ -43,12 +43,35 @@ class SchedulerController( | |||
| 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") | |||
| fun triggerDo2(): String { | |||
| schedulerService.getM18Dos2() | |||
| 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") | |||
| fun triggerMasterData(): String { | |||
| schedulerService.getM18MasterData() | |||
| @@ -88,4 +111,36 @@ class SchedulerController( | |||
| schedulerService.init() | |||
| 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") | |||
| 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") | |||
| 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 | |||
| @Column(name = "created") | |||
| var created: LocalDateTime? = null | |||
| @@ -15,6 +15,8 @@ import com.ffii.fpsms.modules.deliveryOrder.web.models.* | |||
| import com.ffii.fpsms.modules.deliveryOrder.entity.models.* | |||
| @Repository | |||
| interface DeliveryOrderRepository : AbstractRepository<DeliveryOrder, Long> { | |||
| fun existsByCodeAndDeletedIsFalse(code: String): Boolean | |||
| @Query(""" | |||
| select d from DeliveryOrder d | |||
| where d.deleted = false | |||
| @@ -109,6 +111,7 @@ fun searchDoLite( | |||
| and (:status is null or d.status = :status) | |||
| and (:etaStart is null or d.estimatedArrivalDate >= :etaStart) | |||
| and (:etaEnd is null or d.estimatedArrivalDate < :etaEnd) | |||
| and (:isExtra is null or d.isExtra = :isExtra) | |||
| order by d.id desc | |||
| """) | |||
| fun searchDoLitePage( | |||
| @@ -117,6 +120,7 @@ fun searchDoLitePage( | |||
| @Param("status") status: DeliveryOrderStatus?, | |||
| @Param("etaStart") etaStart: LocalDateTime?, | |||
| @Param("etaEnd") etaEnd: LocalDateTime?, | |||
| @Param("isExtra") isExtra: Boolean?, | |||
| pageable: Pageable | |||
| ): Page<DeliveryOrderInfoLite> | |||
| @@ -132,6 +136,7 @@ fun searchDoLitePage( | |||
| and (:status is null or d.status = :status) | |||
| and (:etaStart is null or d.estimatedArrivalDate >= :etaStart) | |||
| 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.code in :allowedSupplierCodes | |||
| order by d.id desc | |||
| @@ -143,6 +148,7 @@ fun searchDoLitePageWithSupplierCodes( | |||
| @Param("status") status: DeliveryOrderStatus?, | |||
| @Param("etaStart") etaStart: LocalDateTime?, | |||
| @Param("etaEnd") etaEnd: LocalDateTime?, | |||
| @Param("isExtra") isExtra: Boolean?, | |||
| @Param("allowedSupplierCodes") allowedSupplierCodes: List<String>, | |||
| pageable: Pageable, | |||
| ): Page<DeliveryOrderInfoLite> | |||
| @@ -16,6 +16,8 @@ import java.time.LocalDate | |||
| @Repository | |||
| interface DoPickOrderRecordRepository : JpaRepository<DoPickOrderRecord, Long> { | |||
| fun findByPickOrderId(pickOrderId: Long): List<DoPickOrderRecord> | |||
| fun findByDoOrderIdAndDeletedFalse(doOrderId: Long): List<DoPickOrderRecord> | |||
| fun findByTicketNoStartingWith(ticketPrefix: String): List<DoPickOrderRecord> | |||
| @Query(""" | |||
| @@ -21,6 +21,8 @@ interface DoPickOrderRepository : JpaRepository<DoPickOrder, Long> { | |||
| ): List<DoPickOrder> | |||
| fun findByPickOrderId(pickOrderId: Long): List<DoPickOrder> | |||
| fun findByDoOrderIdAndDeletedFalse(doOrderId: Long): List<DoPickOrder> | |||
| fun findByTicketStatusIn(statuses: List<DoPickOrderStatus>): List<DoPickOrder> | |||
| // 在 DoPickOrderRepository 中添加这个方法 | |||
| 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? | |||
| @get:Value("#{target.shop?.addr3}") | |||
| val shopAddress: String? | |||
| @get:Value("#{target.isExtra}") | |||
| val isExtra: Boolean | |||
| } | |||
| data class DeliveryOrderInfoLiteDto( | |||
| val id: Long, | |||
| @@ -57,5 +60,6 @@ data class DeliveryOrderInfoLiteDto( | |||
| val shopName: String?, | |||
| val supplierName: 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}") | |||
| val itemId: Long? | |||
| @get:Value("#{target.deleted}") | |||
| val deleted: Boolean? | |||
| } | |||
| @@ -0,0 +1,95 @@ | |||
| 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 | |||
| } | |||
| 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. | |||
| */ | |||
| 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( | |||
| 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 { | |||
| @@ -103,6 +103,7 @@ class DoReleaseCoordinatorService( | |||
| private val userRepository: UserRepository, | |||
| private val pickOrderRepository: PickOrderRepository, | |||
| private val doPickOrderRecordRepository: DoPickOrderRecordRepository, | |||
| private val doFloorSupplierSettingsService: DoFloorSupplierSettingsService, | |||
| ) { | |||
| private val poolSize = Runtime.getRuntime().availableProcessors() | |||
| private val executor = Executors.newFixedThreadPool(min(poolSize, 4)) | |||
| @@ -140,22 +141,15 @@ class DoReleaseCoordinatorService( | |||
| private fun updateBatchTicketNumbers() { | |||
| try { | |||
| val dayOfWeekSql = getDayOfWeekAbbrSql("do.estimatedArrivalDate") | |||
| val pfCases = doFloorSupplierSettingsService.sqlPreferredFloorCases("s.code") | |||
| val updateSql = """ | |||
| UPDATE fpsmsdb.do_pick_order dpo | |||
| INNER JOIN ( | |||
| WITH PreferredFloor AS ( | |||
| SELECT | |||
| 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 | |||
| LEFT JOIN fpsmsdb.shop s ON s.id = do.supplierId AND s.deleted = 0 | |||
| WHERE do.deleted = 0 | |||
| @@ -307,20 +301,13 @@ class DoReleaseCoordinatorService( | |||
| println(" DEBUG: Getting ordered IDs for ${ids.size} orders") | |||
| println(" DEBUG: First 5 IDs: ${ids.take(5)}") | |||
| val dayOfWeekSql = getDayOfWeekAbbrSql("do.estimatedArrivalDate") | |||
| val pfCases = doFloorSupplierSettingsService.sqlPreferredFloorCases("s.code") | |||
| val sql = """ | |||
| WITH PreferredFloor AS ( | |||
| SELECT | |||
| 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 | |||
| LEFT JOIN fpsmsdb.shop s ON s.id = do.supplierId AND s.deleted = 0 | |||
| WHERE do.id IN (${ids.joinToString(",")}) | |||
| @@ -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, | |||
| ) | |||
| } | |||
| } | |||
| } | |||
| @@ -110,7 +110,7 @@ open class DoWorkbenchDopoAssignmentService( | |||
| "4/F" -> "4/F" | |||
| 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>( | |||
| "storeId" to actualStoreId, | |||
| @@ -140,12 +140,26 @@ open class DoWorkbenchDopoAssignmentService( | |||
| sql.append(" AND dop.truckDepartureTime = :depTime ") | |||
| params["depTime"] = depSqlTime | |||
| } | |||
| if (request.loadingSequence != null) { | |||
| sql.append(" AND dop.loadingSequence = :loadingSequence ") | |||
| params["loadingSequence"] = request.loadingSequence | |||
| } | |||
| if (isisExtraReleaseType(request.releaseType)) { | |||
| sql.append(WorkbenchReleaseTypeSupport.legacyIsExtraSql()) | |||
| } else { | |||
| sql.append(WorkbenchReleaseTypeSupport.assignFilterSql(request.releaseType)) | |||
| } | |||
| // 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. | |||
| val candidateLimit = 50 | |||
| 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> { | |||
| if (rows.isEmpty()) return emptyList() | |||
| @@ -205,7 +219,7 @@ open class DoWorkbenchDopoAssignmentService( | |||
| "4/F" -> "4/F" | |||
| 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>( | |||
| "storeId" to actualStoreId, | |||
| @@ -234,7 +248,21 @@ open class DoWorkbenchDopoAssignmentService( | |||
| sql.append(" AND dop.truckDepartureTime = :depTime ") | |||
| 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.legacyIsExtraSql()) | |||
| } 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 { | |||
| jdbcDao.queryForList(sql.toString(), params) | |||
| @@ -283,6 +311,11 @@ open class DoWorkbenchDopoAssignmentService( | |||
| } else null | |||
| } | |||
| private fun isisExtraReleaseType(releaseType: String?): Boolean { | |||
| val n = releaseType?.trim()?.lowercase().orEmpty() | |||
| return n == "isExtra" | |||
| } | |||
| private fun parseDepartureTimeToSql(raw: String?): Time? { | |||
| if (raw.isNullOrBlank()) return null | |||
| val s = raw.trim() | |||
| @@ -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,65 @@ | |||
| package com.ffii.fpsms.modules.deliveryOrder.service | |||
| /** | |||
| * Workbench [delivery_order_pick_order.releaseType] values and SQL filters. | |||
| * Legacy `isExtra` tickets are excluded from merge; only used for legacy Etra views. | |||
| */ | |||
| 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) | |||
| fun summaryFilterSql(releaseType: String, column: String = "dop.releaseType"): String = | |||
| when (releaseType.trim().lowercase()) { | |||
| "batch" -> batchFamilySql(column) | |||
| "single" -> singleFamilySql(column) | |||
| "isextra" -> legacyIsExtraSql(column) | |||
| else -> "" | |||
| } | |||
| fun assignFilterSql(releaseType: String?, column: String = "dop.releaseType"): String { | |||
| val n = releaseType?.trim()?.lowercase().orEmpty() | |||
| return when (n) { | |||
| "batch" -> batchFamilySql(column) | |||
| "single" -> singleFamilySql(column) | |||
| "isextra" -> legacyIsExtraSql(column) | |||
| 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' " | |||
| 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.enums.DeliveryOrderStatus | |||
| 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.SaveDeliveryOrderResponse | |||
| 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.entity.models.DeliveryOrderInfoLite | |||
| 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 java.time.LocalDate | |||
| @RequestMapping("/do") | |||
| @RestController | |||
| @@ -52,7 +56,7 @@ class DeliveryOrderController( | |||
| private val deliveryOrderService: DeliveryOrderService, | |||
| private val stockInLineService: StockInLineService, | |||
| private val doPickOrderService: DoPickOrderService, | |||
| private val doReplenishmentService: DoReplenishmentService, | |||
| ) { | |||
| private val log = LoggerFactory.getLogger(javaClass) | |||
| @@ -70,7 +74,9 @@ class DeliveryOrderController( | |||
| estimatedArrivalDate = request.estimatedArrivalDate, | |||
| pageNum = request.pageNum, | |||
| pageSize = request.pageSize, | |||
| truckLanceCode = request.truckLanceCode | |||
| truckLanceCode = request.truckLanceCode, | |||
| floor = request.floor, | |||
| isExtra = request.isExtra, | |||
| ) | |||
| } | |||
| @@ -86,6 +92,27 @@ class DeliveryOrderController( | |||
| estimatedArrivalDate = request.estimatedArrivalDate, | |||
| pageNum = request.pageNum, | |||
| 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); | |||
| } | |||
| @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}") | |||
| fun searchByCode(@PathVariable code: String): List<DeliveryOrderInfo> { | |||
| return deliveryOrderService.searchByCode(code); | |||
| @@ -24,6 +24,11 @@ import org.springframework.http.HttpStatus | |||
| import org.springframework.http.MediaType | |||
| import org.springframework.http.ResponseEntity | |||
| 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 | |||
| @RequestMapping("/doPickOrder/workbench") | |||
| class DoWorkbenchController( | |||
| @@ -96,23 +101,44 @@ 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`). */ | |||
| @GetMapping("/released") | |||
| fun getWorkbenchReleasedDoPickOrders( | |||
| @RequestParam(required = false) shopName: String?, | |||
| @RequestParam(required = false) storeId: String?, | |||
| @RequestParam(required = false) truck: String? | |||
| @RequestParam(required = false) truck: String?, | |||
| @RequestParam(required = false) releaseType: String?, | |||
| ): List<ReleasedDoPickOrderListItem> { | |||
| return doWorkbenchMainService.findWorkbenchReleasedDeliveryOrderPickOrdersForSelection(shopName, storeId, truck) | |||
| return doWorkbenchMainService.findWorkbenchReleasedDeliveryOrderPickOrdersForSelection( | |||
| shopName, | |||
| storeId, | |||
| truck, | |||
| releaseTypeFilter = releaseType, | |||
| ) | |||
| } | |||
| @GetMapping("/released-today") | |||
| fun getWorkbenchReleasedDoPickOrdersToday( | |||
| @RequestParam(required = false) shopName: 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?, | |||
| ): List<ReleasedDoPickOrderListItem> { | |||
| return doWorkbenchMainService.findWorkbenchReleasedDeliveryOrderPickOrdersForSelectionToday(shopName, storeId, truck) | |||
| return doWorkbenchMainService.findWorkbenchReleasedDeliveryOrderPickOrdersForSelectionToday( | |||
| shopName, | |||
| storeId, | |||
| truck, | |||
| requiredDeliveryDate = requiredDate, | |||
| releaseTypeFilter = releaseType, | |||
| ) | |||
| } | |||
| @PostMapping("/assign-by-delivery-order-pick-order-id") | |||
| @@ -152,19 +178,40 @@ class DoWorkbenchController( | |||
| */ | |||
| @PostMapping("/batch-release/async-v2") | |||
| 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 | |||
| ): MessageResponse { | |||
| return doWorkbenchReleaseService.startBatchReleaseAsyncV2(ids, userId) | |||
| return doWorkbenchReleaseService.startBatchReleaseAsyncSingleV2(listOf(doId), userId) | |||
| } | |||
| /** Synchronous batch release V2 (same semantics as async-v2; for tools / tests). */ | |||
| @PostMapping("/batch-release/sync-v2") | |||
| fun workbenchBatchReleaseSyncV2( | |||
| @RequestBody ids: List<Long>, | |||
| @RequestBody request: WorkbenchBatchReleaseRequest, | |||
| @RequestParam(defaultValue = "1") userId: Long | |||
| ): MessageResponse { | |||
| return doWorkbenchReleaseService.releaseBatchV2(ids, userId) | |||
| return doWorkbenchReleaseService.releaseBatchV2( | |||
| request.ids, | |||
| userId, | |||
| request.mergeExtraIntoLaneTicket, | |||
| ) | |||
| } | |||
| @GetMapping("/batch-release/progress/{jobId}") | |||
| @@ -172,6 +219,22 @@ class DoWorkbenchController( | |||
| 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}") | |||
| fun getWorkbenchTicketReleaseTable( | |||
| @PathVariable startDate: LocalDate, | |||
| @@ -203,6 +266,20 @@ class DoWorkbenchController( | |||
| 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") | |||
| fun printWorkbenchDNLabels(@ModelAttribute request: PrintDNLabelsRequest) { | |||
| doWorkbenchMainService.printDNLabelsWorkbench(request) | |||
| @@ -18,6 +18,12 @@ data class DoDetailResponse( | |||
| @JsonFormat(pattern = "yyyy-MM-dd") | |||
| val completeDate: LocalDateTime?, | |||
| 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> | |||
| ) | |||
| @@ -25,12 +31,18 @@ data class DoDetailLineResponse( | |||
| val id: Long, | |||
| val itemNo: String?, | |||
| 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 status: String?, | |||
| val itemName: String?, | |||
| val uom: String?, | |||
| val uomCode: 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( | |||
| val storeId: String, | |||
| @@ -49,7 +61,18 @@ data class LaneBtn( | |||
| val unassigned: Int, | |||
| val total: Int, | |||
| // 同一 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, | |||
| ) | |||
| /** All Etra (`releaseType=isExtra`) tickets for a day, grouped by shop then truck (no 2F/4F split in UI). */ | |||
| data class WorkbenchEtraShopLaneGroup( | |||
| val shopCode: String?, | |||
| val shopName: String?, | |||
| val lanes: List<LaneBtn>, | |||
| ) | |||
| data class AssignByLaneRequest( | |||
| val userId: Long, | |||
| @@ -57,9 +80,12 @@ data class AssignByLaneRequest( | |||
| val truckDepartureTime: String?, // 可选:限定出车时间 | |||
| val truckLanceCode: String , | |||
| 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( | |||
| @JsonFormat(pattern = "HH:mm") | |||
| val truckDepartureTime: java.time.LocalTime?, | |||
| val truckLanceCode: String?, | |||
| val loadingSequence: Int?, | |||
| @@ -101,11 +127,13 @@ interface DoSearchRowProjection { | |||
| } | |||
| data class ReleasedDoPickOrderListItem( | |||
| val id: Long, // doPickOrderId,用於 assign | |||
| @JsonFormat(pattern = "yyyy-MM-dd") | |||
| val requiredDeliveryDate: LocalDate?, // Date 欄 | |||
| val shopCode: String?, // Shop | |||
| val shopName: String?, // Shop | |||
| val storeId: String?, // 2/F or 4/F | |||
| val truckLanceCode: String?, // Truck (Lane) | |||
| @JsonFormat(pattern = "HH:mm") | |||
| val truckDepartureTime: LocalTime?, // Truck 時間 | |||
| 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 ( | |||
| val doPickOrderId: Long, | |||
| val numOfCarton: Int, | |||
| val blankCartonNumber: Boolean? = false, | |||
| ) | |||
| @@ -4,5 +4,6 @@ data class PrintDNLabelsRequest ( | |||
| val doPickOrderId: Long, | |||
| val printerId: Long, | |||
| val printQty: Int?, | |||
| val numOfCarton: Int | |||
| val numOfCarton: Int, | |||
| val blankCartonNumber: Boolean? = false, | |||
| ) | |||
| @@ -21,7 +21,8 @@ data class ReleaseDoResult( | |||
| val truckDepartureTime: LocalTime?, | |||
| val truckLanceCode: String?, | |||
| val loadingSequence: Int? | |||
| val loadingSequence: Int?, | |||
| val isExtra: Boolean = false, | |||
| ) | |||
| data class SearchDeliveryOrderInfoRequest( | |||
| val code: String?, | |||
| @@ -30,5 +31,9 @@ data class SearchDeliveryOrderInfoRequest( | |||
| val estimatedArrivalDate: LocalDateTime?, | |||
| val pageSize: 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, | |||
| ) | |||
| @@ -20,6 +20,7 @@ data class SaveDeliveryOrderRequest( | |||
| val handlerId: Long?, | |||
| val m18BeId: Long?, | |||
| val deleted: Boolean? = false, | |||
| val isExtra: Boolean? = false, | |||
| ) | |||
| data class SaveDeliveryOrderStatusRequest( | |||
| @@ -1,5 +1,6 @@ | |||
| package com.ffii.fpsms.modules.deliveryOrder.web.models | |||
| import com.fasterxml.jackson.annotation.JsonFormat | |||
| import java.time.LocalDateTime | |||
| import java.time.LocalDate | |||
| import java.time.LocalTime | |||
| @@ -15,14 +16,18 @@ data class TicketReleaseTableResponse( | |||
| val loadingSequence: Int?, | |||
| val ticketStatus: String?, | |||
| val truckId: Long?, | |||
| @JsonFormat(pattern = "HH:mm") | |||
| val truckDepartureTime: LocalTime?, | |||
| val shopId: Long?, | |||
| val handledBy: Long?, | |||
| @JsonFormat(pattern = "yyyy-MM-dd HH:mm:ss") | |||
| val ticketReleaseTime: LocalDateTime?, | |||
| @JsonFormat(pattern = "yyyy-MM-dd HH:mm:ss") | |||
| val ticketCompleteDateTime: LocalDateTime?, | |||
| val truckLanceCode: String?, | |||
| val shopCode: String?, | |||
| val shopName: String?, | |||
| @JsonFormat(pattern = "yyyy-MM-dd") | |||
| val requiredDeliveryDate: LocalDate?, | |||
| val handlerName: String?, | |||
| val numberOfFGItems: Int = 0, | |||
| @@ -1,5 +1,6 @@ | |||
| package com.ffii.fpsms.modules.deliveryOrder.web.models | |||
| import com.fasterxml.jackson.annotation.JsonFormat | |||
| import java.time.LocalDateTime | |||
| import java.time.LocalTime | |||
| @@ -7,13 +8,16 @@ data class TruckScheduleDashboardResponse( | |||
| val storeId: String?, | |||
| val truckId: Long?, | |||
| val truckLanceCode: String?, | |||
| @JsonFormat(pattern = "HH:mm") | |||
| val truckDepartureTime: LocalTime?, | |||
| val numberOfShopsToServe: Int, | |||
| val numberOfPickTickets: Int, | |||
| val totalItemsToPick: Int, | |||
| val numberOfTicketsReleased: Int, | |||
| @JsonFormat(pattern = "yyyy-MM-dd HH:mm:ss") | |||
| val firstTicketStartTime: LocalDateTime?, | |||
| val numberOfTicketsCompleted: Int, | |||
| @JsonFormat(pattern = "yyyy-MM-dd HH:mm:ss") | |||
| val lastTicketEndTime: LocalDateTime?, | |||
| val pickTimeTakenMinutes: Long? | |||
| ) | |||
| @@ -0,0 +1,11 @@ | |||
| package com.ffii.fpsms.modules.deliveryOrder.web.models | |||
| /** | |||
| * Workbench batch release body (async-v2 / sync-v2). | |||
| * [mergeExtraIntoLaneTicket]: when true, isExtra DOs join batch/single family (isExtrabatch / isExtrasingle); | |||
| * when false, standalone `releaseType=isExtra` tickets with `TI-E-` prefix. | |||
| */ | |||
| data class WorkbenchBatchReleaseRequest( | |||
| val ids: List<Long> = emptyList(), | |||
| val mergeExtraIntoLaneTicket: Boolean = true, | |||
| ) | |||
| @@ -0,0 +1,35 @@ | |||
| package com.ffii.fpsms.modules.deliveryOrder.web.models | |||
| import com.fasterxml.jackson.annotation.JsonFormat | |||
| import java.time.LocalDate | |||
| import java.time.LocalTime | |||
| data class WorkbenchMergeTicketCandidate( | |||
| val id: Long, | |||
| val ticketNo: String?, | |||
| val releaseType: String?, | |||
| val shopId: Long?, | |||
| val shopCode: String?, | |||
| val shopName: String?, | |||
| val storeId: String?, | |||
| val truckId: Long?, | |||
| @JsonFormat(pattern = "yyyy-MM-dd") | |||
| val requiredDeliveryDate: LocalDate?, | |||
| val truckLanceCode: String?, | |||
| @JsonFormat(pattern = "HH:mm") | |||
| val truckDepartureTime: LocalTime?, | |||
| val loadingSequence: Int?, | |||
| val deliveryOrderCodes: List<String>, | |||
| /** Stable lane identity for same-truck merge matching (2/F, 4/F, truck-X). */ | |||
| val laneKey: String, | |||
| ) | |||
| data class WorkbenchMergeTicketCandidatesResponse( | |||
| val batchFamilyTickets: List<WorkbenchMergeTicketCandidate>, | |||
| val isExtraTickets: List<WorkbenchMergeTicketCandidate>, | |||
| ) | |||
| data class WorkbenchMergeTicketsRequest( | |||
| val batchOrSingleDopoId: Long, | |||
| val isExtraDopoId: Long, | |||
| ) | |||
| @@ -1,5 +1,6 @@ | |||
| package com.ffii.fpsms.modules.deliveryOrder.web.models | |||
| import com.fasterxml.jackson.annotation.JsonFormat | |||
| import java.time.LocalDate | |||
| import java.time.LocalDateTime | |||
| import java.time.LocalTime | |||
| @@ -10,13 +11,17 @@ data class WorkbenchTicketReleaseTableResponse( | |||
| val ticketNo: String?, | |||
| val loadingSequence: Int?, | |||
| val ticketStatus: String?, | |||
| @JsonFormat(pattern = "HH:mm") | |||
| val truckDepartureTime: LocalTime?, | |||
| val handledBy: Long?, | |||
| @JsonFormat(pattern = "yyyy-MM-dd HH:mm:ss") | |||
| val ticketReleaseTime: LocalDateTime?, | |||
| @JsonFormat(pattern = "yyyy-MM-dd HH:mm:ss") | |||
| val ticketCompleteDateTime: LocalDateTime?, | |||
| val truckLanceCode: String?, | |||
| val shopCode: String?, | |||
| val shopName: String?, | |||
| @JsonFormat(pattern = "yyyy-MM-dd") | |||
| val requiredDeliveryDate: LocalDate?, | |||
| val handlerName: String?, | |||
| val numberOfFGItems: Int = 0, | |||
| @@ -143,6 +143,7 @@ interface JobOrderRepository : AbstractRepository<JobOrder, Long> { | |||
| JOIN b.bomProcesses bp | |||
| JOIN bp.process p | |||
| WHERE jo.deleted = false | |||
| AND (jo.isHidden = false OR jo.isHidden IS NULL) | |||
| AND jo.planStart >= :planStartFrom | |||
| AND jo.planStart < :planStartToExclusive | |||
| AND p.name = :processName | |||
| @@ -25,16 +25,10 @@ class LaserBag2AutoSendScheduler( | |||
| return | |||
| } | |||
| try { | |||
| val report = laserBag2AutoSendService.runAutoSend( | |||
| laserBag2AutoSendService.runAutoSend( | |||
| planStart = LocalDate.now(), | |||
| limitPerRun = limitPerRun, | |||
| ) | |||
| logger.info( | |||
| "Laser Bag2 scheduler: processed {}/{} job orders for {}", | |||
| report.jobOrdersProcessed, | |||
| report.jobOrdersFound, | |||
| report.planStart, | |||
| ) | |||
| } catch (e: Exception) { | |||
| logger.error("Laser Bag2 scheduler failed", e) | |||
| } | |||
| @@ -51,6 +51,7 @@ import com.ffii.fpsms.modules.stock.entity.enum.StockInLineStatus | |||
| import com.ffii.fpsms.modules.stock.entity.StockInLineRepository | |||
| import java.time.LocalDate | |||
| import com.ffii.fpsms.modules.jobOrder.web.model.MaterialPickStatusItem | |||
| import com.ffii.fpsms.modules.jobOrder.web.model.PlasticBoxCartonQtyDashboardRecord | |||
| @Service | |||
| open class JoPickOrderService( | |||
| private val joPickOrderRepository: JoPickOrderRepository, | |||
| @@ -1319,13 +1320,13 @@ open fun getJobOrderListForPrintQrCode(date: LocalDate): List<JobOrderListForPri | |||
| val allStockInLines = stockInLineRepository.findByReceiptDateAndDeletedFalse(date) | |||
| println("📦 Found ${allStockInLines.size} StockInLines with receiptDate = $date") | |||
| println("📦 All StockInLines: ${allStockInLines.map { it.id }}, ${allStockInLines.map { it.jobOrder?.id }}") | |||
| println("📦 All StockInLines: ${allStockInLines.map { it.status }}, ${allStockInLines.map { it.jobOrder?.bom?.description }}") | |||
| println("📦 All StockInLines: ${allStockInLines.map { it.status }}, ${allStockInLines.map { it.jobOrder?.bom?.bomKind }}") | |||
| // 2. 进一步过滤:status = "received" 或 "completed",且 BOM type = "WIP" 或 "FG" | |||
| val stockInLines = allStockInLines.filter { | |||
| it.jobOrder != null | |||
| //it.status == "received" && // 只显示 QC 完成但尚未 putaway 的 | |||
| // it.jobOrder?.bom?.description in listOf("WIP", "FG") | |||
| // it.jobOrder?.bom?.bomKind in listOf("WIP", "FG") | |||
| } | |||
| println("✅ After filtering: ${stockInLines.size} StockInLines") | |||
| @@ -1347,7 +1348,7 @@ open fun getJobOrderListForPrintQrCode(date: LocalDate): List<JobOrderListForPri | |||
| for (stockInLine in stockInLines) { | |||
| val jobOrder = jobOrders.find { it.id == stockInLine.jobOrder?.id } | |||
| if (jobOrder != null && jobOrder.bom?.description in listOf("WIP", "FG")) { | |||
| if (jobOrder != null && jobOrder.bom?.bomKind in listOf("WIP", "FG")) { | |||
| // Check if this job order is already in the result (to avoid duplicates) | |||
| val existing = result.find { | |||
| it.stockInLineId == stockInLine.id | |||
| @@ -1365,7 +1366,7 @@ open fun getJobOrderListForPrintQrCode(date: LocalDate): List<JobOrderListForPri | |||
| finihedTime = jobOrder.planEnd ?: jobOrder.modified ?: LocalDateTime.now() | |||
| ) | |||
| result.add(response) | |||
| println("✅ Added StockInLine ${stockInLine.id} (JobOrder ${jobOrder.id}, BOM: ${jobOrder.bom?.description})") | |||
| println("✅ Added StockInLine ${stockInLine.id} (JobOrder ${jobOrder.id}, BOM: ${jobOrder.bom?.bomKind})") | |||
| } | |||
| } else { | |||
| println("❌ StockInLine ${stockInLine.id}: JobOrder ${stockInLine.jobOrder?.id} not found or BOM type not WIP/FG") | |||
| @@ -1688,6 +1689,9 @@ open fun getCompletedJobOrderPickOrders(completedDate: LocalDate?): List<Map<Str | |||
| "secondScanCompleted" to secondScanCompleted, | |||
| "totalItems" to joPickOrders.size, | |||
| "completedItems" to joPickOrders.count { it.matchStatus == JoPickOrderStatus.completed }, | |||
| "plasticBoxCartonQty2f" to pickOrder.plasticBoxCartonQty2f, | |||
| "plasticBoxCartonQty3f" to pickOrder.plasticBoxCartonQty3f, | |||
| "plasticBoxCartonQty4f" to pickOrder.plasticBoxCartonQty4f, | |||
| ) | |||
| } else { | |||
| println("❌ Pick order ${pickOrder.id} has no job order, skipping.") | |||
| @@ -1703,6 +1707,32 @@ open fun getCompletedJobOrderPickOrders(completedDate: LocalDate?): List<Map<Str | |||
| emptyList() | |||
| } | |||
| } | |||
| open fun getPlasticBoxCartonQtyDashboard( | |||
| from: LocalDate, | |||
| to: LocalDate, | |||
| ): List<PlasticBoxCartonQtyDashboardRecord> { | |||
| val fromDt = from.atStartOfDay() | |||
| val toExclusive = to.plusDays(1).atStartOfDay() | |||
| return pickOrderRepository | |||
| .findCompletedWithPlasticBoxCartonQtyInPlanStartRange( | |||
| PickOrderStatus.COMPLETED, | |||
| fromDt, | |||
| toExclusive, | |||
| ) | |||
| .mapNotNull { pickOrder -> | |||
| val planStart = pickOrder.jobOrder?.planStart ?: return@mapNotNull null | |||
| val statLocalDate = planStart.toLocalDate() | |||
| PlasticBoxCartonQtyDashboardRecord( | |||
| pickOrderId = pickOrder.id, | |||
| statDate = "${statLocalDate.year}-${"%02d".format(statLocalDate.monthValue)}-${"%02d".format(statLocalDate.dayOfMonth)}", | |||
| plasticBoxCartonQty2f = pickOrder.plasticBoxCartonQty2f, | |||
| plasticBoxCartonQty3f = pickOrder.plasticBoxCartonQty3f, | |||
| plasticBoxCartonQty4f = pickOrder.plasticBoxCartonQty4f, | |||
| ) | |||
| } | |||
| } | |||
| open fun getJobOrderPickOrders(date: LocalDate?, status: PickOrderStatus?): List<Map<String, Any?>> { | |||
| println("=== getJobOrderPickOrders ===") | |||
| println("date: $date, status: $status") | |||
| @@ -2352,7 +2382,7 @@ open fun getCompletedJobOrderPickOrders(completedDate: LocalDate?): List<Map<Str | |||
| jobOrderType = jobOrderType?.name, | |||
| itemId = item.id ?: 0L, | |||
| itemName = item.name ?: "", | |||
| bomDescription = bom?.description, | |||
| bomDescription = bom?.bomKind, | |||
| reqQty = jobOrder.reqQty ?: BigDecimal.ZERO, | |||
| //uomId = bom.outputQtyUom?.id : 0L, | |||
| uomId = 0, | |||
| @@ -3,11 +3,12 @@ package com.ffii.fpsms.modules.jobOrder.service | |||
| import com.ffii.fpsms.modules.jobOrder.entity.JoPickOrderRepository | |||
| import com.ffii.fpsms.modules.jobOrder.entity.JobOrderRepository | |||
| import com.ffii.fpsms.modules.jobOrder.enums.JobOrderStatus | |||
| import com.ffii.fpsms.modules.jobOrder.web.model.JobOrderBasicInfoResponse | |||
| import com.ffii.fpsms.modules.jobOrder.web.model.JobOrderLotsHierarchicalResponse | |||
| import com.ffii.fpsms.modules.jobOrder.web.model.PickOrderInfoWorkbenchResponse | |||
| import com.ffii.fpsms.modules.jobOrder.web.model.JobOrderLotsHierarchicalWorkbenchResponse | |||
| import com.ffii.fpsms.modules.jobOrder.web.model.JobOrderBasicInfoWorkbenchResponse | |||
| import com.ffii.fpsms.modules.jobOrder.web.model.LotDetailResponse | |||
| import com.ffii.fpsms.modules.jobOrder.web.model.PickOrderInfoResponse | |||
| import com.ffii.fpsms.modules.jobOrder.web.model.PickOrderLineWithLotsResponse | |||
| import com.ffii.fpsms.modules.jobOrder.web.model.PickOrderLineWithLotsWorkbenchResponse | |||
| import com.ffii.fpsms.modules.jobOrder.web.model.StockOutLineDetailResponse | |||
| import com.ffii.fpsms.modules.master.web.models.MessageResponse | |||
| import com.ffii.fpsms.modules.pickOrder.entity.PickOrderLineRepository | |||
| @@ -49,11 +50,8 @@ open class JoWorkbenchMainService( | |||
| private val suggestedPickLotWorkbenchService: SuggestedPickLotWorkbenchService, | |||
| private val stockOutLineWorkbenchService: StockOutLineWorkbenchService, | |||
| ) { | |||
| // Keep aligned with SuggestedPickLotWorkbenchService default excludes for diagnosis logs. | |||
| private val workbenchDefaultExcludeWarehouseCodes: Set<String> = setOf( | |||
| //"2F-W202-01-00", | |||
| //"2F-W200-#A-00", | |||
| ) | |||
| private val workbenchDefaultExcludeWarehouseCodes: Set<String> = | |||
| JoWorkbenchPickConstants.DEFAULT_EXCLUDE_WAREHOUSE_CODES | |||
| private fun debugPrintSuggestionNullReasons(pickOrderId: Long) { | |||
| val pickOrder = pickOrderRepository.findById(pickOrderId).orElse(null) ?: return | |||
| @@ -194,9 +192,9 @@ open class JoWorkbenchMainService( | |||
| /** | |||
| * Hierarchical pick UI for JO Workbench: available qty **in − out**; stockouts include **suggestedPickQty** when SPL matches SOL lot line. | |||
| */ | |||
| open fun getJobOrderLotsHierarchicalByPickOrderIdWorkbench(pickOrderId: Long): JobOrderLotsHierarchicalResponse { | |||
| println("=== JoWorkbenchMainService.getJobOrderLotsHierarchicalByPickOrderIdWorkbench ===") | |||
| println("pickOrderId: $pickOrderId") | |||
| open fun getJobOrderLotsHierarchicalByPickOrderIdWorkbench(pickOrderId: Long): JobOrderLotsHierarchicalWorkbenchResponse { | |||
| //println("=== JoWorkbenchMainService.getJobOrderLotsHierarchicalByPickOrderIdWorkbench ===") | |||
| //println("pickOrderId: $pickOrderId") | |||
| return try { | |||
| val pickOrder = pickOrderRepository.findById(pickOrderId).orElse(null) | |||
| @@ -299,8 +297,8 @@ open class JoWorkbenchMainService( | |||
| } | |||
| val joPickOrders = joPickOrderRepository.findByPickOrderId(pickOrder.id!!) | |||
| val pickOrderInfo = PickOrderInfoResponse( | |||
| val pickOrderInfo = PickOrderInfoWorkbenchResponse( | |||
| id = pickOrder.id, | |||
| code = pickOrder.code, | |||
| consoCode = pickOrder.consoCode, | |||
| @@ -310,10 +308,12 @@ open class JoWorkbenchMainService( | |||
| type = pickOrder.type?.value, | |||
| status = pickOrder.status?.value, | |||
| assignTo = pickOrder.assignTo?.id, | |||
| jobOrder = JobOrderBasicInfoResponse( | |||
| jobOrder = JobOrderBasicInfoWorkbenchResponse( | |||
| id = jobOrder.id!!, | |||
| code = jobOrder.code ?: "", | |||
| name = "Job Order ${jobOrder.code}" | |||
| name = "Job Order ${jobOrder.code}", | |||
| itemCode = jobOrder.bom?.code, | |||
| itemName = jobOrder.bom?.name, | |||
| ) | |||
| ) | |||
| @@ -342,7 +342,7 @@ open class JoWorkbenchMainService( | |||
| val handlerNameInner = jpoInner?.handledBy?.let { uid -> | |||
| userService.find(uid).orElse(null)?.name | |||
| } | |||
| println("handlerName: $handlerNameInner") | |||
| //println("handlerName: $handlerNameInner") | |||
| val availableQty = if (sol?.status == "rejected") { | |||
| null | |||
| } else { | |||
| @@ -429,7 +429,7 @@ open class JoWorkbenchMainService( | |||
| ) | |||
| } | |||
| PickOrderLineWithLotsResponse( | |||
| PickOrderLineWithLotsWorkbenchResponse( | |||
| id = pol.id!!, | |||
| itemId = item?.id, | |||
| itemCode = item?.code, | |||
| @@ -445,7 +445,7 @@ open class JoWorkbenchMainService( | |||
| ) | |||
| } | |||
| JobOrderLotsHierarchicalResponse( | |||
| JobOrderLotsHierarchicalWorkbenchResponse( | |||
| pickOrder = pickOrderInfo, | |||
| pickOrderLines = pickOrderLinesResult | |||
| ) | |||
| @@ -456,10 +456,10 @@ open class JoWorkbenchMainService( | |||
| } | |||
| } | |||
| private fun emptyHierarchical(message: String): JobOrderLotsHierarchicalResponse { | |||
| private fun emptyHierarchical(message: String): JobOrderLotsHierarchicalWorkbenchResponse { | |||
| println("❌ $message") | |||
| return JobOrderLotsHierarchicalResponse( | |||
| pickOrder = PickOrderInfoResponse( | |||
| return JobOrderLotsHierarchicalWorkbenchResponse( | |||
| pickOrder = PickOrderInfoWorkbenchResponse( | |||
| id = null, | |||
| code = null, | |||
| consoCode = null, | |||
| @@ -467,7 +467,7 @@ open class JoWorkbenchMainService( | |||
| type = null, | |||
| status = null, | |||
| assignTo = null, | |||
| jobOrder = JobOrderBasicInfoResponse(0, "", "") | |||
| jobOrder = JobOrderBasicInfoWorkbenchResponse(0, "", "",null,null) | |||
| ), | |||
| pickOrderLines = emptyList() | |||
| ) | |||
| @@ -0,0 +1,36 @@ | |||
| package com.ffii.fpsms.modules.jobOrder.service | |||
| /** | |||
| * JO workbench pick constants. | |||
| * | |||
| * [DEFAULT_EXCLUDE_WAREHOUSE_CODES] applies on **assign / first prime** ([JoWorkbenchMainService]) | |||
| * and on **scan-pick re-suggest** ([com.ffii.fpsms.modules.deliveryOrder.service.DoWorkbenchMainService]). | |||
| */ | |||
| object JoWorkbenchPickConstants { | |||
| val DEFAULT_EXCLUDE_WAREHOUSE_CODES: Set<String> = setOf( | |||
| "4F-W402-01-00", | |||
| "4F-W402-02-00", | |||
| "4F-W402-03-00", | |||
| "4F-W402-04-00", | |||
| "4F-W402-05-00", | |||
| "4F-W402-#A-00", | |||
| "4F-W402-#B-00", | |||
| "4F-W402-#C-00", | |||
| "4F-W402-#D-00", | |||
| "4F-W402-#E-00", | |||
| "4F-W402-#F-00", | |||
| "4F-W402-#G-00", | |||
| "4F-W402-#H-00", | |||
| "4F-W402-#I-00", | |||
| "4F-W402-#J-00", | |||
| "4F-W402-#K-00", | |||
| "4F-W402-#L-00", | |||
| "4F-W402-#M-00", | |||
| "4F-W402-#N-00", | |||
| "4F-W402-#O-00", | |||
| "4F-W402-#P-00", | |||
| "4F-W402-#Q-00", | |||
| "4F-W402-#R-00", | |||
| "4F-W402-#S-00", | |||
| ) | |||
| } | |||
| @@ -8,6 +8,8 @@ import com.ffii.fpsms.modules.jobOrder.web.model.CreateJobOrderBomMaterialReques | |||
| import com.ffii.fpsms.modules.master.entity.ItemsRepository | |||
| import com.ffii.fpsms.modules.master.entity.UomConversionRepository | |||
| import com.ffii.fpsms.modules.master.service.ItemUomService | |||
| import com.ffii.fpsms.modules.master.service.BomMaterialQtyService | |||
| import com.ffii.fpsms.modules.master.service.BomOutputQtyService | |||
| import com.ffii.fpsms.modules.master.web.models.MessageResponse | |||
| import org.springframework.stereotype.Service | |||
| import java.math.BigDecimal | |||
| @@ -20,22 +22,23 @@ open class JobOrderBomMaterialService( | |||
| private val jobOrderRepository: JobOrderRepository, | |||
| private val itemsRepository: ItemsRepository, | |||
| val itemUomService: ItemUomService, | |||
| private val uomConversionRepository: UomConversionRepository | |||
| private val uomConversionRepository: UomConversionRepository, | |||
| private val bomOutputQtyService: BomOutputQtyService, | |||
| private val bomMaterialQtyService: BomMaterialQtyService, | |||
| ) { | |||
| open fun createJobOrderBomMaterialRequests(joId: Long): List<CreateJobOrderBomMaterialRequest> { | |||
| val zero = BigDecimal.ZERO | |||
| val jo = jobOrderRepository.findById(joId).getOrNull() ?: throw NoSuchElementException() | |||
| val proportion = (jo.reqQty ?: zero).divide(jo.bom?.outputQty ?: BigDecimal.ONE, 5, RoundingMode.HALF_UP) | |||
| val bomStockOutputQty = bomOutputQtyService.stockOutputQty(jo.bom) | |||
| val proportion = (jo.reqQty ?: zero).divide(bomStockOutputQty, 5, RoundingMode.HALF_UP) | |||
| val jobmRequests = jo.bom?.bomMaterials?.map { bm -> | |||
| // val salesUnit = bm.item?.id?.let { itemUomService.findSalesUnitByItemId(it) } | |||
| val stockUnit = bm.item?.id?.let { itemUomService.findStockUnitByItemId(it) } | |||
| CreateJobOrderBomMaterialRequest( | |||
| joId = joId, | |||
| itemId = bm.item?.id, | |||
| //reqQty = (bm.qty?.times(proportion) ?: zero).setScale(0,RoundingMode.CEILING), | |||
| reqQty = (bm.stockQty?.times(proportion) ?: zero).setScale(0, RoundingMode.CEILING), | |||
| uomId = stockUnit?.uom?.id | |||
| reqQty = bomMaterialQtyService.stockQtyForJob(bm, proportion), | |||
| uomId = bomMaterialQtyService.stockUomIdForJob(bm) ?: stockUnit?.uom?.id | |||
| ) | |||
| } ?: listOf() | |||
| @@ -43,10 +46,25 @@ open class JobOrderBomMaterialService( | |||
| } | |||
| fun createJobOrderBomMaterials(request: List<CreateJobOrderBomMaterialRequest>): MessageResponse { | |||
| val joIds = request.mapNotNull { it.joId }.distinct() | |||
| val itemIds = request.mapNotNull { it.itemId }.distinct() | |||
| val uomIds = request.mapNotNull { it.uomId }.distinct() | |||
| // 批量取回,避免在 map 裡逐筆 findById 造成 N+1。 | |||
| val joById = if (joIds.isNotEmpty()) { | |||
| jobOrderRepository.findAllById(joIds).associateBy { it.id } | |||
| } else emptyMap() | |||
| val itemById = if (itemIds.isNotEmpty()) { | |||
| itemsRepository.findAllById(itemIds).associateBy { it.id } | |||
| } else emptyMap() | |||
| val uomById = if (uomIds.isNotEmpty()) { | |||
| uomConversionRepository.findAllById(uomIds).associateBy { it.id } | |||
| } else emptyMap() | |||
| val joBomMaterials = request.map { req -> | |||
| val jo = req.joId?.let { jobOrderRepository.findById(it).getOrNull() } | |||
| val item = req.itemId?.let { itemsRepository.findById(it).getOrNull() } | |||
| val uom = req.uomId?.let { uomConversionRepository.findById(it).getOrNull() } | |||
| val jo = req.joId?.let { joById[it] } | |||
| val item = req.itemId?.let { itemById[it] } | |||
| val uom = req.uomId?.let { uomById[it] } | |||
| val roundedReqQty = req.reqQty?.setScale(0, RoundingMode.CEILING) | |||
| val statusEnum = JobOrderBomMaterialStatus.entries.find { it.value == req.status } | |||
| ?: JobOrderBomMaterialStatus.PENDING | |||
| @@ -0,0 +1,243 @@ | |||
| package com.ffii.fpsms.modules.jobOrder.service | |||
| import com.ffii.fpsms.modules.jobOrder.entity.JobOrder | |||
| import com.ffii.fpsms.modules.jobOrder.entity.JobOrderRepository | |||
| import com.ffii.fpsms.modules.jobOrder.enums.JobOrderStatus | |||
| import com.ffii.fpsms.modules.pickOrder.entity.PickOrder | |||
| import com.ffii.fpsms.modules.pickOrder.entity.PickOrderRepository | |||
| import com.ffii.fpsms.modules.productProcess.entity.ProductProcess | |||
| import com.ffii.fpsms.modules.productProcess.entity.ProductProcessRepository | |||
| import com.ffii.fpsms.modules.productProcess.enums.ProductProcessStatus | |||
| import com.ffii.fpsms.modules.stock.entity.StockInLineRepository | |||
| import com.ffii.fpsms.modules.stock.service.StockInLineService | |||
| import org.slf4j.LoggerFactory | |||
| import org.springframework.stereotype.Service | |||
| import org.springframework.transaction.support.TransactionTemplate | |||
| import java.time.LocalDateTime | |||
| import java.util.concurrent.atomic.AtomicBoolean | |||
| /** | |||
| * Daily batch after plan day ends: at run time (default 00:00:15), process job orders whose | |||
| * [JobOrder.planStart] fell on the previous calendar day. | |||
| * | |||
| * - Branch A: no pick submitted, product process pending → hide job order. | |||
| * - Branch B: pick submitted, product process still pending → reschedule to today 00:00:00 and renumber. | |||
| */ | |||
| @Service | |||
| open class JobOrderPlanStartAutoService( | |||
| private val jobOrderRepository: JobOrderRepository, | |||
| private val pickOrderRepository: PickOrderRepository, | |||
| private val productProcessRepository: ProductProcessRepository, | |||
| private val stockInLineRepository: StockInLineRepository, | |||
| private val jobOrderService: JobOrderService, | |||
| private val stockInLineService: StockInLineService, | |||
| private val transactionTemplate: TransactionTemplate, | |||
| ) { | |||
| private val logger = LoggerFactory.getLogger(javaClass) | |||
| private val inFlight = AtomicBoolean(false) | |||
| data class JobOrderPlanStartAutoReport( | |||
| val runAt: LocalDateTime, | |||
| val targetPlanDayFrom: LocalDateTime, | |||
| val targetPlanDayToExclusive: LocalDateTime, | |||
| val candidates: Int = 0, | |||
| val hidden: Int = 0, | |||
| val rescheduled: Int = 0, | |||
| val skipped: Int = 0, | |||
| val errors: Int = 0, | |||
| ) | |||
| open fun runAutoProcess(runAt: LocalDateTime = LocalDateTime.now()): JobOrderPlanStartAutoReport { | |||
| if (!inFlight.compareAndSet(false, true)) { | |||
| logger.warn("Job order plan-start auto process skipped: previous run still in flight") | |||
| val targetDay = runAt.toLocalDate().minusDays(1) | |||
| return JobOrderPlanStartAutoReport( | |||
| runAt = runAt, | |||
| targetPlanDayFrom = targetDay.atStartOfDay(), | |||
| targetPlanDayToExclusive = targetDay.plusDays(1).atStartOfDay(), | |||
| ) | |||
| } | |||
| try { | |||
| return runAutoProcessInternal(runAt) | |||
| } finally { | |||
| inFlight.set(false) | |||
| } | |||
| } | |||
| private fun runAutoProcessInternal(runAt: LocalDateTime): JobOrderPlanStartAutoReport { | |||
| val targetDay = runAt.toLocalDate().minusDays(1) | |||
| val from = targetDay.atStartOfDay() | |||
| val toExclusive = targetDay.plusDays(1).atStartOfDay() | |||
| val newPlanStart = runAt.toLocalDate().atStartOfDay() | |||
| var hidden = 0 | |||
| var rescheduled = 0 | |||
| var skipped = 0 | |||
| var errors = 0 | |||
| val jobOrders = jobOrderRepository | |||
| .findByDeletedFalseAndPlanStartFromBeforeExclusiveOrderByIdAsc(from, toExclusive) | |||
| .filter { isEligibleCandidate(it) } | |||
| val joIds = jobOrders.mapNotNull { it.id } | |||
| val pickOrdersByJoId = loadPickOrdersByJobOrderId(joIds) | |||
| val productProcessesByJoId = loadProductProcessesByJobOrderId(joIds) | |||
| logger.info( | |||
| "Job order plan-start auto: runAt={}, targetPlanDay=[{}, {}), candidates={}", | |||
| runAt, | |||
| from, | |||
| toExclusive, | |||
| jobOrders.size, | |||
| ) | |||
| for (jo in jobOrders) { | |||
| val joId = jo.id ?: continue | |||
| try { | |||
| when ( | |||
| classify( | |||
| jo, | |||
| pickOrdersByJoId[joId].orEmpty(), | |||
| productProcessesByJoId[joId], | |||
| ) | |||
| ) { | |||
| Branch.HIDE -> { | |||
| transactionTemplate.executeWithoutResult { | |||
| applyHide(jo, runAt) | |||
| } | |||
| hidden++ | |||
| } | |||
| Branch.RESCHEDULE -> { | |||
| transactionTemplate.executeWithoutResult { | |||
| applyReschedule(jo, productProcessesByJoId[joId], newPlanStart, runAt) | |||
| } | |||
| rescheduled++ | |||
| } | |||
| Branch.SKIP -> skipped++ | |||
| } | |||
| } catch (e: Exception) { | |||
| errors++ | |||
| logger.error("Job order plan-start auto failed for joId={} code={}: {}", joId, jo.code, e.message, e) | |||
| } | |||
| } | |||
| val report = JobOrderPlanStartAutoReport( | |||
| runAt = runAt, | |||
| targetPlanDayFrom = from, | |||
| targetPlanDayToExclusive = toExclusive, | |||
| candidates = jobOrders.size, | |||
| hidden = hidden, | |||
| rescheduled = rescheduled, | |||
| skipped = skipped, | |||
| errors = errors, | |||
| ) | |||
| logger.info("Job order plan-start auto finished: {}", report) | |||
| return report | |||
| } | |||
| private fun isEligibleCandidate(jo: JobOrder): Boolean { | |||
| if (jo.isHidden == true) return false | |||
| if (jo.status == JobOrderStatus.COMPLETED) return false | |||
| return true | |||
| } | |||
| private enum class Branch { | |||
| HIDE, | |||
| RESCHEDULE, | |||
| SKIP, | |||
| } | |||
| private fun classify( | |||
| jo: JobOrder, | |||
| pickOrders: List<PickOrder>, | |||
| productProcess: ProductProcess?, | |||
| ): Branch { | |||
| if (!isProductProcessPendingNotStarted(productProcess)) { | |||
| return Branch.SKIP | |||
| } | |||
| val maxSubmittedLines = pickOrders.maxOfOrNull { it.submittedLines ?: 0 } ?: 0 | |||
| return when { | |||
| maxSubmittedLines == 0 -> Branch.HIDE | |||
| maxSubmittedLines > 0 -> Branch.RESCHEDULE | |||
| else -> Branch.SKIP | |||
| } | |||
| } | |||
| private fun isProductProcessPendingNotStarted(productProcess: ProductProcess?): Boolean { | |||
| if (productProcess == null) return false | |||
| if (productProcess.deleted) return false | |||
| if (productProcess.status != ProductProcessStatus.PENDING) return false | |||
| if (productProcess.startTime != null) return false | |||
| return true | |||
| } | |||
| private fun loadPickOrdersByJobOrderId(jobOrderIds: List<Long>): Map<Long, List<PickOrder>> { | |||
| if (jobOrderIds.isEmpty()) return emptyMap() | |||
| return pickOrderRepository | |||
| .findAllByJobOrder_IdInAndDeletedFalseOrderByJobOrder_IdAscCreatedDesc(jobOrderIds) | |||
| .groupBy { it.jobOrder?.id ?: -1L } | |||
| .filterKeys { it > 0L } | |||
| } | |||
| private fun loadProductProcessesByJobOrderId(jobOrderIds: List<Long>): Map<Long, ProductProcess> { | |||
| if (jobOrderIds.isEmpty()) return emptyMap() | |||
| return productProcessRepository | |||
| .findByJobOrder_IdInAndDeletedIsFalse(jobOrderIds) | |||
| .mapNotNull { pp -> pp.jobOrder?.id?.let { it to pp } } | |||
| .groupBy { it.first } | |||
| .mapValues { (_, entries) -> | |||
| entries.map { it.second }.firstOrNull { isProductProcessPendingNotStarted(it) } | |||
| ?: entries.map { it.second }.first() | |||
| } | |||
| .filterValues { it != null } | |||
| .mapValues { it.value!! } | |||
| } | |||
| private fun applyHide(jo: JobOrder, runAt: LocalDateTime) { | |||
| jo.isHidden = true | |||
| appendRemarks(jo, "[auto ${runAt.toLocalDate()}] hidden: overdue plan day, no pick submitted, process pending") | |||
| jobOrderRepository.save(jo) | |||
| logger.info("Job order plan-start auto hid joId={} code={}", jo.id, jo.code) | |||
| } | |||
| private fun applyReschedule( | |||
| jo: JobOrder, | |||
| productProcess: ProductProcess?, | |||
| newPlanStart: LocalDateTime, | |||
| runAt: LocalDateTime, | |||
| ) { | |||
| val pp = productProcess?.takeIf { isProductProcessPendingNotStarted(it) } | |||
| ?: throw IllegalStateException("Product process not pending for reschedule, joId=${jo.id}") | |||
| val newCode = jobOrderService.assignJobNo(newPlanStart) | |||
| jo.planStart = newPlanStart | |||
| jo.code = newCode | |||
| appendRemarks( | |||
| jo, | |||
| "[auto ${runAt.toLocalDate()}] rescheduled from overdue plan day; pick started, process pending", | |||
| ) | |||
| jobOrderRepository.save(jo) | |||
| pp.date = newPlanStart.toLocalDate() | |||
| productProcessRepository.save(pp) | |||
| val sil = jo.id?.let { stockInLineRepository.findFirstByJobOrder_IdAndDeletedFalse(it) } | |||
| if (sil != null) { | |||
| sil.lotNo = stockInLineService.assignLotNoForJo(newPlanStart.toLocalDate()) | |||
| sil.productLotNo = newCode | |||
| stockInLineRepository.save(sil) | |||
| } | |||
| logger.info( | |||
| "Job order plan-start auto rescheduled joId={} newCode={} newPlanStart={}", | |||
| jo.id, | |||
| newCode, | |||
| newPlanStart, | |||
| ) | |||
| } | |||
| private fun appendRemarks(jo: JobOrder, snippet: String) { | |||
| val existing = jo.remarks?.trim().orEmpty() | |||
| jo.remarks = if (existing.isEmpty()) snippet else "$existing | $snippet" | |||
| } | |||
| } | |||
| @@ -69,6 +69,8 @@ import java.time.LocalDate | |||
| import java.time.LocalDateTime | |||
| import com.ffii.fpsms.modules.master.entity.BomMaterialRepository | |||
| import com.ffii.fpsms.modules.master.service.ItemUomService | |||
| import com.ffii.fpsms.modules.master.service.BomMaterialQtyService | |||
| import com.ffii.fpsms.modules.master.service.BomOutputQtyService | |||
| import com.ffii.fpsms.modules.master.web.models.ConvertUomByItemRequest | |||
| import com.ffii.fpsms.modules.stock.service.StockInLineService | |||
| import com.ffii.fpsms.modules.stock.web.model.SaveStockInLineRequest | |||
| @@ -95,8 +97,9 @@ open class JobOrderService( | |||
| val productProcessRepository: ProductProcessRepository, | |||
| val jobOrderBomMaterialRepository: JobOrderBomMaterialRepository, | |||
| val bomMaterialRepository: BomMaterialRepository, | |||
| val itemUomService: ItemUomService | |||
| val itemUomService: ItemUomService, | |||
| private val bomOutputQtyService: BomOutputQtyService, | |||
| private val bomMaterialQtyService: BomMaterialQtyService, | |||
| ) { | |||
| open fun allJobOrdersByPage(request: SearchJobOrderInfoRequest): RecordsRes<JobOrderInfo> { | |||
| @@ -579,10 +582,14 @@ open class JobOrderService( | |||
| } | |||
| // ✅ 使用 stockReqQty (bomMaterial.saleQty) 和 stockUom (bomMaterial.salesUnit) | |||
| val stockReqQty = jobm.reqQty ?: bomMaterial?.stockQty ?: BigDecimal.ZERO | |||
| val stockUomId = bomMaterial?.stockUnit?.toLong() | |||
| ?: itemUomService.findStockUnitByItemId(itemId)?.uom?.id // Fallback: 从 Item 获取库存单位 | |||
| ?: jobm.uom?.id // 最后的 fallback | |||
| val stockReqQty = jobm.reqQty | |||
| ?: bomMaterial?.let { bm -> | |||
| bomMaterialQtyService.stockQtyForJob(bm, BigDecimal.ONE) | |||
| } | |||
| ?: BigDecimal.ZERO | |||
| val stockUomId = bomMaterial?.let { bomMaterialQtyService.stockUomIdForJob(it) } | |||
| ?: itemUomService.findStockUnitByItemId(itemId)?.uom?.id | |||
| ?: jobm.uom?.id | |||
| SavePickOrderLineRequest( | |||
| itemId = itemId, | |||
| @@ -737,6 +744,92 @@ open class JobOrderService( | |||
| } | |||
| //Pick Record | |||
| private fun validatePickRecordFloor(floor: String?): String { | |||
| val normalizedFloor = floor?.trim()?.uppercase() | |||
| ?: throw BadRequestException("floor is required for pick record print") | |||
| if (normalizedFloor !in setOf("2F", "3F", "4F", "ALL")) { | |||
| throw BadRequestException("floor must be one of 2F, 3F, 4F, ALL") | |||
| } | |||
| return normalizedFloor | |||
| } | |||
| private fun validatePlasticBoxCartonQty(qty: Int?): Int { | |||
| val value = qty ?: throw BadRequestException("plasticBoxCartonQty is required") | |||
| if (value < 1) { | |||
| throw BadRequestException("plasticBoxCartonQty must be at least 1") | |||
| } | |||
| return value | |||
| } | |||
| private data class AllFloorsPlasticBoxCartonQty( | |||
| val qty2f: Int, | |||
| val qty3f: Int, | |||
| val qty4f: Int, | |||
| val sum: Int, | |||
| ) | |||
| private fun normalizeFloorPlasticBoxCartonQty(qty: Int?): Int { | |||
| if (qty == null) return 0 | |||
| if (qty < 0) { | |||
| throw BadRequestException("plastic box carton qty cannot be negative") | |||
| } | |||
| return qty | |||
| } | |||
| private fun resolveAllFloorsPlasticBoxCartonQty( | |||
| qty2f: Int?, | |||
| qty3f: Int?, | |||
| qty4f: Int?, | |||
| ): AllFloorsPlasticBoxCartonQty { | |||
| val q2 = normalizeFloorPlasticBoxCartonQty(qty2f) | |||
| val q3 = normalizeFloorPlasticBoxCartonQty(qty3f) | |||
| val q4 = normalizeFloorPlasticBoxCartonQty(qty4f) | |||
| return AllFloorsPlasticBoxCartonQty(q2, q3, q4, q2 + q3 + q4) | |||
| } | |||
| private fun updatePickOrderPlasticBoxCartonQty(pickOrderId: Long, floor: String, qty: Int) { | |||
| val pickOrder = pickOrderRepository.findById(pickOrderId).orElse(null) ?: return | |||
| when (floor) { | |||
| "2F" -> pickOrder.plasticBoxCartonQty2f = qty | |||
| "3F" -> pickOrder.plasticBoxCartonQty3f = qty | |||
| "4F" -> pickOrder.plasticBoxCartonQty4f = qty | |||
| } | |||
| pickOrderRepository.save(pickOrder) | |||
| } | |||
| private fun persistAllFloorsPlasticBoxCartonQty(pickOrderId: Long, all: AllFloorsPlasticBoxCartonQty) { | |||
| updatePickOrderPlasticBoxCartonQty(pickOrderId, "2F", all.qty2f) | |||
| updatePickOrderPlasticBoxCartonQty(pickOrderId, "3F", all.qty3f) | |||
| updatePickOrderPlasticBoxCartonQty(pickOrderId, "4F", all.qty4f) | |||
| } | |||
| open fun getPickRecordPlasticBoxCartonQty(pickOrderId: Long): PickRecordPlasticBoxCartonQtyResponse { | |||
| val pickOrder = pickOrderRepository.findById(pickOrderId).orElseThrow { | |||
| NoSuchElementException("Pick order not found with ID: $pickOrderId") | |||
| } | |||
| return PickRecordPlasticBoxCartonQtyResponse( | |||
| plasticBoxCartonQty2f = pickOrder.plasticBoxCartonQty2f, | |||
| plasticBoxCartonQty3f = pickOrder.plasticBoxCartonQty3f, | |||
| plasticBoxCartonQty4f = pickOrder.plasticBoxCartonQty4f, | |||
| ) | |||
| } | |||
| private fun resolvePlasticBoxCartonQtyForPickRecord(request: ExportPickRecordRequest): Int { | |||
| val floor = validatePickRecordFloor(request.floor) | |||
| return if (floor == "ALL") { | |||
| val all = resolveAllFloorsPlasticBoxCartonQty( | |||
| request.plasticBoxCartonQty2f, | |||
| request.plasticBoxCartonQty3f, | |||
| request.plasticBoxCartonQty4f, | |||
| ) | |||
| persistAllFloorsPlasticBoxCartonQty(request.pickOrderIds, all) | |||
| all.sum | |||
| } else { | |||
| request.plasticBoxCartonQty | |||
| ?: throw BadRequestException("plasticBoxCartonQty is required") | |||
| } | |||
| } | |||
| @Throws(IOException::class) | |||
| @Transactional | |||
| open fun exportPickRecord(request: ExportPickRecordRequest): Map<String, Any> { | |||
| @@ -821,6 +914,8 @@ open class JobOrderService( | |||
| println("unit (from BOM): $unit")*/ | |||
| params["unit"] = pickRecordInfo.firstOrNull()?.get("uomConversionDesc") as? String ?: "N/A" | |||
| val plasticBoxCartonQtyForPdf = resolvePlasticBoxCartonQtyForPickRecord(request) | |||
| params["PlasticBoxCartonQty"] = plasticBoxCartonQtyForPdf.toString() | |||
| val pickOrderCode = pickRecordInfo.firstOrNull()?.get("pickOrderCode") as? String ?: "unknown" | |||
| return mapOf( | |||
| @@ -833,13 +928,26 @@ open class JobOrderService( | |||
| @Transactional | |||
| open fun printPickRecord(request: PrintPickRecordRequest){ | |||
| val printer = printerService.findById(request.printerId) ?: throw java.util.NoSuchElementException("No such printer") | |||
| val pdf = exportPickRecord( | |||
| val floor = validatePickRecordFloor(request.floor) | |||
| val exportRequest = if (floor == "ALL") { | |||
| ExportPickRecordRequest( | |||
| pickOrderIds = request.pickOrderId, | |||
| floor = request.floor, | |||
| plasticBoxCartonQty2f = request.plasticBoxCartonQty2f, | |||
| plasticBoxCartonQty3f = request.plasticBoxCartonQty3f, | |||
| plasticBoxCartonQty4f = request.plasticBoxCartonQty4f, | |||
| ) | |||
| ) | |||
| } else { | |||
| val plasticBoxCartonQty = validatePlasticBoxCartonQty(request.plasticBoxCartonQty) | |||
| updatePickOrderPlasticBoxCartonQty(request.pickOrderId, floor, plasticBoxCartonQty) | |||
| ExportPickRecordRequest( | |||
| pickOrderIds = request.pickOrderId, | |||
| floor = request.floor, | |||
| plasticBoxCartonQty = plasticBoxCartonQty, | |||
| ) | |||
| } | |||
| val pdf = exportPickRecord(exportRequest) | |||
| val jasperPrint = pdf["report"] as JasperPrint | |||
| @@ -1023,7 +1131,8 @@ open fun updateJoReqQty(request: UpdateJoReqQtyRequest): MessageResponse { | |||
| // 更新相关的 JobOrderBomMaterial 的 reqQty(根据新的比例重新计算) | |||
| val bom = jobOrder.bom | |||
| if (bom != null && bom.outputQty != null && bom.outputQty!! > BigDecimal.ZERO) { | |||
| val proportion = newReqQty.divide(bom.outputQty!!, 5, RoundingMode.HALF_UP) | |||
| val bomStockOutputQty = bomOutputQtyService.stockOutputQty(bom) | |||
| val proportion = newReqQty.divide(bomStockOutputQty, 5, RoundingMode.HALF_UP) | |||
| val jobOrderBomMaterials = jobOrderBomMaterialRepository.findAllByJobOrderId(jobOrder.id) | |||
| jobOrderBomMaterials.forEach { jobm -> | |||
| @@ -35,9 +35,8 @@ class LaserBag2AutoSendService( | |||
| sendsPerJob: Int = defaultSendsPerJob, | |||
| delayBetweenSendsMs: Long = defaultDelayBetweenSendsMs, | |||
| ): LaserBag2AutoSendReport { | |||
| val (reachable, laserIp, laserPort) = plasticBagPrinterService.probeLaserBag2Tcp() | |||
| val (reachable, _, _) = plasticBagPrinterService.probeLaserBag2Tcp() | |||
| if (!reachable) { | |||
| logger.warn("Connection failed to the laser print: {} / {}", laserIp, laserPort) | |||
| return LaserBag2AutoSendReport( | |||
| planStart = planStart, | |||
| jobOrdersFound = 0, | |||
| @@ -7,13 +7,25 @@ import org.springframework.stereotype.Service | |||
| import java.time.LocalDate | |||
| import com.ffii.core.support.JdbcDao | |||
| import org.apache.poi.ss.usermodel.FillPatternType | |||
| import org.apache.poi.ss.usermodel.IndexedColors | |||
| import org.apache.poi.ss.usermodel.VerticalAlignment | |||
| import org.apache.poi.xssf.usermodel.XSSFWorkbook | |||
| import java.io.ByteArrayOutputStream | |||
| import java.math.BigDecimal | |||
| import java.math.RoundingMode | |||
| @Service | |||
| open class PSService( | |||
| private val jdbcDao: JdbcDao, | |||
| ) { | |||
| /** Default: 6 days before today to 1 day after today. */ | |||
| /** | |||
| * FG BOM settings rows for 排期設定. | |||
| * Default date window: 6 days before today to 1 day after today. | |||
| * | |||
| * Performance: DO daily avg is pre-aggregated once (date-first via idx_do_deleted_eta), | |||
| * then joined — avoids per-item correlated scans of full delivery_order_line history. | |||
| */ | |||
| fun getItemDailyOut(fromDate: LocalDate? = null, toDate: LocalDate? = null): List<Map<String, Any>> { | |||
| val defaultToday = LocalDate.now() | |||
| val to = toDate ?: defaultToday.plusDays(1) | |||
| @@ -23,41 +35,59 @@ open class PSService( | |||
| "toDate" to to.toString(), | |||
| ) | |||
| val sql = """ | |||
| WITH do_daily AS ( | |||
| SELECT | |||
| dol.itemId, | |||
| DATE(do.estimatedArrivalDate) AS shipDate, | |||
| SUM(dol.qty) AS dayQty | |||
| FROM delivery_order do | |||
| INNER JOIN delivery_order_line dol | |||
| ON dol.deliveryOrderId = do.id AND dol.deleted = 0 | |||
| WHERE do.deleted = 0 | |||
| AND do.estimatedArrivalDate >= :fromDate | |||
| AND do.estimatedArrivalDate <= :toDate | |||
| GROUP BY dol.itemId, DATE(do.estimatedArrivalDate) | |||
| ), | |||
| avg_daily AS ( | |||
| SELECT itemId, IFNULL(ROUND(AVG(dayQty)), 0) AS avgQtyLastMonth | |||
| FROM do_daily | |||
| GROUP BY itemId | |||
| ), | |||
| pending_job AS ( | |||
| SELECT bomId, SUM(reqQty) AS pendingJobQty | |||
| FROM job_order | |||
| WHERE status != 'completed' | |||
| GROUP BY bomId | |||
| ), | |||
| cot AS ( | |||
| SELECT | |||
| itemCode, | |||
| SUM(CASE WHEN systemType = 'coffee' THEN 1 ELSE 0 END) AS isCoffee, | |||
| SUM(CASE WHEN systemType = 'tea' THEN 1 ELSE 0 END) AS isTea, | |||
| SUM(CASE WHEN systemType = 'lemon' THEN 1 ELSE 0 END) AS isLemon | |||
| FROM coffee_or_tea | |||
| WHERE deleted = 0 | |||
| GROUP BY itemCode | |||
| ) | |||
| SELECT | |||
| (SELECT dailyQty FROM item_daily_out WHERE itemCode = items.code) AS dailyQty, | |||
| (SELECT | |||
| IFNULL(ROUND(AVG(d.dailyQty)), 0) | |||
| FROM | |||
| (SELECT | |||
| SUM(dol.qty) AS dailyQty | |||
| FROM | |||
| delivery_order_line dol | |||
| LEFT JOIN delivery_order do ON dol.deliveryOrderId = do.id | |||
| WHERE | |||
| do.deleted = 0 | |||
| AND dol.itemId = items.id | |||
| AND do.estimatedArrivalDate >= :fromDate AND do.estimatedArrivalDate <= :toDate | |||
| GROUP BY do.estimatedArrivalDate) AS d) AS avgQtyLastMonth, | |||
| (SELECT SUM(reqQty) FROM job_order WHERE bomId = bom.id AND status != 'completed') AS pendingJobQty, | |||
| (SELECT COUNT(1) FROM coffee_or_tea WHERE systemType = 'coffee' AND itemCode = items.code AND deleted = 0) AS isCoffee, | |||
| (SELECT COUNT(1) FROM coffee_or_tea WHERE systemType = 'tea' AND itemCode = items.code AND deleted = 0) AS isTea, | |||
| (SELECT COUNT(1) FROM coffee_or_tea WHERE systemType = 'lemon' AND itemCode = items.code AND deleted = 0) AS isLemon, | |||
| CASE WHEN item_fake_onhand.onHandQty IS NOT NULL THEN item_fake_onhand.onHandQty | |||
| ELSE inventory.onHandQty - 500 END AS stockQty, | |||
| ido.dailyQty AS dailyQty, | |||
| IFNULL(ad.avgQtyLastMonth, 0) AS avgQtyLastMonth, | |||
| pj.pendingJobQty AS pendingJobQty, | |||
| IFNULL(cot.isCoffee, 0) AS isCoffee, | |||
| IFNULL(cot.isTea, 0) AS isTea, | |||
| IFNULL(cot.isLemon, 0) AS isLemon, | |||
| CASE WHEN ifo.onHandQty IS NOT NULL THEN ifo.onHandQty | |||
| ELSE inventory.onHandQty - 500 END AS stockQty, | |||
| bom.baseScore, | |||
| bom.outputQty, | |||
| bom.outputQtyUom, | |||
| (SELECT udfudesc | |||
| FROM delivery_order_line | |||
| LEFT JOIN uom_conversion ON delivery_order_line.uomId = uom_conversion.id | |||
| WHERE delivery_order_line.itemId = bom.itemId | |||
| LIMIT 1) AS doUom, | |||
| NULL AS doUom, | |||
| items.code AS itemCode, | |||
| items.name AS itemName, | |||
| uc_stock.udfudesc AS unit, | |||
| bom.description, | |||
| bom.bomKind, | |||
| inventory.onHandQty, | |||
| item_fake_onhand.onHandQty AS fakeOnHandQty, | |||
| ifo.onHandQty AS fakeOnHandQty, | |||
| bom.itemId, | |||
| bom.id AS bomId, | |||
| CASE WHEN bom.isDark = 5 THEN 11 WHEN bom.isDark = 3 THEN 6 WHEN bom.isDark = 1 THEN 2 ELSE 0 END AS markDark, | |||
| @@ -67,14 +97,19 @@ open class PSService( | |||
| bom.complexity AS markComplexity, | |||
| CASE WHEN bom.allergicSubstances = 5 THEN 11 ELSE 0 END AS markAS, | |||
| inventory.id AS inventoryId | |||
| FROM | |||
| bom | |||
| FROM bom | |||
| LEFT JOIN items ON bom.itemId = items.id | |||
| LEFT JOIN inventory ON items.id = inventory.itemId | |||
| LEFT JOIN item_fake_onhand ON items.code = item_fake_onhand.itemCode | |||
| LEFT JOIN item_fake_onhand ifo ON items.code = ifo.itemCode | |||
| LEFT JOIN item_daily_out ido ON items.code = ido.itemCode | |||
| LEFT JOIN item_uom iu ON iu.itemId = items.id AND iu.stockUnit = 1 | |||
| LEFT JOIN uom_conversion uc_stock ON uc_stock.id = iu.uomId | |||
| WHERE bom.deleted = 0 and bom.description = 'FG' | |||
| LEFT JOIN avg_daily ad ON ad.itemId = items.id | |||
| LEFT JOIN pending_job pj ON pj.bomId = bom.id | |||
| LEFT JOIN cot ON cot.itemCode = items.code | |||
| WHERE bom.deleted = 0 | |||
| AND bom.bomKind = 'FG' | |||
| ORDER BY items.code | |||
| """.trimIndent() | |||
| return jdbcDao.queryForList(sql, args) | |||
| } | |||
| @@ -160,6 +195,49 @@ open class PSService( | |||
| } | |||
| /** Set or clear coffee_or_tea for itemCode + systemType (coffee / tea / lemon). */ | |||
| /** | |||
| * Recalculate [inventory.onHandQty] / hold / unavailable from [inventory_lot_line] for FG BOM items. | |||
| * Same aggregation as pick-issue manual inventory sync. | |||
| */ | |||
| fun refreshInventoryOnHandForFgBomItems(): Int { | |||
| val sql = """ | |||
| UPDATE inventory i | |||
| INNER JOIN ( | |||
| SELECT DISTINCT b.itemId | |||
| FROM bom b | |||
| WHERE b.deleted = 0 AND b.bomKind = 'FG' | |||
| ) bom_items ON bom_items.itemId = i.itemId | |||
| LEFT JOIN ( | |||
| SELECT | |||
| il.itemId, | |||
| SUM(COALESCE(ill.inQty, 0) - COALESCE(ill.outQty, 0)) AS totalOnHandQty, | |||
| SUM(COALESCE(ill.holdQty, 0)) AS totalOnHoldQty, | |||
| SUM(CASE | |||
| WHEN ill.status = 'unavailable' | |||
| THEN COALESCE(ill.inQty, 0) - COALESCE(ill.outQty, 0) - COALESCE(ill.holdQty, 0) | |||
| ELSE 0 | |||
| END) AS totalUnavailableQty | |||
| FROM inventory_lot_line ill | |||
| INNER JOIN inventory_lot il ON il.id = ill.inventoryLotId AND il.deleted = 0 | |||
| WHERE ill.deleted = 0 | |||
| GROUP BY il.itemId | |||
| ) calc ON calc.itemId = i.itemId | |||
| SET | |||
| i.onHandQty = COALESCE(calc.totalOnHandQty, 0), | |||
| i.onHoldQty = COALESCE(calc.totalOnHoldQty, 0), | |||
| i.unavailableQty = COALESCE(calc.totalUnavailableQty, 0), | |||
| i.status = IF( | |||
| COALESCE(calc.totalOnHandQty, 0) - COALESCE(calc.totalOnHoldQty, 0) - COALESCE(calc.totalUnavailableQty, 0) > 0, | |||
| 'available', | |||
| 'unavailable' | |||
| ), | |||
| i.modified = NOW(), | |||
| i.modifiedBy = 'ps-refresh-onhand' | |||
| WHERE i.deleted = 0 | |||
| """.trimIndent() | |||
| return jdbcDao.executeUpdate(sql, emptyMap<String, Any>()) | |||
| } | |||
| fun setCoffeeOrTea(itemCode: String, systemType: String, enabled: Boolean) { | |||
| val args = mapOf("itemCode" to itemCode, "systemType" to systemType) | |||
| jdbcDao.executeUpdate( | |||
| @@ -214,4 +292,167 @@ open class PSService( | |||
| return jdbcDao.queryForList(sql, args) | |||
| } | |||
| /** | |||
| * Items with at least one BOM (deleted = 0), plus stock-unit label. | |||
| */ | |||
| fun listBomItemsWithStockUnit(): List<Map<String, Any>> { | |||
| val sql = """ | |||
| SELECT | |||
| items.code AS itemCode, | |||
| items.name AS itemName, | |||
| uc_stock.udfudesc AS stockUnit | |||
| FROM ( | |||
| SELECT DISTINCT b.itemId | |||
| FROM bom b | |||
| WHERE b.deleted = 0 | |||
| ) bom_items | |||
| INNER JOIN items ON items.id = bom_items.itemId AND items.deleted = 0 | |||
| LEFT JOIN item_uom iu_stock ON iu_stock.itemId = items.id | |||
| AND iu_stock.stockUnit = 1 AND iu_stock.deleted = 0 | |||
| LEFT JOIN uom_conversion uc_stock ON uc_stock.id = iu_stock.uomId | |||
| ORDER BY items.code | |||
| """.trimIndent() | |||
| return jdbcDao.queryForList(sql, emptyMap<String, Any>()) | |||
| } | |||
| /** | |||
| * Sum of [delivery_order_line.qty] (stock unit) by item code and ETA date. | |||
| * When [bomItemCodes] is provided, only matching rows are returned (BOM filter in memory). | |||
| */ | |||
| fun sumDeliveryOrderQtyByItemAndDate( | |||
| fromDate: LocalDate, | |||
| toDate: LocalDate, | |||
| bomItemCodes: Set<String>? = null, | |||
| ): List<Map<String, Any>> { | |||
| val args = mapOf( | |||
| "fromDate" to fromDate.toString(), | |||
| "toDateExclusive" to toDate.plusDays(1).toString(), | |||
| ) | |||
| val sql = """ | |||
| WITH do_in_range AS ( | |||
| SELECT id, estimatedArrivalDate | |||
| FROM delivery_order | |||
| WHERE deleted = 0 | |||
| AND estimatedArrivalDate IS NOT NULL | |||
| AND estimatedArrivalDate >= :fromDate | |||
| AND estimatedArrivalDate < :toDateExclusive | |||
| ) | |||
| SELECT | |||
| items.code AS itemCode, | |||
| DATE(do.estimatedArrivalDate) AS shipDate, | |||
| SUM(COALESCE(dol.qty, 0)) AS qtySum | |||
| FROM do_in_range do | |||
| INNER JOIN delivery_order_line dol ON dol.deliveryOrderId = do.id AND dol.deleted = 0 | |||
| INNER JOIN items ON items.id = dol.itemId AND items.deleted = 0 | |||
| GROUP BY items.code, DATE(do.estimatedArrivalDate) | |||
| ORDER BY items.code, shipDate | |||
| """.trimIndent() | |||
| val rows = jdbcDao.queryForList(sql, args) | |||
| if (bomItemCodes.isNullOrEmpty()) { | |||
| return rows | |||
| } | |||
| return rows.filter { row -> | |||
| val itemCode = row["itemCode"]?.toString() ?: return@filter false | |||
| itemCode in bomItemCodes | |||
| } | |||
| } | |||
| fun exportDeliveryOrderQtyByDateExcel(fromDate: LocalDate, toDate: LocalDate): ByteArray { | |||
| require(!fromDate.isAfter(toDate)) { "fromDate must be on or before toDate" } | |||
| val dayCount = java.time.temporal.ChronoUnit.DAYS.between(fromDate, toDate) + 1 | |||
| require(dayCount in 1..366) { "Date range must be between 1 and 366 days" } | |||
| val dates = generateSequence(fromDate) { it.plusDays(1) }.takeWhile { !it.isAfter(toDate) }.toList() | |||
| val items = listBomItemsWithStockUnit() | |||
| val bomItemCodes = items.mapNotNull { it["itemCode"]?.toString() }.toSet() | |||
| val qtyRows = sumDeliveryOrderQtyByItemAndDate(fromDate, toDate, bomItemCodes) | |||
| val qtyByItemDate = mutableMapOf<String, MutableMap<LocalDate, BigDecimal>>() | |||
| qtyRows.forEach { row -> | |||
| val itemCode = row["itemCode"]?.toString() ?: return@forEach | |||
| val shipDate = parseSqlDate(row["shipDate"]) ?: return@forEach | |||
| val qty = toBigDecimal(row["qtySum"]) | |||
| qtyByItemDate.getOrPut(itemCode) { mutableMapOf() }[shipDate] = qty | |||
| } | |||
| val workbook = XSSFWorkbook() | |||
| val sheet = workbook.createSheet("DO Qty by Date") | |||
| val headerStyle = workbook.createCellStyle().apply { | |||
| fillForegroundColor = IndexedColors.GREY_25_PERCENT.index | |||
| fillPattern = FillPatternType.SOLID_FOREGROUND | |||
| verticalAlignment = VerticalAlignment.CENTER | |||
| val font = workbook.createFont() | |||
| font.bold = true | |||
| setFont(font) | |||
| } | |||
| val textStyle = workbook.createCellStyle().apply { | |||
| verticalAlignment = VerticalAlignment.CENTER | |||
| } | |||
| val headerRow = sheet.createRow(0) | |||
| val headers = mutableListOf("Item Code", "Item Name", "UOM") | |||
| headers.addAll(dates.map { it.toString() }) | |||
| headers.forEachIndexed { col, title -> | |||
| headerRow.createCell(col).apply { | |||
| setCellValue(title) | |||
| cellStyle = headerStyle | |||
| } | |||
| } | |||
| items.forEachIndexed { index, item -> | |||
| val row = sheet.createRow(index + 1) | |||
| val itemCode = item["itemCode"]?.toString() ?: "" | |||
| val itemName = item["itemName"]?.toString() ?: "" | |||
| val stockUnit = item["stockUnit"]?.toString() ?: "" | |||
| row.createCell(0).apply { setCellValue(itemCode); cellStyle = textStyle } | |||
| row.createCell(1).apply { setCellValue(itemName); cellStyle = textStyle } | |||
| row.createCell(2).apply { setCellValue(stockUnit); cellStyle = textStyle } | |||
| val dateQtyMap = qtyByItemDate[itemCode] | |||
| dates.forEachIndexed { dateIdx, date -> | |||
| val qty = (dateQtyMap?.get(date) ?: BigDecimal.ZERO) | |||
| .setScale(0, RoundingMode.HALF_UP) | |||
| row.createCell(3 + dateIdx).setCellValue(qty.toLong().toDouble()) | |||
| } | |||
| } | |||
| for (col in 0 until headers.size) { | |||
| sheet.setColumnWidth( | |||
| col, | |||
| when (col) { | |||
| 0 -> 4500 | |||
| 1 -> 9000 | |||
| 2 -> 3200 | |||
| else -> 3200 | |||
| }, | |||
| ) | |||
| } | |||
| ByteArrayOutputStream().use { out -> | |||
| workbook.write(out) | |||
| workbook.close() | |||
| return out.toByteArray() | |||
| } | |||
| } | |||
| private fun parseSqlDate(value: Any?): LocalDate? = when (value) { | |||
| null -> null | |||
| is LocalDate -> value | |||
| is java.sql.Date -> value.toLocalDate() | |||
| is java.time.LocalDateTime -> value.toLocalDate() | |||
| else -> { | |||
| val text = value.toString().trim() | |||
| if (text.length >= 10) LocalDate.parse(text.substring(0, 10)) else null | |||
| } | |||
| } | |||
| private fun toBigDecimal(value: Any?): BigDecimal = when (value) { | |||
| null -> BigDecimal.ZERO | |||
| is BigDecimal -> value | |||
| is Number -> BigDecimal.valueOf(value.toDouble()) | |||
| else -> value.toString().toBigDecimalOrNull() ?: BigDecimal.ZERO | |||
| } | |||
| } | |||
| @@ -177,7 +177,7 @@ class PlasticBagPrinterService( | |||
| val ids = filtered.mapNotNull { it.id } | |||
| val printed = pyJobOrderPrintSubmitService.sumPrintedQtyByJobOrderIds(ids) | |||
| return filtered.map { jo -> | |||
| PyJobOrderListMapper.toListItem(jo, printed[jo.id!!], stockInLineRepository, itemUomService) | |||
| PyJobOrderListMapper.toLaserListItem(jo, printed[jo.id!!], stockInLineRepository, itemUomService) | |||
| } | |||
| } | |||
| @@ -1367,15 +1367,18 @@ class PlasticBagPrinterService( | |||
| } | |||
| val qrValue = zplEscape(qrPayload) | |||
| // Must match python Bag2.py generate_zpl_dataflex() | |||
| // Must match python Bag3.py generate_zpl_dataflex() field layout / fonts. | |||
| val fontRegular = "E:STXihei.ttf" | |||
| val fontBold = "E:STXihei.ttf" | |||
| // Match python Bag3.py DataFlex defaults: narrower ^PW so job preview is not mostly empty on the right (^A@R fields are tall, not wide). | |||
| val labelPw = 400 | |||
| val labelLl = 500 | |||
| return """ | |||
| ^XA | |||
| ^CI28 | |||
| ^PW700 | |||
| ^LL500 | |||
| ^PW$labelPw | |||
| ^LL$labelLl | |||
| ^PO N | |||
| ^FO10,20 | |||
| ^BQN,2,4^FDQA,$qrValue^FS | |||
| @@ -27,7 +27,6 @@ import com.ffii.fpsms.modules.pickOrder.enums.PickOrderStatus | |||
| import org.springframework.format.annotation.DateTimeFormat | |||
| 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 | |||
| @@ -224,9 +223,19 @@ fun recordSecondScanIssue( | |||
| return joPickOrderService.getCompletedJobOrderPickOrderLotDetails(pickOrderId) | |||
| } | |||
| @GetMapping("/pick-record-plastic-box-carton-qty/{pickOrderId}") | |||
| fun getPickRecordPlasticBoxCartonQty(@PathVariable pickOrderId: Long): PickRecordPlasticBoxCartonQtyResponse { | |||
| return jobOrderService.getPickRecordPlasticBoxCartonQty(pickOrderId) | |||
| } | |||
| @GetMapping("/print-PickRecord") | |||
| fun printPickRecord(@ModelAttribute request: PrintPickRecordRequest){ | |||
| jobOrderService.printPickRecord(request) | |||
| } | |||
| @PostMapping("/PickRecord") | |||
| @Throws(UnsupportedEncodingException::class, NoSuchMessageException::class, ParseException::class, Exception::class) | |||
| fun printPickRecord(@Valid @RequestBody request: ExportPickRecordRequest, response: HttpServletResponse){ | |||
| fun exportPickRecord(@Valid @RequestBody request: ExportPickRecordRequest, response: HttpServletResponse) { | |||
| response.characterEncoding = "utf-8" | |||
| response.contentType = "application/pdf" | |||
| val out: OutputStream = response.outputStream | |||
| @@ -236,11 +245,6 @@ fun recordSecondScanIssue( | |||
| out.write(JasperExportManager.exportReportToPdf(jasperPrint)) | |||
| } | |||
| @GetMapping("/print-PickRecord") | |||
| fun printPickRecord(@ModelAttribute request: PrintPickRecordRequest){ | |||
| jobOrderService.printPickRecord(request) | |||
| } | |||
| @PostMapping("/FGStockInLabel") | |||
| @Throws(UnsupportedEncodingException::class, NoSuchMessageException::class, ParseException::class, Exception::class) | |||
| fun exportFGStockInLabel(@Valid @RequestBody request: ExportFGStockInLabelRequest, response: HttpServletResponse){ | |||
| @@ -272,6 +276,18 @@ fun recordSecondScanIssue( | |||
| ): List<Map<String, Any?>> { | |||
| return joPickOrderService.getCompletedJobOrderPickOrders(completedDate) | |||
| } | |||
| @GetMapping("/plastic-box-carton-qty-dashboard") | |||
| fun getPlasticBoxCartonQtyDashboard( | |||
| @RequestParam(name = "from") | |||
| @DateTimeFormat(iso = DateTimeFormat.ISO.DATE) | |||
| from: LocalDate, | |||
| @RequestParam(name = "to") | |||
| @DateTimeFormat(iso = DateTimeFormat.ISO.DATE) | |||
| to: LocalDate, | |||
| ): List<PlasticBoxCartonQtyDashboardRecord> { | |||
| return joPickOrderService.getPlasticBoxCartonQtyDashboard(from, to) | |||
| } | |||
| @GetMapping("/job-order-pick-orders") | |||
| fun getJobOrderPickOrders( | |||
| @RequestParam(name = "date", required = false) | |||
| @@ -332,7 +348,7 @@ fun getJobOrderPickOrderLotDetails( | |||
| /** Workbench: available qty uses in−out (matches scan-pick); stockouts include suggested SPL qty when matched. */ | |||
| @GetMapping("/all-lots-hierarchical-by-pick-order-workbench/{pickOrderId}") | |||
| fun getJobOrderLotsHierarchicalByPickOrderIdWorkbench(@PathVariable pickOrderId: Long): JobOrderLotsHierarchicalResponse { | |||
| fun getJobOrderLotsHierarchicalByPickOrderIdWorkbench(@PathVariable pickOrderId: Long): JobOrderLotsHierarchicalWorkbenchResponse { | |||
| return joWorkbenchMainService.getJobOrderLotsHierarchicalByPickOrderIdWorkbench(pickOrderId) | |||
| } | |||
| @@ -4,10 +4,11 @@ import com.ffii.fpsms.modules.jobOrder.service.PlasticBagPrinterService | |||
| import com.ffii.fpsms.modules.jobOrder.service.PSService | |||
| import com.ffii.fpsms.modules.jobOrder.web.model.PrintRequest | |||
| import com.ffii.fpsms.modules.jobOrder.web.model.LaserRequest | |||
| import jakarta.servlet.http.HttpServletResponse | |||
| import org.springframework.http.HttpHeaders | |||
| import org.springframework.http.MediaType | |||
| import org.springframework.web.bind.annotation.* | |||
| import java.time.LocalDate | |||
| import java.time.format.DateTimeParseException | |||
| import org.springframework.http.ResponseEntity | |||
| @RestController | |||
| @@ -79,6 +80,13 @@ class PSController( | |||
| return ResponseEntity.ok(mapOf("ok" to true, "itemCode" to itemCode, "onHandQty" to (onHandQty ?: "deleted"))) | |||
| } | |||
| /** Recalculate inventory on-hand from lot lines for FG BOM items (排期設定 刷新庫存). */ | |||
| @PostMapping("/refresh-inventory-onhand") | |||
| fun refreshInventoryOnHand(): ResponseEntity<Map<String, Any>> { | |||
| val updated = psService.refreshInventoryOnHandForFgBomItems() | |||
| return ResponseEntity.ok(mapOf("ok" to true, "updated" to updated)) | |||
| } | |||
| /** Set or clear coffee/tea/lemon for an item. systemType: coffee | tea | lemon, enabled: boolean. */ | |||
| @PostMapping("/setCoffeeOrTea") | |||
| fun setCoffeeOrTea(@RequestBody body: Map<String, Any>): ResponseEntity<Map<String, Any>> { | |||
| @@ -91,4 +99,37 @@ class PSController( | |||
| psService.setCoffeeOrTea(itemCode, systemType, enabled) | |||
| return ResponseEntity.ok(mapOf("ok" to true, "itemCode" to itemCode, "systemType" to systemType, "enabled" to enabled)) | |||
| } | |||
| /** | |||
| * Export delivery-order qty sums (stock unit) for BOM items, pivoted by ETA date. | |||
| */ | |||
| @GetMapping( | |||
| value = ["/export-do-qty-by-date"], | |||
| produces = ["application/vnd.openxmlformats-officedocument.spreadsheetml.sheet"], | |||
| ) | |||
| fun exportDoQtyByDate( | |||
| @RequestParam fromDate: String, | |||
| @RequestParam toDate: String, | |||
| ): ResponseEntity<Any> { | |||
| val from = try { | |||
| LocalDate.parse(fromDate) | |||
| } catch (_: DateTimeParseException) { | |||
| return ResponseEntity.badRequest().body(mapOf("error" to "Invalid fromDate")) | |||
| } | |||
| val to = try { | |||
| LocalDate.parse(toDate) | |||
| } catch (_: DateTimeParseException) { | |||
| return ResponseEntity.badRequest().body(mapOf("error" to "Invalid toDate")) | |||
| } | |||
| return try { | |||
| val bytes = psService.exportDeliveryOrderQtyByDateExcel(from, to) | |||
| val filename = "do_qty_${from}_to_${to}.xlsx" | |||
| ResponseEntity.ok() | |||
| .header(HttpHeaders.CONTENT_DISPOSITION, "attachment; filename=$filename") | |||
| .contentType(MediaType.parseMediaType("application/vnd.openxmlformats-officedocument.spreadsheetml.sheet")) | |||
| .body(bytes) | |||
| } catch (e: IllegalArgumentException) { | |||
| ResponseEntity.badRequest().body(mapOf("error" to (e.message ?: "Invalid date range"))) | |||
| } | |||
| } | |||
| } | |||
| @@ -90,9 +90,45 @@ data class PickOrderInfoResponse( | |||
| data class JobOrderBasicInfoResponse( | |||
| val id: Long, | |||
| val code: String, | |||
| val name: String | |||
| val name: String, | |||
| ) | |||
| data class JobOrderLotsHierarchicalWorkbenchResponse( | |||
| val pickOrder: PickOrderInfoWorkbenchResponse, | |||
| val pickOrderLines: List<PickOrderLineWithLotsWorkbenchResponse> | |||
| ) | |||
| data class PickOrderInfoWorkbenchResponse( | |||
| val id: Long?, | |||
| val code: String?, | |||
| val consoCode: String?, | |||
| val targetDate: String?, | |||
| val type: String?, | |||
| val status: String?, | |||
| val assignTo: Long?, | |||
| val jobOrder: JobOrderBasicInfoWorkbenchResponse | |||
| ) | |||
| data class PickOrderLineWithLotsWorkbenchResponse( | |||
| val id: Long, | |||
| val itemId: Long?, | |||
| val itemCode: String?, | |||
| val itemName: String?, | |||
| val requiredQty: Double?, | |||
| // Total available qty across all inventory lot lines for this item (used by JO pick UI) | |||
| val totalAvailableQty: Double? = null, | |||
| val uomCode: String?, | |||
| val uomDesc: String?, | |||
| val status: String?, | |||
| val lots: List<LotDetailResponse>, | |||
| val stockouts: List<StockOutLineDetailResponse> = emptyList(), | |||
| val handler: String? | |||
| ) | |||
| data class JobOrderBasicInfoWorkbenchResponse( | |||
| val id: Long, | |||
| val code: String, | |||
| val name: String, | |||
| val itemCode: String?, | |||
| val itemName: String?, | |||
| ) | |||
| data class PickOrderLineWithLotsResponse( | |||
| val id: Long, | |||
| val itemId: Long?, | |||
| @@ -3,4 +3,8 @@ package com.ffii.fpsms.modules.jobOrder.web.model | |||
| data class ExportPickRecordRequest ( | |||
| val pickOrderIds: Long, | |||
| val floor: String? = null, | |||
| val plasticBoxCartonQty: Int? = null, | |||
| val plasticBoxCartonQty2f: Int? = null, | |||
| val plasticBoxCartonQty3f: Int? = null, | |||
| val plasticBoxCartonQty4f: Int? = null, | |||
| ) | |||
| @@ -0,0 +1,7 @@ | |||
| package com.ffii.fpsms.modules.jobOrder.web.model | |||
| data class PickRecordPlasticBoxCartonQtyResponse( | |||
| val plasticBoxCartonQty2f: Int?, | |||
| val plasticBoxCartonQty3f: Int?, | |||
| val plasticBoxCartonQty4f: Int?, | |||
| ) | |||
| @@ -0,0 +1,9 @@ | |||
| package com.ffii.fpsms.modules.jobOrder.web.model | |||
| data class PlasticBoxCartonQtyDashboardRecord( | |||
| val pickOrderId: Long?, | |||
| val statDate: String, | |||
| val plasticBoxCartonQty2f: Int? = null, | |||
| val plasticBoxCartonQty3f: Int? = null, | |||
| val plasticBoxCartonQty4f: Int? = null, | |||
| ) | |||
| @@ -5,4 +5,8 @@ data class PrintPickRecordRequest( | |||
| val printerId: Long, | |||
| val printQty: Int?, | |||
| val floor: String? = null, | |||
| val plasticBoxCartonQty: Int? = null, | |||
| val plasticBoxCartonQty2f: Int? = null, | |||
| val plasticBoxCartonQty3f: Int? = null, | |||
| val plasticBoxCartonQty4f: Int? = null, | |||
| ) | |||
| @@ -0,0 +1,33 @@ | |||
| package com.ffii.fpsms.modules.logistic.entity | |||
| import com.ffii.core.entity.BaseEntity | |||
| import jakarta.persistence.Column | |||
| import jakarta.persistence.Entity | |||
| import jakarta.persistence.Table | |||
| import jakarta.validation.constraints.NotNull | |||
| import jakarta.validation.constraints.Size | |||
| @Entity | |||
| @Table(name = "logistic") | |||
| open class Logistic : BaseEntity<Long>() { | |||
| @field:NotNull | |||
| @field:Size(max = 255) | |||
| @Column(name = "logisticName", nullable = false, length = 255) | |||
| open var logisticName: String? = null | |||
| @field:NotNull | |||
| @field:Size(max = 50) | |||
| @Column(name = "carPlate", nullable = false, length = 50) | |||
| open var carPlate: String? = null | |||
| @field:NotNull | |||
| @field:Size(max = 255) | |||
| @Column(name = "driverName", nullable = false, length = 255) | |||
| open var driverName: String? = null | |||
| @field:NotNull | |||
| @Column(name = "driverNumber", nullable = false) | |||
| open var driverNumber: Int? = null | |||
| } | |||
| @@ -0,0 +1,12 @@ | |||
| package com.ffii.fpsms.modules.logistic.entity | |||
| import com.ffii.core.support.AbstractRepository | |||
| import org.springframework.stereotype.Repository | |||
| @Repository | |||
| interface LogisticRepository : AbstractRepository<Logistic, Long> { | |||
| fun findAllByDeletedFalseOrderByIdAsc(): List<Logistic> | |||
| fun findByIdAndDeletedFalse(id: Long): Logistic? | |||
| fun findByCarPlateAndDeletedFalse(carPlate: String): Logistic? | |||
| } | |||
| @@ -0,0 +1,82 @@ | |||
| package com.ffii.fpsms.modules.logistic.service | |||
| import com.ffii.fpsms.modules.logistic.entity.Logistic | |||
| import com.ffii.fpsms.modules.logistic.entity.LogisticRepository | |||
| import com.ffii.fpsms.modules.logistic.web.models.SaveLogisticRequest | |||
| import jakarta.transaction.Transactional | |||
| import org.springframework.stereotype.Service | |||
| import org.springframework.web.server.ResponseStatusException | |||
| import org.springframework.http.HttpStatus | |||
| @Service | |||
| open class LogisticService( | |||
| private val logisticRepository: LogisticRepository, | |||
| ) { | |||
| open fun findAll(): List<Logistic> { | |||
| return logisticRepository.findAllByDeletedFalseOrderByIdAsc() | |||
| } | |||
| open fun findById(id: Long): Logistic? { | |||
| return logisticRepository.findByIdAndDeletedFalse(id) | |||
| } | |||
| open fun requireById(id: Long): Logistic { | |||
| return logisticRepository.findByIdAndDeletedFalse(id) | |||
| ?: throw ResponseStatusException(HttpStatus.NOT_FOUND, "Logistic not found with id: $id") | |||
| } | |||
| @Transactional | |||
| open fun save(request: SaveLogisticRequest): Logistic { | |||
| val entity = request.id?.let { requireById(it) } ?: Logistic() | |||
| entity.apply { | |||
| logisticName = request.logisticName.trim() | |||
| carPlate = request.carPlate.trim() | |||
| driverName = request.driverName.trim() | |||
| driverNumber = request.driverNumber | |||
| } | |||
| return logisticRepository.save(entity) | |||
| } | |||
| /** | |||
| * 批次「新增」物流主檔:同一交易內寫入,任一筆失敗則整批 rollback。 | |||
| * 供看板一次儲存多筆暫存主檔,避免逐筆 POST 中途失敗留下孤兒列。 | |||
| */ | |||
| @Transactional | |||
| open fun saveBatchCreate(requests: List<SaveLogisticRequest>): List<Logistic> { | |||
| if (requests.isEmpty()) return emptyList() | |||
| if (requests.size > 100) { | |||
| throw ResponseStatusException( | |||
| HttpStatus.BAD_REQUEST, | |||
| "Batch size exceeds limit (100)", | |||
| ) | |||
| } | |||
| requests.forEach { r -> | |||
| if (r.id != null) { | |||
| throw ResponseStatusException( | |||
| HttpStatus.BAD_REQUEST, | |||
| "save-batch only accepts new rows (id must be null)", | |||
| ) | |||
| } | |||
| } | |||
| return requests.map { req -> | |||
| val entity = Logistic().apply { | |||
| logisticName = req.logisticName.trim() | |||
| carPlate = req.carPlate.trim() | |||
| driverName = req.driverName.trim() | |||
| driverNumber = req.driverNumber | |||
| } | |||
| logisticRepository.save(entity) | |||
| } | |||
| } | |||
| @Transactional | |||
| open fun deleteById(id: Long): String { | |||
| val entity = requireById(id) | |||
| entity.deleted = true | |||
| logisticRepository.save(entity) | |||
| return "Logistic deleted successfully with id: $id" | |||
| } | |||
| } | |||
| @@ -0,0 +1,65 @@ | |||
| package com.ffii.fpsms.modules.logistic.web | |||
| import com.ffii.fpsms.modules.logistic.service.LogisticService | |||
| import com.ffii.fpsms.modules.logistic.web.models.DeleteLogisticRequest | |||
| import com.ffii.fpsms.modules.logistic.web.models.LogisticResponse | |||
| import com.ffii.fpsms.modules.logistic.web.models.SaveLogisticRequest | |||
| import com.ffii.fpsms.modules.logistic.web.models.SaveLogisticsBatchRequest | |||
| import com.ffii.fpsms.modules.master.web.models.MessageResponse | |||
| import jakarta.validation.Valid | |||
| import org.springframework.http.ResponseEntity | |||
| import org.springframework.web.bind.annotation.* | |||
| @RestController | |||
| @RequestMapping("/logistic") | |||
| class LogisticController( | |||
| private val logisticService: LogisticService, | |||
| ) { | |||
| @GetMapping("/all") | |||
| fun findAll(): List<LogisticResponse> { | |||
| return logisticService.findAll().map { it.toResponse() } | |||
| } | |||
| @GetMapping("/{id}") | |||
| fun findById(@PathVariable id: Long): LogisticResponse { | |||
| return logisticService.requireById(id).toResponse() | |||
| } | |||
| @PostMapping("/save") | |||
| fun save(@Valid @RequestBody request: SaveLogisticRequest): LogisticResponse { | |||
| return logisticService.save(request).toResponse() | |||
| } | |||
| /** 批次新增主檔;單一 transaction,與 [save] 分開避免誤用 id 更新混進批次。 */ | |||
| @PostMapping("/save-batch") | |||
| fun saveBatch(@Valid @RequestBody body: SaveLogisticsBatchRequest): List<LogisticResponse> { | |||
| return logisticService.saveBatchCreate(body.items).map { it.toResponse() } | |||
| } | |||
| @PostMapping("/delete") | |||
| fun delete(@Valid @RequestBody request: DeleteLogisticRequest): ResponseEntity<MessageResponse> { | |||
| val result = logisticService.deleteById(request.id) | |||
| return ResponseEntity.ok( | |||
| MessageResponse( | |||
| id = request.id, | |||
| name = null, | |||
| code = null, | |||
| type = "logistic", | |||
| message = result, | |||
| errorPosition = null, | |||
| entity = null, | |||
| ) | |||
| ) | |||
| } | |||
| private fun com.ffii.fpsms.modules.logistic.entity.Logistic.toResponse(): LogisticResponse { | |||
| return LogisticResponse( | |||
| id = this.id ?: 0L, | |||
| logisticName = this.logisticName ?: "", | |||
| carPlate = this.carPlate ?: "", | |||
| driverName = this.driverName ?: "", | |||
| driverNumber = this.driverNumber ?: 0, | |||
| ) | |||
| } | |||
| } | |||
| @@ -0,0 +1,9 @@ | |||
| package com.ffii.fpsms.modules.logistic.web.models | |||
| import jakarta.validation.constraints.NotNull | |||
| data class DeleteLogisticRequest( | |||
| @field:NotNull | |||
| val id: Long, | |||
| ) | |||
| @@ -0,0 +1,10 @@ | |||
| package com.ffii.fpsms.modules.logistic.web.models | |||
| data class LogisticResponse( | |||
| val id: Long, | |||
| val logisticName: String, | |||
| val carPlate: String, | |||
| val driverName: String, | |||
| val driverNumber: Int, | |||
| ) | |||
| @@ -0,0 +1,21 @@ | |||
| package com.ffii.fpsms.modules.logistic.web.models | |||
| import jakarta.validation.constraints.NotBlank | |||
| import jakarta.validation.constraints.NotNull | |||
| import jakarta.validation.constraints.Size | |||
| data class SaveLogisticRequest( | |||
| val id: Long? = null, | |||
| @field:NotBlank | |||
| @field:Size(max = 255) | |||
| val logisticName: String, | |||
| @field:NotBlank | |||
| @field:Size(max = 50) | |||
| val carPlate: String, | |||
| @field:NotBlank | |||
| @field:Size(max = 255) | |||
| val driverName: String, | |||
| @field:NotNull | |||
| val driverNumber: Int, | |||
| ) | |||
| @@ -0,0 +1,12 @@ | |||
| package com.ffii.fpsms.modules.logistic.web.models | |||
| import jakarta.validation.Valid | |||
| import jakarta.validation.constraints.NotEmpty | |||
| import jakarta.validation.constraints.Size | |||
| data class SaveLogisticsBatchRequest( | |||
| @field:NotEmpty | |||
| @field:Size(max = 100) | |||
| @field:Valid | |||
| val items: List<SaveLogisticRequest>, | |||
| ) | |||
| @@ -3,6 +3,8 @@ package com.ffii.fpsms.modules.master.entity | |||
| import com.fasterxml.jackson.annotation.JsonBackReference | |||
| import com.fasterxml.jackson.annotation.JsonManagedReference | |||
| import com.ffii.core.entity.BaseEntity | |||
| import com.ffii.fpsms.modules.master.enums.BomStatus | |||
| import com.ffii.fpsms.modules.master.enums.BomStatusConverter | |||
| import jakarta.persistence.* | |||
| import jakarta.validation.constraints.NotNull | |||
| import jakarta.validation.constraints.Size | |||
| @@ -51,8 +53,11 @@ open class Bom : BaseEntity<Long>() { | |||
| @Size(max = 100) | |||
| @NotNull | |||
| @Column(name = "description", nullable = false, length = 100) | |||
| open var description: String? = null | |||
| @Column(name = "bomKind", nullable = false, length = 100) | |||
| open var bomKind: String? = null | |||
| @Column(name = "revisionNo") | |||
| open var revisionNo: Int? = null | |||
| @Column(name = "outputQty", precision = 14, scale = 2) | |||
| open var outputQty: BigDecimal? = null | |||
| @@ -87,4 +92,12 @@ open class Bom : BaseEntity<Long>() { | |||
| @Column(name = "baseScore", precision = 14, scale = 2) | |||
| open var baseScore: BigDecimal? = null | |||
| @Column(name = "status", nullable = false, length = 20) | |||
| @Convert(converter = BomStatusConverter::class) | |||
| open var status: BomStatus = BomStatus.ACTIVE | |||
| @Size(max = 255) | |||
| @Column(name = "putawayLocationCode", length = 255) | |||
| open var putawayLocationCode: String? = null | |||
| } | |||
| @@ -37,27 +37,6 @@ open class BomMaterial : BaseEntity<Long>() { | |||
| @Column(name = "uomName", length = 100) | |||
| open var uomName: String? = null | |||
| @Column(name = "saleQty", precision = 14, scale = 2) | |||
| open var saleQty: BigDecimal? = null | |||
| @ManyToOne | |||
| @JoinColumn(name = "salesUnitId") | |||
| open var salesUnit: UomConversion? = null | |||
| @Column(name = "salesUnitCode") | |||
| open var salesUnitCode: String? = null | |||
| @Column(name = "baseQty", precision = 14, scale = 2) | |||
| open var baseQty: BigDecimal? = null | |||
| @Column(name = "baseUnit", nullable = false) | |||
| open var baseUnit: Integer? = null | |||
| @Column(name = "baseUnitName", length = 100) | |||
| open var baseUnitName: String? = null | |||
| @Column(name = "stockQty", precision = 14, scale = 2) | |||
| open var stockQty: BigDecimal? = null | |||
| @Column(name = "stockUnit", nullable = false) | |||
| open var stockUnit: Integer? = null | |||
| @Column(name = "stockUnitName", length = 100) | |||
| open var stockUnitName: String? = null | |||
| @NotNull | |||
| @ManyToOne(optional = false) | |||
| @JoinColumn(name = "bomId", nullable = false) | |||