Quellcode durchsuchen

Merge branch 'production' into bomUpdateTest

# Conflicts:
#	src/main/java/com/ffii/fpsms/modules/jobOrder/service/JoPickOrderService.kt
#	src/main/java/com/ffii/fpsms/modules/master/service/ItemsService.kt
#	src/main/java/com/ffii/fpsms/modules/master/web/ItemsController.kt
#	src/main/java/com/ffii/fpsms/modules/productProcess/service/ProductProcessService.kt
bomUpdateTest
CANCERYS\kw093 vor 1 Monat
Ursprung
Commit
6f6315c3ad
100 geänderte Dateien mit 12359 neuen und 335 gelöschten Zeilen
  1. +11
    -0
      .cursor/commands/gen-commit-test-plans.md
  2. +48
    -0
      .cursor/rules/deploy-test-plan.mdc
  3. +28
    -0
      .cursor/rules/gen-commit-test-plan.mdc
  4. +250
    -108
      .cursor/skills/fp-mtms-version-checklist/SKILL.md
  5. +32
    -0
      AGENTS.md
  6. +236
    -0
      docs/MTMS_M18_DATA_MAPPING.md
  7. +33
    -0
      docs/deploy/20260727_isextra_truck_x_ticket_fix.md
  8. +175
    -0
      docs/deploy/HOW_TO_GENERATE_COMMIT_TEST_PLANS.md
  9. +46
    -0
      docs/deploy/TEMPLATE.md
  10. +15
    -0
      docs/deploy/commit-plans/2026-07-20_94dbc8db_no_message.docx
  11. +16
    -0
      docs/deploy/commit-plans/2026-07-22_173e6ce5_workbenchgoodpickexecutiondetail_ui.docx
  12. +30
    -0
      docs/deploy/commit-plans/2026-07-22_3ebf46e3_uom.docx
  13. +11
    -0
      docs/deploy/commit-plans/2026-07-23_18a4d2db_no_message.docx
  14. +13
    -0
      docs/deploy/commit-plans/2026-07-23_a81c6f1c_added_onpack2030_for_pp2404.docx
  15. +14
    -0
      docs/deploy/commit-plans/2026-07-24_5f628db3_no_message.docx
  16. +11
    -0
      docs/deploy/commit-plans/2026-07-24_93c3e931_no_message.docx
  17. +14
    -0
      docs/deploy/commit-plans/2026-07-27_b12b9a49_isextra_truck_ticket_fix.docx
  18. +14
    -0
      docs/deploy/commit-plans/_index.md
  19. +106
    -0
      docs/exports/MTMS_BOM_UserGuide.docx
  20. +149
    -0
      docs/exports/MTMS_DO_Shipping_UserGuide.docx
  21. +105
    -0
      docs/exports/MTMS_JO_Pick_Production_PutAway_UserGuide.docx
  22. +67
    -0
      docs/exports/MTMS_M18_DATA_MAPPING.docx
  23. BIN
      docs/exports/MTMS_M18_DATA_MAPPING.xlsx
  24. +177
    -0
      docs/exports/MTMS_Schedule_JobOrder_UserGuide.docx
  25. +60
    -0
      docs/generated/m18-item-type-mapping.md
  26. +17
    -0
      docs/generated/m18-stsearch-types.md
  27. +256
    -0
      docs/user-guides/MTMS_BOM_使用說明.md
  28. +259
    -0
      docs/user-guides/MTMS_工單提料報工上架_使用說明.md
  29. +459
    -0
      docs/user-guides/MTMS_排程與工單_使用說明.md
  30. +375
    -0
      docs/user-guides/MTMS_送貨訂單與出貨_使用說明.md
  31. +345
    -0
      scripts/export_m18_mapping_office.py
  32. +193
    -0
      scripts/export_user_guide_office.py
  33. +505
    -0
      scripts/generate_commit_test_plans_docx.py
  34. +120
    -0
      scripts/generate_deploy_test_plan.py
  35. +239
    -0
      scripts/generate_m18_mapping_docs.py
  36. +16
    -1
      src/main/java/com/ffii/fpsms/config/security/SecurityConfig.java
  37. +22
    -0
      src/main/java/com/ffii/fpsms/modules/deliveryOrder/service/DoFloorSupplierSettingsService.kt
  38. +34
    -3
      src/main/java/com/ffii/fpsms/modules/deliveryOrder/service/DoReleaseCoordinatorService.kt
  39. +42
    -7
      src/main/java/com/ffii/fpsms/modules/deliveryOrder/service/DoWorkbenchDopoAssignmentService.kt
  40. +319
    -39
      src/main/java/com/ffii/fpsms/modules/deliveryOrder/service/DoWorkbenchMainService.kt
  41. +56
    -0
      src/main/java/com/ffii/fpsms/modules/deliveryOrder/service/DoWorkbenchReleaseService.kt
  42. +13
    -3
      src/main/java/com/ffii/fpsms/modules/deliveryOrder/service/WorkbenchReleaseTypeSupport.kt
  43. +6
    -0
      src/main/java/com/ffii/fpsms/modules/deliveryOrder/web/DoWorkbenchController.kt
  44. +1
    -1
      src/main/java/com/ffii/fpsms/modules/deliveryOrder/web/models/DoDetailResponse.kt
  45. +6
    -0
      src/main/java/com/ffii/fpsms/modules/deliveryOrder/web/models/WorkbenchScanPickRequest.kt
  46. +49
    -38
      src/main/java/com/ffii/fpsms/modules/jobOrder/service/JoPickOrderService.kt
  47. +40
    -23
      src/main/java/com/ffii/fpsms/modules/jobOrder/service/JoWorkbenchMainService.kt
  48. +6
    -2
      src/main/java/com/ffii/fpsms/modules/jobOrder/service/JobOrderPlanStartAutoService.kt
  49. +3
    -0
      src/main/java/com/ffii/fpsms/modules/jobOrder/web/model/CreateJobOrderRequest.kt
  50. +125
    -37
      src/main/java/com/ffii/fpsms/modules/master/service/ItemsService.kt
  51. +9
    -0
      src/main/java/com/ffii/fpsms/modules/master/web/ItemsController.kt
  52. +51
    -0
      src/main/java/com/ffii/fpsms/modules/pickOrder/entity/PickOrderRepository.kt
  53. +10
    -4
      src/main/java/com/ffii/fpsms/modules/pickOrder/service/HierarchicalFgPayloadAssembler.kt
  54. +31
    -6
      src/main/java/com/ffii/fpsms/modules/pickOrder/service/PickExecutionIssueService.kt
  55. +284
    -0
      src/main/java/com/ffii/fpsms/modules/pickOrder/service/PickOrderLifecycleService.kt
  56. +13
    -4
      src/main/java/com/ffii/fpsms/modules/pickOrder/service/PickOrderService.kt
  57. +8
    -5
      src/main/java/com/ffii/fpsms/modules/pickOrder/service/PickOrderWorkbenchService.kt
  58. +28
    -0
      src/main/java/com/ffii/fpsms/modules/pickOrder/web/PickOrderLifecycleController.kt
  59. +50
    -0
      src/main/java/com/ffii/fpsms/modules/pickOrder/web/models/PickOrderLifecycleResponse.kt
  60. +68
    -0
      src/main/java/com/ffii/fpsms/modules/productProcess/entity/ProductProcessRepository.kt
  61. +352
    -21
      src/main/java/com/ffii/fpsms/modules/productProcess/service/ProductProcessService.kt
  62. +21
    -1
      src/main/java/com/ffii/fpsms/modules/productProcess/web/ProductProcessController.kt
  63. +42
    -2
      src/main/java/com/ffii/fpsms/modules/productProcess/web/model/SaveProductProcessRequest.kt
  64. +204
    -0
      src/main/java/com/ffii/fpsms/modules/report/service/DoUserPickAuditReportService.kt
  65. +281
    -0
      src/main/java/com/ffii/fpsms/modules/report/service/ShopOrderReplenishmentReportService.kt
  66. +140
    -0
      src/main/java/com/ffii/fpsms/modules/report/web/DoUserPickAuditReportController.kt
  67. +28
    -0
      src/main/java/com/ffii/fpsms/modules/report/web/ReportController.kt
  68. +4
    -0
      src/main/java/com/ffii/fpsms/modules/stock/entity/InventoryLotLineRepository.kt
  69. +4
    -1
      src/main/java/com/ffii/fpsms/modules/stock/entity/InventoryLotRepository.kt
  70. +52
    -0
      src/main/java/com/ffii/fpsms/modules/stock/entity/InventoryRepository.kt
  71. +36
    -0
      src/main/java/com/ffii/fpsms/modules/stock/entity/ItemLotTraceLog.kt
  72. +7
    -0
      src/main/java/com/ffii/fpsms/modules/stock/entity/ItemLotTraceLogRepository.kt
  73. +35
    -1
      src/main/java/com/ffii/fpsms/modules/stock/entity/StockLedger.kt
  74. +7
    -0
      src/main/java/com/ffii/fpsms/modules/stock/entity/StockOutLine.kt
  75. +12
    -0
      src/main/java/com/ffii/fpsms/modules/stock/entity/SuggestedPickLot.kt
  76. +3
    -0
      src/main/java/com/ffii/fpsms/modules/stock/entity/projection/InventoryLotLineInfo.kt
  77. +16
    -1
      src/main/java/com/ffii/fpsms/modules/stock/service/InventoryLotLineService.kt
  78. +38
    -0
      src/main/java/com/ffii/fpsms/modules/stock/service/InventoryService.kt
  79. +60
    -0
      src/main/java/com/ffii/fpsms/modules/stock/service/ItemLotTraceLogService.kt
  80. +48
    -0
      src/main/java/com/ffii/fpsms/modules/stock/service/ItemLotTraceService.kt
  81. +24
    -6
      src/main/java/com/ffii/fpsms/modules/stock/service/StockInLineService.kt
  82. +63
    -0
      src/main/java/com/ffii/fpsms/modules/stock/service/StockLedgerLotSnapshot.kt
  83. +58
    -9
      src/main/java/com/ffii/fpsms/modules/stock/service/StockOutLineService.kt
  84. +26
    -2
      src/main/java/com/ffii/fpsms/modules/stock/service/StockOutLineWorkbenchService.kt
  85. +27
    -2
      src/main/java/com/ffii/fpsms/modules/stock/service/StockTakeRecordService.kt
  86. +23
    -6
      src/main/java/com/ffii/fpsms/modules/stock/service/SuggestedPickLotService.kt
  87. +44
    -2
      src/main/java/com/ffii/fpsms/modules/stock/service/SuggestedPickLotWorkbenchService.kt
  88. +319
    -0
      src/main/java/com/ffii/fpsms/modules/stock/trace/ItemLotTraceOrchestrator.kt
  89. +308
    -0
      src/main/java/com/ffii/fpsms/modules/stock/trace/TraceBomLoader.kt
  90. +112
    -0
      src/main/java/com/ffii/fpsms/modules/stock/trace/TraceExtendedLoader.kt
  91. +855
    -0
      src/main/java/com/ffii/fpsms/modules/stock/trace/TraceGraphNodeBuilder.kt
  92. +1110
    -0
      src/main/java/com/ffii/fpsms/modules/stock/trace/TraceGraphSemanticsBuilder.kt
  93. +396
    -0
      src/main/java/com/ffii/fpsms/modules/stock/trace/TraceInboundLoader.kt
  94. +357
    -0
      src/main/java/com/ffii/fpsms/modules/stock/trace/TraceJoPreludeLoader.kt
  95. +319
    -0
      src/main/java/com/ffii/fpsms/modules/stock/trace/TraceLotResolver.kt
  96. +351
    -0
      src/main/java/com/ffii/fpsms/modules/stock/trace/TraceMovementLoader.kt
  97. +431
    -0
      src/main/java/com/ffii/fpsms/modules/stock/trace/TraceOutboundLoader.kt
  98. +120
    -0
      src/main/java/com/ffii/fpsms/modules/stock/trace/TraceProductionLoader.kt
  99. +61
    -0
      src/main/java/com/ffii/fpsms/modules/stock/trace/TraceSqlSupport.kt
  100. +246
    -0
      src/main/java/com/ffii/fpsms/modules/stock/trace/TraceUpstreamLoader.kt

+ 11
- 0
.cursor/commands/gen-commit-test-plans.md Datei anzeigen

@@ -0,0 +1,11 @@
# Gen commit test plans

Generate Word test plans for recent commits (default last 10).

1. Run from FPSMS-backend repo root:
`python scripts/generate_commit_test_plans_docx.py HEAD~10..HEAD`
2. If the user named a range or SHA, use that instead (`HEAD~1..HEAD` for latest only).
3. Reply with the path `docs/deploy/commit-plans/` and summarize `_index.md`.
4. For the newest commit, briefly confirm areas covered; offer to refine from diff if needed.

See `docs/deploy/HOW_TO_GENERATE_COMMIT_TEST_PLANS.md`.

+ 48
- 0
.cursor/rules/deploy-test-plan.mdc Datei anzeigen

@@ -0,0 +1,48 @@
---
description: Require deploy/release notes with test plan and expected results for commits and PRs
alwaysApply: true
---

# Deploy notes: what changed + how to test

When the user asks to **commit**, **open a PR**, **prepare a deploy**, or **summarize changes for the team**, also produce a short **Deploy / QA note** (Markdown) with test steps and expected results.

## Required sections

```markdown
## Summary
- (1–3 bullets: what changed and why; user-facing impact)

## Scope
- Backend: …
- Frontend: … (if any)
- DB / Liquibase: … (none if N/A)
- Config / ops: … (none if N/A)

## Commits
- `abc1234` — short title

## Test plan
| # | Steps (who / where / data) | Expected result |
|---|----------------------------|-----------------|
| 1 | … | … |
| 2 | … | … |

## Out of scope / not tested
- …

## Rollback
- How to revert or disable if broken (branch, flag, or previous build)
```

## Rules

- Prefer **concrete UI labels** in 「」 and real routes (e.g. `/doworkbench`) when UI is involved.
- Prefer **concrete sample data** when known (item codes, dates, shop codes) from local DB or the change itself.
- Cover **happy path + one failure / edge** when the change adds validation or a fix.
- If only backend API changed, still give **how to verify** (API call, UI screen that uses it, or SQL check).
- Do **not** invent test steps for files you did not inspect; if unclear, say what to confirm with the author.
- Save durable notes under `docs/deploy/` when the user asks to keep them (filename: `YYYYMMDD_short-topic.md`).
- To auto-generate **one Word file per commit** into a folder, run:
`python scripts/generate_commit_test_plans_docx.py <range>` → `docs/deploy/commit-plans/`.
- Commit messages should still be clear; “no message” is not acceptable when we author the commit.

+ 28
- 0
.cursor/rules/gen-commit-test-plan.mdc Datei anzeigen

@@ -0,0 +1,28 @@
---
description: When user asks to gen test plan(s) for new/recent commit(s), auto-run the Word generator
alwaysApply: true
---

# Gen commit test plans (simple ask)

If the user says anything like:

- 「gen test plan」 / 「generate test plan」
- 「new commit test plan」 / 「test plan for commit」
- 「產測試計畫」 / 「產生 commit 測試計畫」
- 「有新 commit,幫我出 test plan」

Then **do this immediately** (do not only explain):

1. Default range: **new / recent commits** = `HEAD~10..HEAD`
- If user gives a SHA or `A..B`, use that.
- If they say “latest / this commit”, use `HEAD~1..HEAD`.
- If they say “since last deploy” and give a SHA, use `<sha>..HEAD`.
2. Run from repo root:
```bash
python scripts/generate_commit_test_plans_docx.py <range>
```
3. Tell them the output folder: `docs/deploy/commit-plans/` and point to `_index.md`.
4. Optionally refine the **latest** commit’s plan from the real diff if steps look too generic.

Team how-to: `docs/deploy/HOW_TO_GENERATE_COMMIT_TEST_PLANS.md`

+ 250
- 108
.cursor/skills/fp-mtms-version-checklist/SKILL.md Datei anzeigen

@@ -2,9 +2,10 @@
name: fp-mtms-version-checklist
description: >-
Updates the FP-MTMS Version Programs and Functions Checklist Excel on each
developer's synced SharePoint folder. Use only when the user explicitly
invokes this skill or asks to update the FP-MTMS version control checklist,
page/program version sheet, or function version sheet.
developer's synced SharePoint folder. Overflow beyond Latest+3 previous
versions is appended to the Archive long-table Excel. Use only when the user
explicitly invokes this skill or asks to update the FP-MTMS version control
checklist, page/program version sheet, function version sheet, or archive.
disable-model-invocation: true
---

@@ -26,8 +27,8 @@ All written cell values must be **Traditional Chinese + English**

**Exception — do NOT bilingualize:**

- `Functions in Page&Program` column **F — Name of Function within
Program** (English symbol + source path only; see Column F rule)
- `Functions in Page&Program` column **G — Name of Function within
Program** (English symbol + source path only; see Column G rule)
- Version & Date fields (e.g. `v1.0.0 2026-07-14`)
- Developed By (person name as-is)
- Ref. No. (number)
@@ -52,12 +53,31 @@ Job Order Pick Execution page (/jodetail).
Used / called from: Item Tracing (doc deep-link to open completed pick record).
```

**Major Changes** (Page col F / history I,L,O and Functions col I / history
L,O,R) must also list **file + line range + short per-line explanation** —
**Major Changes** (Page col F / history I,L,O and Functions col J / history
M,P,S) must also list **file + line range + short per-line explanation** —
see **Major Changes: file, lines, explanation** below.

Prefer EN/ZH pairs from `src/i18n/en/*` and `src/i18n/zh/*` for menu and
feature terms. Enable Excel wrap text when a cell has multiple lines.
feature terms.

## Excel formatting (required on every write)

When writing with `openpyxl`, apply formatting so cells stay readable:

1. **Wrap text** — enable wrap on every multi-line cell you write (Purpose,
Major Changes, Function name with multiple paths, Highlights). Prefer
`Alignment(wrap_text=True, vertical="top")`.
2. **Line breaks** — use real Excel newlines (`\n`) between ZH/EN pairs and
between file / line bullets; do not stuff everything onto one long line.
3. **Row height** — after writing multi-line cells, set a reasonable row
height (or leave Excel auto-fit) so wrapped text is visible; do not leave
multi-line content in a single-line-tall row if the sheet already uses
taller history rows.
4. **Do not break headers** — never rename, reorder, or delete existing header
labels; only fill data cells (and Archive append columns as defined).
5. **Match existing style** — when updating a row, keep the same wrap/alignment
pattern as neighbouring filled cells on that sheet.
6. **Archive** — same wrap rules as main; no extra “Source” or helper columns.

## Excel path (per developer)

@@ -72,10 +92,18 @@ Path varies by Windows user / OneDrive sync root. Resolve in this order:
`FP-MTMS Version Programs and Functions Checklist v0.1.xlsx` (real `.xlsx` only).
4. **Always confirm the resolved path with the user before writing.**

Do not edit `.url` shortcuts or Downloads copies unless the user explicitly asks.
### Archive Excel path

Use `openpyxl` to read/write. If the file is locked (PermissionError), ask the
user to close Excel / Excel Online sync lock, then retry.
Same folder as the main checklist. Default:

`{USERPROFILE}/2Fi Business Solutions Limited/2Fi Business Solutions Limited - FP-MTMS VERSION CONTROL/FP-MTMS Version Programs and Functions Checklist v0.1 - Archive.xlsx`

If the user provides an archive path, use it. Confirm both main and archive
paths before writing. Do not edit `.url` shortcuts or Downloads copies unless
the user explicitly asks.

Use `openpyxl` to read/write. If either file is locked (PermissionError), ask
the user to close Excel / Excel Online sync lock, then retry.

## Hard rule: preview first — do not interview column by column

@@ -86,7 +114,7 @@ Instead:
1. Infer all planned adds/updates from conversation context, git diff, PR,
feature description, and existing Excel rows.
2. Before any write, show a **full preview** of every row that will be added or
changed (including history shifts).
changed (including history shifts and any Archive long-table appends).
3. Ask **one** confirmation: whether this preview is correct (yes / no / what
to fix). Prefer the Ask Questions tool when available; otherwise ask once
in chat.
@@ -109,6 +137,7 @@ Before drafting or writing any row:
2. Match on existing rows when the program/page already exists.
3. If updating a function: the parent page/program should already exist on
`Page&Program Name` (or be included in the same preview as a new page row).
Set **Page Ref. No. (B)** to that page row’s Ref. No.
4. If nothing matches and the user intends a **new** program or function, say so
clearly in the preview as `NEW ROW`.
5. Do **not** invent duplicate rows for the same page + same function.
@@ -126,10 +155,12 @@ rightward** before writing the new Latest:
| Latest | → 1st previous |
| 1st previous | → 2nd previous |
| 2nd previous | → 3rd previous |
| 3rd previous | → **deleted** (dropped) |
| 3rd previous | → **Archive Excel** (append long-table row; then clear 3rd on main) |

Then write the new change into **Latest** (version/date, highlights, Developed By).

If the old 3rd previous slot is **empty**, skip archive append for that shift.

### Page&Program Name — fields that shift together

Treat each “slot” as a triple:
@@ -139,35 +170,113 @@ Treat each “slot” as a triple:
- **2nd**: K, L, M
- **3rd**: N, O, P

Shift: `(D,F,G) → (H,I,J) → (K,L,M) → (N,O,P)` then drop old 3rd; write new into D/F/G.
Shift: `(D,F,G) → (H,I,J) → (K,L,M) → (N,O,P)` then **archive** old 3rd
(N/O/P) to the Archive Excel long table if filled; clear N/O/P on main; write
new into D/F/G.
Leave identity columns A–C and purpose E unchanged unless the user asked to
change them (purpose may be refreshed if the preview says so).

### Functions in Page&Program — fields that shift together

- **Latest**: H (Function Version & Date), I (Major Changes), J (Developed By)
- **1st**: K, L, M
- **2nd**: N, O, P
- **3rd**: Q, R, S
- **Latest**: I (Function Version & Date), J (Major Changes), K (Developed By)
- **1st**: L, M, N
- **2nd**: O, P, Q
- **3rd**: R, S, T

Shift: `(I,J,K) → (L,M,N) → (O,P,Q) → (R,S,T)` then **archive** old 3rd
(R/S/T) to the Archive Excel long table if filled; clear R/S/T on main; write
new into I/J/K.
Leave A–H (Ref, **Page Ref. No.**, subsystem, page, page version/purpose,
function name/highlight) unchanged unless the preview explicitly updates them.

Always show the shift **and any archive appends** in the preview (old Latest →
new 1st, old 3rd → Archive, etc.).

#### Column B — Page Ref. No. (required)

**Page Ref. No. (B)** on every Functions row must equal the parent row’s
**Ref. No. (A)** on sheet `Page&Program Name`.

Rules:

1. Resolve the parent by matching **System Page / Program Name** (and
subsystem when needed) on the Page sheet; use that page row’s A.
2. Never leave B blank on a Functions data row.
3. Do **not** invent a Page Ref that has no matching Page sheet row (unless
that page row is included in the same approved preview as NEW).
4. Multiple function rows under the same page share the **same** Page Ref. No.
5. Function **Ref. No. (A)** stays independent (one per 功能 row).
6. Preview must show `Page Ref. No. = <n> → Page sheet Ref <n> (<page name>)`.

Page sheet columns A–P are unchanged by this field (Page sheet has no
Page Ref column).

## Archive Excel — long table (required on 3rd overflow)

When a filled **3rd previous** is pushed off the main checklist, **append one
row** to the Archive workbook (never overwrite existing archive rows).

Data starts at **row 5** (rows 1–3 title/notes, row 4 headers).

### Sheet: `Page&Program Name` (archive)

| Col | Header |
|-----|--------|
| A | Ref. No. |
| B | Name of Subsystem / Module / Menu Selection |
| C | System Page / Program Name |
| D | Version & Date |
| E | Major Changes Highlights |
| F | Developed By |
| G | Archived At |

Map from main page row 3rd slot: A←A, B←B, C←C, D←N, E←O, F←P.
`Archived At` = today's date (`YYYY-MM-DD`).
Do **not** add a Source column.

Shift: `(H,I,J) → (K,L,M) → (N,O,P) → (Q,R,S)` then drop old 3rd; write new into H/I/J.
Leave A–G (Ref, subsystem, page, page version/purpose, function name/highlight)
unchanged unless the preview explicitly updates them.
### Sheet: `Functions in Page&Program` (archive)

Always show the shift in the preview (old Latest → new 1st, etc.).
| Col | Header |
|-----|--------|
| A | Ref. No. |
| B | Page Ref. No. |
| C | Name of Subsystem / Module / Menu Selection |
| D | System Page / Program Name |
| E | Name of Function within Program |
| F | Version & Date |
| G | Major Changes Highlights |
| H | Developed By |
| I | Archived At |

Map from main functions row 3rd slot:
A←A, B←B (Page Ref. No.), C←C, D←D, E←G, F←R, G←S, H←T;
`Archived At` (I) = today.
Always copy **Page Ref. No.** so archive rows stay linked to the parent page.
Do **not** add a Source column.

### Archive write rules

1. **Append only** — find the next empty data row (after last non-empty row ≥ 5).
2. Keep **中英對照** and Major Changes content exactly as they were on main 3rd.
3. Do **not** invent archive rows for empty 3rd slots.
4. Do **not** edit older archive rows.
5. Write archive **before or in the same approved batch as** the main shift; if
archive save fails (lock), do not leave main half-shifted — abort and ask the
user to close the file, then retry the whole approved batch.
6. Never commit either Excel file to git.

### Function history only when that function changed (required)

**Do not** push or invent a Functions-sheet history slot for a commit / release
that did **not** change the symbols listed in column F for that row.
that did **not** change the symbols listed in column G for that row.

- **Page&Program Name** may still advance Latest / history for a page-level
release (e.g. backend-only fix under the same page).
- **Functions in Page&Program**: shift H→K→N→Q **only** when this row’s
function(s) actually changed (diff touches the verified F symbol(s) / paths).
- **Functions in Page&Program**: shift I→L→O→R **only** when this row’s
function(s) actually changed (diff touches the verified G symbol(s) / paths).
- If a commit updates the **page** but not this function:
- You may refresh column **D** (Latest Page Version) to match the page row.
- Leave **H/I/J** and previous function history **unchanged**.
- You may refresh column **E** (Latest Page Version) to match the page row.
- Leave **I/J/K** and previous function history **unchanged**.
- Do **not** write filler Latest/history text such as「頁面版本對齊;本功能無程式變更」
/ “Page version align; no UI change this release”.
- When mapping several commits into history for a **new** function row: include
@@ -176,13 +285,13 @@ that did **not** change the symbols listed in column F for that row.
versions and history depths.

Example: page Latest = v1.0.3 (backend TRF fix). Frontend transfer UI row last
changed at v1.0.2 → keep function H = v1.0.2; optional D = v1.0.3; no fake
changed at v1.0.2 → keep function I = v1.0.2; optional E = v1.0.3; no fake
v1.0.3 function history slot.

### Major Changes: file, lines, explanation (required)

For every **Major Changes Highlights** cell written into Excel (Page sheet
Latest F and history I/L/O; Functions sheet Latest I and history L/O/R),
Latest F and history I/L/O; Functions sheet Latest J and history M/P/S),
include concrete code locations from the commit / diff — not only a summary
sentence.

@@ -219,10 +328,10 @@ StockInLineService.kt
- Prefer the member / symbol name in the explanation when helpful
(e.g. `createStockOutBatch`, `assignLotNo`).
- If many files: keep bullets short; still list each touched path that belongs
to this row (Functions row → only files/symbols in column F, plus closely
to this row (Functions row → only files/symbols in column G, plus closely
related hunks in those files for this change).
- Page-sheet Major Changes may summarize all files for that page release;
Functions-sheet Major Changes stay scoped to that function row’s F symbols
Functions-sheet Major Changes stay scoped to that function row’s G symbols
and their files.
- Show the same file/line bullets in the **preview** before write.
- Pure i18n-only or one-liner UI copy changes: still cite file + line(s)
@@ -266,31 +375,41 @@ Data starts at **row 5** (rows 1–4 are title/headers).
| Col | Header |
|-----|--------|
| A | Ref. No. |
| B | Name of Subsystem / Module / Menu Selection |
| C | System Page / Program Name |
| D | Latest Page Version & Date |
| E | Page Purpose Highlights |
| F | Name of Function within Program |
| G | Functions Highlight |
| H | Latest Function Version & Date |
| I | Major Changes Highlights |
| J | Developed By |
| K | 1st Previous Page Version & Date |
| L | 1st Major Changes Highlights |
| M | Developed By |
| N | 2nd Previous Page Version & Date |
| O | 2nd Major Changes Highlights |
| P | Developed By |
| Q | 3rd Previous Page Version & Date |
| R | 3rd Major Changes Highlights |
| S | Developed By |

#### Column F — English symbol + source file (required)

**Name of Function within Program (F)** must use the real English
| B | Page Ref. No. |
| C | Name of Subsystem / Module / Menu Selection |
| D | System Page / Program Name |
| E | Latest Page Version & Date |
| F | Page Purpose Highlights |
| G | Name of Function within Program |
| H | Functions Highlight |
| I | Latest Function Version & Date |
| J | Major Changes Highlights |
| K | Developed By |
| L | 1st Previous Page Version & Date |
| M | 1st Major Changes Highlights |
| N | Developed By |
| O | 2nd Previous Page Version & Date |
| P | 2nd Major Changes Highlights |
| Q | Developed By |
| R | 3rd Previous Page Version & Date |
| S | 3rd Major Changes Highlights |
| T | Developed By |

(Main workbook may also show helper note columns such as U `Archive`; do
**not** require filling them unless the user asks. Page Ref. No. is the
required parent link.)

#### Column B — Page Ref. No. (see also history section)

Must point at the parent `Page&Program Name` row’s **Ref. No. (A)**. Required
on every Functions data row (main and archive).

#### Column G — English symbol + source file (required)

**Name of Function within Program (G)** must use the real English
identifier from code (component / class / function / endpoint name),
**and** state which file(s) it comes from. Do **not** put only a
localized UI label in F.
localized UI label in G.

Format:

@@ -313,7 +432,7 @@ ItemTracingLocations — src/components/ItemTracing/ItemTracingLocations.tsx
##### Service / controller / class files — also list member functions

When the primary symbol is a **service, controller, or other class**
(not a React page component), F must also name the **member function(s)**
(not a React page component), G must also name the **member function(s)**
that were added or changed — not only the class name.

Put **each class + its methods + its path on its own line** (Excel `\n`,
@@ -348,13 +467,13 @@ Fact-check each listed member function exists in that file (same casing).
If only one method changed, list that one method. Do not cite a class
without its relevant `fun` / method names when the change is in a
`*Service` / `*Controller` (or similar) file.
**Functions Highlight (G)** and **Major Changes (I)** must be 中英對照
**Functions Highlight (H)** and **Major Changes (J)** must be 中英對照
(Chinese then English on the next line). Prefer i18n terms. Page /
subsystem names (B/C) use `中文 / English`.
subsystem names (C/D) use `中文 / English`.

#### Column E — Page Purpose + where the function is used (required)
#### Column F — Page Purpose + where the function is used (required)

On **Functions in Page&Program**, **Page Purpose Highlights (E)** must
On **Functions in Page&Program**, **Page Purpose Highlights (F)** must
include:

1. The owning page purpose (what page C is for), **and**
@@ -409,11 +528,11 @@ When inferring usage, fact-check callers (imports, links, API consumers)
from the change/diff. Do not invent a caller. If usage is only the owning
page, say `本頁(<page>)`. If multiple callers, list them (i18n names).

Do **not** leave E as only the generic page blurb when the function was
Do **not** leave F as only the generic page blurb when the function was
added/changed for another page’s flow (e.g. refs that support 批號追溯
but sit under 工單提料/提料單/成品出倉).

#### Fact-check column F before preview (required)
#### Fact-check column G before preview (required)

Never invent a symbol or path. For **every** Functions-sheet row in the
preview (new or updated), verify against the real repos:
@@ -441,10 +560,10 @@ preview (new or updated), verify against the real repos:
4. **Wrong location** — if the symbol lives elsewhere, correct the path to
the real file; do not keep a convenient-but-false path.
5. **Not found** — if the file or symbol cannot be verified, do **not** put
it in F. Omit the row or ask the user; never write an unverified F.
it in G. Omit the row or ask the user; never write an unverified G.

In the preview, mark verified rows (optional short note), e.g.
`F verified: symbol+path OK`. If any F failed fact-check, fix before asking
`G verified: symbol+path OK`. If any G failed fact-check, fix before asking
for confirmation.

## Preview format (required before save)
@@ -452,46 +571,59 @@ for confirmation.
Show a clear preview, for example:

```text
Excel: <resolved path>
Excel: <resolved main path>
Archive: <resolved archive path>
Sheet: Functions in Page&Program

Row 12 (UPDATE existing | Ref 7 | Page: 批號追溯 | Function: ItemTracingFlowGraphSearch — src/components/ItemTracing/ItemTracingFlowGraphSearch.tsx)
Row 12 (UPDATE existing | Ref 7 | Page Ref 3 → 批號追溯 / Item Tracing | Function: ItemTracingFlowGraphSearch — src/components/ItemTracing/ItemTracingFlowGraphSearch.tsx)
History shift:
old Latest (H/I/J) → 1st
old Latest (I/J/K) → 1st
old 1st → 2nd
old 2nd → 3rd
old 3rd → DROP
old 3rd → ARCHIVE (append)
Archive append (Functions in Page&Program):
A: 7
B: 3
C: 倉庫管理 / Store Management
D: 批號追溯 / Item Tracing
E: ItemTracingFlowGraphSearch — src/components/ItemTracing/ItemTracingFlowGraphSearch.tsx
F: <old 3rd version & date>
G: <old 3rd major changes>
H: <old 3rd developed by>
I: 2026-07-16
New Latest:
H: v1.2.0 2026-07-14
I: 新增流程圖節點搜尋。
I: v1.2.0 2026-07-14
J: 新增流程圖節點搜尋。
Add flow-graph node search.
ItemTracingFlowGraphSearch.tsx
L42–88: 搜尋框與節點高亮。
L42–88: search box and node highlight.
J: <developer>
K: <developer>

Row NEW (append | next Ref 15 | Page: 批號追溯 / Item Tracing | Function: ItemTracingScanBar — src/components/ItemTracing/ItemTracingScanBar.tsx)
Row NEW (append | next Ref 15 | Page Ref 3 → 批號追溯 / Item Tracing | Function: ItemTracingScanBar — src/components/ItemTracing/ItemTracingScanBar.tsx)
A: 15
B: 倉庫管理 / Store Management
C: 批號追溯 / Item Tracing
E: …頁面目的(中文)…
B: 3
C: 倉庫管理 / Store Management
D: 批號追溯 / Item Tracing
F: …頁面目的(中文)…
…page purpose (English)…
使用/呼叫來源:本頁(批號追溯 / Item Tracing)。
Used / called from: this page (批號追溯 / Item Tracing).
F: ItemTracingScanBar — src/components/ItemTracing/ItemTracingScanBar.tsx
G: 相機掃碼/手動查詢
G: ItemTracingScanBar — src/components/ItemTracing/ItemTracingScanBar.tsx
H: 相機掃碼/手動查詢
Camera scan / manual search
...
H/I/J: <new latest>
I/J/K: <new latest>
history slots: empty

Row NEW (… | Page: 工單提料 / Job Order Pick Execution | Function: CompleteJobOrderRecord — …)
C: 工單提料 / Job Order Pick Execution
E: 工單提料執行頁面(/jodetail)。
Row NEW (… | Page Ref <n> → 工單提料 / Job Order Pick Execution | Function: CompleteJobOrderRecord — …)
B: <page Ref. No.>
D: 工單提料 / Job Order Pick Execution
F: 工單提料執行頁面(/jodetail)。
Job Order Pick Execution page (/jodetail).
使用/呼叫來源:批號追溯(單據深連結)。
Used / called from: Item Tracing (doc deep-link).
F: CompleteJobOrderRecord — src/components/Jodetail/completeJobOrderRecord.tsx
G: CompleteJobOrderRecord — src/components/Jodetail/completeJobOrderRecord.tsx
JodetailSearch — src/components/Jodetail/JodetailSearch.tsx

@@ -502,23 +634,27 @@ For page-sheet rows, use the same style with D/F/G and H–P history.

## Workflow

1. Confirm Excel path exists (and is writable when saving).
1. Confirm **main and archive** Excel paths exist (and are writable when saving).
2. Infer target sheets and rows from the user’s change description / diff.
3. **Pre-check** existing program/page/function rows in Excel.
4. For each Functions row: **fact-check** English symbol + every source path
against the codebase (see Fact-check column F). Correct or drop failures.
5. For each Functions row: set **E** with page purpose **and** usage/呼叫來源
(owning page vs caller page; see Column E rule).
against the codebase (see Fact-check column G). Correct or drop failures.
Also set / verify **Page Ref. No. (B)** against the parent Page sheet Ref.
5. For each Functions row: set **F** with page purpose **and** usage/呼叫來源
(owning page vs caller page; see Column F rule).
6. For each existing match: plan history shift + new Latest values
(**only** shift function history when that function’s F symbols changed).
(**only** shift function history when that function’s G symbols changed).
If old 3rd is filled, plan an **Archive long-table append**.
7. For each Major Changes cell (page + functions, Latest and any filled history
slots): resolve **file path + line range(s) + short per-hunk explanation**
from git diff / current sources (see Major Changes: file, lines, explanation).
8. For each new function/page: plan a new Ref. No. row (next integer after max used).
9. Present the **full preview** (verified F + file/line Major Changes) → ask once
if correct.
10. On approval: apply shifts + writes with `openpyxl`, save, report sheet names,
row numbers, Ref. Nos., and key Latest fields.
9. Present the **full preview** (verified G + Page Ref + file/line Major Changes +
archive appends) → ask once if correct.
10. On approval: with `openpyxl`, **append archive rows first** (if any), then
apply main shifts + writes; apply **Excel formatting** (wrap text /
newlines); save both workbooks; report sheet names, row numbers,
Ref. Nos., Page Ref. Nos., archive rows appended, and key Latest fields.
11. **Code comments** — for every Functions-sheet symbol that was added or
updated, add or refresh the FP-MTMS checklist comment on that
component / class member (see Code comments rule). Include this in the
@@ -529,19 +665,19 @@ For page-sheet rows, use the same style with D/F/G and H–P history.

After the user approves the checklist preview (or as part of the same
approved batch), **add or update a source comment** on every function /
component listed in column F for each affected Functions-sheet row.
component listed in column G for each affected Functions-sheet row.

### Comment contents (required fields)

Must include:

1. **Ref. No.** — Functions sheet Ref. No. for that row
2. **Version** — Latest Function Version (column H), e.g. `v1.0.0`
3. **Update date** — the date from column H, e.g. `2026-07-14`
2. **Version** — Latest Function Version (column I), e.g. `v1.0.0`
3. **Update date** — the date from column I, e.g. `2026-07-14`

### Canonical format

Parse column H `vX.Y.Z YYYY-MM-DD` into version + date.
Parse column I `vX.Y.Z YYYY-MM-DD` into version + date.

**TypeScript / TSX** (JSDoc immediately above the component / export /
function):
@@ -572,10 +708,10 @@ checklist line as the first line of the block:
### Placement rules

- **React component**: above the primary `const ComponentName` / `export
default` that matches column F.
default` that matches column G.
- **Service / controller**: above **each** listed member function
(e.g. both `trace` and `traceLocation`).
- Multi-line F (one symbol per line): comment **each** symbol in its file.
- Multi-line G (one symbol per line): comment **each** symbol in its file.
- On later checklist updates to the same row: **replace** the old Ref/version/date
in the comment with the new Latest values (do not stack duplicate checklist lines).

@@ -599,14 +735,15 @@ the user explicitly asks to sync comments only.
- **Developed By**: `git config user.name` or chat context.
- **Purpose / Changes / Function highlight**: 中英對照; short bullets from
recent PR/diff; prefer i18n terms.
- **Major Changes (Page F / Functions I and history slots)**: summary ZH/EN
- **Major Changes (Page F / Functions J and history slots)**: summary ZH/EN
**plus** each changed file, line range(s), and short per-hunk explanation
(see Major Changes: file, lines, explanation). Resolve lines from git diff
+ current file; do not omit locations.
- **Subsystem / Page names**: `中文 / English` from i18n EN+ZH.
- **Function name (col F)**: English code symbol + source file path only
- **Function name (col G)**: English code symbol + source file path only
(no 中英對照). **Fact-check** symbol+path before preview.
- **Page Purpose (E)**: 中英對照 + usage/呼叫來源 lines (ZH then EN).
- **Page Purpose (F)**: 中英對照 + usage/呼叫來源 lines (ZH then EN).
- **Page Ref. No. (B)**: parent Page sheet Ref. No. for column D’s page.
- **Ref. No.**: for new rows, next integer after the last used Ref. No. on that sheet.
- Prefer updating an existing row over creating a duplicate when page +
English function symbol (and file) match.
@@ -616,27 +753,32 @@ the user explicitly asks to sync comments only.
- Ask the user to fill cells one column at a time
- Save without an approved preview
- Skip history shift when updating an existing Latest
- Push or invent **function** history (H–S) for a commit that did not change
that row’s column F symbols — no “page align / no code change” filler slots
- Drop a filled **3rd previous** without appending it to the Archive long table
- Add or fill an Archive **Source** column (removed; do not reintroduce)
- Overwrite or edit existing Archive rows (append only)
- Skip Excel formatting (wrap text / newlines) on multi-line cells you write
- Push or invent **function** history (I–T) for a commit that did not change
that row’s column G symbols — no “page align / no code change” filler slots
- Write Major Changes as summary-only without **file path + line range(s) +
short per-hunk explanation** (Page F / Functions I and matching history cols)
short per-hunk explanation** (Page F / Functions J and matching history cols)
- Write Chinese-only or English-only prose in bilingual-required columns
(everything except Function name F, versions, Developed By, Ref. No., and
(everything except Function name G, versions, Developed By, Ref. No., Page Ref. No., and
the path/line tokens inside Major Changes)
- Put only a Chinese UI label in **Name of Function within Program** without
the English symbol and source file
- Bilingualize column F (keep symbol + path English-only)
- Omit usage/呼叫來源 from **Page Purpose Highlights (E)** when the function
- Bilingualize column G (keep symbol + path English-only)
- Omit usage/呼叫來源 from **Page Purpose Highlights (F)** when the function
is invoked from another page (e.g. 批號追溯 deep-link into 工單提料)
- Cite a `*Service` / `*Controller` class in F without its relevant member
- Cite a `*Service` / `*Controller` class in G without its relevant member
function names (e.g. write `ItemLotTraceService.trace / .traceLocation`,
not only `ItemLotTraceService`)
- Write an F value whose symbol or path was not verified in the codebase
- Write a G value whose symbol or path was not verified in the codebase
- Leave **Page Ref. No. (B)** blank or pointing at a non-existent Page row
- Guess file paths or rename symbols to “look right” without opening the file
- Put two different 功能 on the same Ref. No. row
- Create a function row when the parent program/page was neither found nor
included in the same approved preview as a new page row
- Clear history columns without showing that drop in the preview
- Clear history columns without showing that shift/archive in the preview
- Skip adding/updating FP-MTMS checklist comments on code for Functions rows
that were just written (must include Ref. No., version, and update date)
- Commit the Excel file to git


+ 32
- 0
AGENTS.md Datei anzeigen

@@ -10,3 +10,35 @@
- If a task is about UI behavior (charts, clicks, page rendering, dialogs), check `../FPSMS-frontend`.
- If a task is about API/business logic/data query, check this backend repo.
- For end-to-end changes, update both repos and keep API request/response fields aligned.

## M18 ↔ MTMS data mapping

- Handbook: [`docs/MTMS_M18_DATA_MAPPING.md`](docs/MTMS_M18_DATA_MAPPING.md)
- Auto tables: [`docs/generated/`](docs/generated/) — regenerate with `python scripts/generate_m18_mapping_docs.py` after changing `ItemType` / `M18ItemType` or product-type `when` in `M18MasterDataService`.
- Word / Excel (readable exports): `python scripts/export_m18_mapping_office.py` → [`docs/exports/`](docs/exports/)

## User guides (UI-oriented)

- Source Markdown: [`docs/user-guides/`](docs/user-guides/)
- Export Word: `python scripts/export_user_guide_office.py` → `docs/exports/*.docx`
- Guides (UI labels in 「」):
- 排程 → 工單 → `MTMS_Schedule_JobOrder_UserGuide.docx`
- BOM 匯入/啟停/應用 → `MTMS_BOM_UserGuide.docx`
- 工單提料/報工/上架 → `MTMS_JO_Pick_Production_PutAway_UserGuide.docx`
- 送貨訂單/放單/成品出倉 → `MTMS_DO_Shipping_UserGuide.docx`

## Deploy / QA notes (what changed + how to test)

- **How-to for the team:** [`docs/deploy/HOW_TO_GENERATE_COMMIT_TEST_PLANS.md`](docs/deploy/HOW_TO_GENERATE_COMMIT_TEST_PLANS.md)
- **In Cursor:** say `gen test plan` / `產測試計畫`, or slash **`/gen-commit-test-plans`**
- Template: [`docs/deploy/TEMPLATE.md`](docs/deploy/TEMPLATE.md)
- Filled notes: [`docs/deploy/`](docs/deploy/) (e.g. `YYYYMMDD_short-topic.md`)
- **Auto Word test plan per commit** (folder):
```bash
pip install python-docx
python scripts/generate_commit_test_plans_docx.py HEAD~10..HEAD
```
→ [`docs/deploy/commit-plans/`](docs/deploy/commit-plans/) (`YYYY-MM-DD_<sha>_<slug>.docx` + `_index.md`)
Optional: `--also-md` / `--out-dir path` / `--limit N`
- Scaffold one range note: `python scripts/generate_deploy_test_plan.py HEAD~5..HEAD --out docs/deploy/draft.md`
- For critical deploys, ask the agent to refine steps against the real diff (Cursor rule: deploy-test-plan)

+ 236
- 0
docs/MTMS_M18_DATA_MAPPING.md Datei anzeigen

@@ -0,0 +1,236 @@
# MTMS (FPSMS) ↔ M18 資料對照手冊

本文件說明 **MTMS / FPSMS** 與 **M18** 之間的主檔與交易對應、同步方向,以及已知陷阱。

| 區塊 | 維護方式 |
|------|----------|
| 本手冊(說明、流程、陷阱) | **人手**維護 |
| [`docs/generated/`](./generated/) 對照表 | **腳本產生**(見下方) |

重新產生自動表(改完 enum / mapping 後請跑):

```bash
python scripts/generate_m18_mapping_docs.py
```

匯出 **Word / Excel**(給非技術閱讀;需已安裝 `python-docx`、`openpyxl`):

```bash
pip install python-docx openpyxl
python scripts/export_m18_mapping_office.py
```

產出:

- `docs/exports/MTMS_M18_DATA_MAPPING.docx`
- `docs/exports/MTMS_M18_DATA_MAPPING.xlsx`

---

## 1. 系統與名詞

| 名稱 | 說明 |
|------|------|
| **MTMS / FPSMS** | 本後端 `FPSMS-backend` + 前端 `FPSMS-frontend` |
| **M18** | 外部 ERP/主檔與採購/送貨來源系統 |
| **Pull** | M18 → MTMS(product / vendor / unit / currency / BOM / business unit / PO / DO) |
| **Push** | MTMS → M18(例如 GRN、BOM for shop) |

設定入口:`m18/M18Config.kt`(`m18.config.*`)、scheduler 見 `application.yml` / `application-prod.yml`(`scheduler.m18Sync`、`scheduler.m18Grn`)。

主程式目錄:`src/main/java/com/ffii/fpsms/m18/`。

---

## 2. Master API 類型(`StSearchType`)

完整表見自動產生檔:

→ **[generated/m18-stsearch-types.md](./generated/m18-stsearch-types.md)**

摘要:

| M18 `stSearch` | MTMS 落點 |
|----------------|-----------|
| `pro` | `items` |
| `ven` | `shop`(`type=supplier`) |
| `virDept` | `shop`(`type=shop`) |
| `unit` | `uom_conversion`(+ cunit) |
| `cur` | `currency` |
| `udfbomforshop` | `bom` / materials |

實作:`M18MasterDataService`。

---

## 3. 貨品類型(最常查)

### 3.1 同步規則(自動表)

→ **[generated/m18-item-type-mapping.md](./generated/m18-item-type-mapping.md)**

程式:`M18MasterDataService.saveProduct` / `saveProducts` 依 `pro.udfProducttype`:

```text
Consumable Material → consumables
Non-consumable Material → non-consumables
Product → fg
WIP → sfg
Item → item
(其他,含 CMB) → mat ← default
```

Enum 定義:`modules/master/web/models/NewItemRequest.kt`(`ItemType`、`M18ItemType`)。

### 3.2 UI 顯示(存貨)

存貨 Type 欄:`t(itemType)`,翻譯在 `FPSMS-frontend/src/i18n/zh/inventory.json`。

| `items.type` | 存貨頁(zh) |
|--------------|--------------|
| `mat` | 原料 |
| `fg` | 成品 |
| `sfg` / `wip` | 半成品 |
| `consumables` / `cmb` | 消耗品 |
| `non-consumables` / `nm` | 非消耗品/雜項 |

> 系統 **沒有**「產品」這個 `items.type`。M18 的 **Product** 對應 MTMS **`fg`(成品)**。

### 3.3 可手動改嗎?

可以:Settings → Items → Edit → Type(`ProductDetails.tsx`:`fg` / `wip` / `mat` / `cmb` / `nm`)。

注意:之後若再跑 **product sync**,type 會依 M18 `udfProducttype` **覆寫**(含再次落到 `mat`)。

### 3.4 已知陷阱:`CMB`

M18 實務上可出現 `"udfProducttype": "CMB"`(例如蔗糖水 `MG1852`)。

- `"CMB"` ≠ `"Consumable Material"`
- 也不等於前端的 `cmb`
- → sync 走 **else → `mat`** → 存貨顯示 **原料**

若要顯示消耗品:需改 mapping(例如把 `CMB` 對到 `consumables`),或在 M18 改成已支援的字串;僅手動改 MTMS 可能被下次 sync 蓋掉。

---

## 4. 供應商與店鋪

| 方向 | M18 | MTMS |
|------|-----|------|
| Pull vendors | `ven` | `shop`,`ShopType.SUPPLIER`(`supplier`) |
| Pull business units | `virDept` | `shop`,`ShopType.SHOP`(`shop`) |

鍵:`shop.m18Id`、`shop.code`。名稱優先 `descZhTW` → `descZhCN` → `desc`。

`ShopType`:`modules/master/enums/ShopType.kt`。

---

## 5. 單位(UoM)

| M18 | MTMS |
|-----|------|
| Unit master (`unit`) | `uom_conversion`(`code`、`udfudesc`、`udfShortDesc`、`m18Id`…) |
| Cunit 明細 | `M18CunitService.replaceForUnit` |

Item 級採購/庫存/銷售單位在 sync product price 時寫入 `item_uom`(見 `M18MasterDataService` product 區塊)。

PO/DO 行常同時保留:

| 欄位 | 意義 |
|------|------|
| `qty` / `uomId` | MTMS 業務單位(例如採購單位換算後) |
| `qtyM18` / `uomIdM18` | M18 原始單位數量 |

PO 換算邏輯見 `M18PurchaseOrderService`(`convertQtyToPurchaseQty`)。

---

## 6. 貨幣、BOM

| M18 | MTMS | Service |
|-----|------|---------|
| Currency | `currency` | `saveCurrencies` |
| BOM (`udfbomforshop`) | `bom` / `bom_material` | `saveBoms` |

Shop BOM **回寫** M18:`M18BomForShopService`(push)。

---

## 7. 交易文件(摘要)

| 文件 | 方向 | MTMS 主表 | 筆記 |
|------|------|-----------|------|
| PO | M18 → MTMS | `purchase_order` / `purchase_order_line` | `m18Id` / data log;qty 可能換算 |
| DO | M18 → MTMS | `delivery_order` / `delivery_order_line` | 含 `qtyM18`、`uomIdM18` |
| GRN | MTMS → M18 | stock-in → M18 GRN API | 部分 `m18CreatedUId` **不送** GRN(見下) |

### GRN 略過規則

`m18/M18GrnRules.kt`:

| M18 PO `createUid` | 備註 | 行為 |
|--------------------|------|------|
| `2569` | legato | 不 post GRN |
| `2676` | xtech | 不 post GRN |

---

## 8. Config 鍵(對照時常用)

見 `M18Config` / `application-*.yml`:

- `m18.config.seriesId.pp|pf|sc|se|sf|sr`
- `m18.config.beId.pp|pf|toa`
- `m18.config.supplier-not.material-po`
- `m18.config.supplier.shop-po` / `oem-po`
- `scheduler.m18Sync.enabled`
- `scheduler.m18Grn.createEnabled`

---

## 9. 驗證用 SQL 範例

```sql
-- 某貨品目前 type(決定存貨顯示)
SELECT code, name, type, m18Id, m18LastModifyDate
FROM items
WHERE deleted = 0 AND code = 'MG1852';

-- 統計 type 分佈
SELECT type, COUNT(*) AS cnt
FROM items
WHERE deleted = 0
GROUP BY type
ORDER BY cnt DESC;
```

若 M18 回傳 `udfProducttype` 可與上表比對;對不上表中「exact string」者皆會變 `mat`。

---

## 10. 維護約定

1. **改 mapping**:先改 Kotlin enum / `when`,再跑 `python scripts/generate_m18_mapping_docs.py`,把 `docs/generated/*` 一併 commit。
2. **改說明/陷阱**:只改本檔,勿手改 `docs/generated/`。
3. **給營運/開會用**:跑 `python scripts/export_m18_mapping_office.py`,打開 `docs/exports/*.docx` / `*.xlsx`。
4. **新發現的 M18 值**(如新的 `udfProducttype`):記入 generated 腳本的 `KNOWN_UNMAPPED_M18_VALUES`,或補正式 mapping 後重生。
5. PR 若動到 `NewItemRequest.kt` / `M18MasterDataService` product type 分支,review 應檢查 generated docs 是否已更新。

---

## 11. 相關程式索引

| 主題 | 路徑 |
|------|------|
| Item / M18 type enums | `modules/master/web/models/NewItemRequest.kt` |
| Product sync | `m18/service/M18MasterDataService.kt` |
| StSearch | `m18/model/M18MasterDataRequest.kt` |
| Shop type | `modules/master/enums/ShopType.kt` |
| GRN skip | `m18/M18GrnRules.kt` |
| PO sync | `m18/service/M18PurchaseOrderService.kt` |
| DO sync | `m18/service/M18DeliveryOrderService.kt` |
| BOM→M18 | `m18/service/M18BomForShopService.kt` |
| 存貨 Type 顯示 | `InventoryTable.tsx` + `i18n/zh/inventory.json` |
| 物品 Type 下拉 | `CreateItem/ProductDetails.tsx` |

+ 33
- 0
docs/deploy/20260727_isextra_truck_x_ticket_fix.md Datei anzeigen

@@ -0,0 +1,33 @@
# Deploy note — isExtra / 車線-X workbench ticket fix
Date: 2026-07-27
Branch / build: production (`b12b9a49`)
Author: (fill)

## Summary
- Workbench「加單」檢視納入整組 Etra 放單類型(`isExtra` / `isExtrabatch` / `isExtrasingle`),不再只認單一 `isExtra`。
- 「車線-X」加單票依供應商偏好樓層在畫面上拆成「2/F」/「4/F」顯示(不改 DB `storeId`),撳單指派篩選與之一致。

## Scope
- Backend: `DoWorkbenchMainService`, `DoWorkbenchDopoAssignmentService`, `WorkbenchReleaseTypeSupport`, `DoDetailResponse`
- Frontend: 無(沿用現有 `/doworkbench` 加單 UI)
- DB / Liquibase: none
- Config / ops: none

## Commits
- `b12b9a49` — isextra truck X ticket fix

## Test plan
| # | Steps | Expected result |
|---|--------|-----------------|
| 1 | 「倉庫管理」→「成品出倉」→ Tab「加單」。選有 **isExtrabatch/isExtrasingle** 票的「是日/翌日」日期。 | 加單車線面板看得到這些票(不只舊的 `isExtra`);未撳數/總單數合理。 |
| 2 | 同一日找 **車線-X** 且 `storeId` 空的加單票;對照供應商屬 2F/4F 設定。 | 票出現在對應「2/F 票」或「4/F 票」檢視;不應因 storeId 空而整組消失。 |
| 3 | 在加單模式對該車線按撳單/「確認分配」。 | 指派成功;票進入提料明細,`ticketStatus` 變提貨中;無「此樓層沒有可用的提料單」誤報(若確實有未分配票)。 |
| 4 | 對照:一般「批量/單量」非加單票、非車線-X。 | 行為與修前相同(回歸)。 |
| 5 | (負向)加單日完全無 Etra 票。 | 「該樓層未有需處理訂單」或空面板,無 500。 |

## Out of scope / not tested
- 送貨訂單「批量放單」建立票邏輯本身(僅測 Workbench 加單顯示/指派)
- 前端 UI 大改(其他 commit)

## Rollback
- 還原 `b12b9a49` 或 redeploy 前一版 backend build

+ 175
- 0
docs/deploy/HOW_TO_GENERATE_COMMIT_TEST_PLANS.md Datei anzeigen

@@ -0,0 +1,175 @@
# How to generate commit test plans (Word)

This guide is for QA / deploy teammates. Use it when a build is ready and you need **what changed** and **how to test** for each commit.

## Easiest: just tell Cursor

In Cursor chat (Agent), say any of:

- `gen test plan`
- `generate test plan for new commits`
- `產測試計畫` / `有新 commit,幫我出 test plan`
- Or use the slash command: **`/gen-commit-test-plans`**

Cursor will run the generator and put Word files in `docs/deploy/commit-plans/`.

You can be more specific:

- `gen test plan for latest commit`
- `gen test plan for HEAD~5..HEAD`
- `gen test plan since abc1234`

---

## What you get

Running the generator creates **one Word file per commit** under:

```
docs/deploy/commit-plans/
YYYY-MM-DD_<shortsha>_<slug>.docx
_index.md ← list of all generated plans
```

Each `.docx` includes:

- Commit id, date, author, message
- Auto-detected areas (e.g. 送貨出倉, 工單, BOM)
- Files touched
- **Test plan table**: Steps + Expected result

> These plans are a **starting point** (from commit message + file paths). For critical releases, refine steps with the developer or ask the Cursor agent to review the real diff.

---

## Prerequisites

1. Clone / pull latest `FPSMS-backend`.
2. Install Python 3, then:

```bash
pip install python-docx
```

3. Open a terminal at the **repo root** (`FPSMS-backend`).

---

## Basic usage

Generate plans for the last 10 commits:

```bash
python scripts/generate_commit_test_plans_docx.py HEAD~10..HEAD
```

Then open the folder:

```
docs/deploy/commit-plans/
```

Start from `_index.md` to see the list, then open each `.docx`.

---

## Common examples

### Last N commits on current branch

```bash
python scripts/generate_commit_test_plans_docx.py HEAD~5..HEAD
```

### Only the latest commit

```bash
python scripts/generate_commit_test_plans_docx.py HEAD~1..HEAD
```

### Commits since a known SHA (e.g. last production deploy)

```bash
python scripts/generate_commit_test_plans_docx.py <last_deployed_sha>..HEAD
```

Example:

```bash
python scripts/generate_commit_test_plans_docx.py b12b9a49..HEAD
```

### Commits on `production` that are not yet on another branch

```bash
python scripts/generate_commit_test_plans_docx.py origin/staging..origin/production
```

(Adjust branch names to match your remote.)

### Custom output folder

```bash
python scripts/generate_commit_test_plans_docx.py HEAD~10..HEAD --out-dir docs/deploy/commit-plans/release-2026-08-01
```

### Also write Markdown next to Word

```bash
python scripts/generate_commit_test_plans_docx.py HEAD~10..HEAD --also-md
```

### Cap how many commits are processed

```bash
python scripts/generate_commit_test_plans_docx.py HEAD~50..HEAD --limit 15
```

---

## Suggested team workflow

1. Developer merges / tags the build to deploy.
2. QA or release owner runs the generator for **only the commits in this deploy** (use `last_sha..new_sha`).
3. Share the `docs/deploy/commit-plans/` folder (or zip it) with testers.
4. Testers follow Steps / Expected result in each Word file; mark pass/fail.
5. For vague commits (`no message`), ask the author to clarify before sign-off.
6. Optional: keep a hand-written summary in `docs/deploy/YYYYMMDD_topic.md` (see `TEMPLATE.md`).

---

## Related files

| File | Purpose |
|------|---------|
| `scripts/generate_commit_test_plans_docx.py` | Auto Word plans **per commit** |
| `scripts/generate_deploy_test_plan.py` | One Markdown scaffold for a **whole range** |
| `docs/deploy/TEMPLATE.md` | Manual deploy / QA note template |
| `docs/deploy/20260727_isextra_truck_x_ticket_fix.md` | Example of a filled manual note |

---

## Troubleshooting

| Problem | What to try |
|---------|-------------|
| `No module named 'docx'` | `pip install python-docx` |
| `No commits in range` | Check the range syntax: `A..B` means commits reachable from B but not from A |
| Word filename looks odd | Filenames are ASCII-only on purpose (Windows-safe). Chinese text is still inside the document |
| Steps look too generic | Commit had `no message` or unusual paths — ask author / refine with agent |
| Need frontend checks | This repo is backend; also check `FPSMS-frontend` if the change is UI |

---

## Quick copy-paste (release day)

```bash
cd /path/to/FPSMS-backend
git fetch
git checkout <deploy-branch>
pip install python-docx
python scripts/generate_commit_test_plans_docx.py <previous_release_sha>..<this_release_sha>
explorer docs\deploy\commit-plans
```

(On Mac/Linux, open `docs/deploy/commit-plans` in Finder/files instead of `explorer`.)

+ 46
- 0
docs/deploy/TEMPLATE.md Datei anzeigen

@@ -0,0 +1,46 @@
# Deploy / QA note template

Copy this for each deploy (or ask the agent: “寫 deploy test plan for commits X..Y”).

```markdown
# Deploy note — <short title>
Date: YYYY-MM-DD
Branch / build: <branch or tag>
Author: <name>

## Summary
-

## Scope
- Backend:
- Frontend:
- DB / Liquibase: none
- Config / ops: none

## Commits
- `________` —

## Test plan
| # | Steps | Expected result |
|---|--------|-----------------|
| 1 | | |
| 2 | | |

## Out of scope / not tested
-

## Rollback
-
```

Filled examples live in `docs/deploy/` after each release prep.

### Auto-generate Word plans (one file per commit)

Full team instructions: [`HOW_TO_GENERATE_COMMIT_TEST_PLANS.md`](HOW_TO_GENERATE_COMMIT_TEST_PLANS.md)

```bash
pip install python-docx
python scripts/generate_commit_test_plans_docx.py HEAD~10..HEAD
# → docs/deploy/commit-plans/<date>_<sha>_<slug>.docx
```

+ 15
- 0
docs/deploy/commit-plans/2026-07-20_94dbc8db_no_message.docx Datei anzeigen

@@ -0,0 +1,15 @@
Test plan -- 94dbc8db
no messageCommit: 94dbc8db9a0daa677613bfd16bbc039fcd092f02
Date: 2026-07-20 | Author: tommy
Generated: 2026-08-01 15:55 UTCSummary
no messageAreas (auto-detected)
庫存 / 上架 / 出入倉
Files touched
src/main/java/com/ffii/fpsms/modules/productProcess/service/ProductProcessService.kt
src/main/java/com/ffii/fpsms/modules/productProcess/web/ProductProcessController.kt
src/main/java/com/ffii/fpsms/modules/stock/trace/ItemLotTraceOrchestrator.kt
src/main/java/com/ffii/fpsms/modules/stock/trace/TraceMovementLoader.kt
src/main/java/com/ffii/fpsms/modules/stock/trace/TraceOutboundLoader.kt
Test plan
Auto-generated from paths + commit message. Refine before sign-off on critical deploys.#StepsExpected result1依改動點進「上架掃碼」或相關庫存查詢頁。掃碼/查詢結果與庫存數量合理。2做一筆小量入/出/調撥(測試庫)。成功;庫存異動可查。3部署後煙霧:登入系統,開側欄主要入口一次(排程/工單/送貨訂單/成品出倉)。頁面可開、無全域錯誤橫幅。4(回歸)與本改動相鄰但未改的主流程走一輪 Happy path。無明顯回退。5向作者確認此 commit 的實際意圖(訊息為 empty/no message)。補上說明後再簽核上線。Out of scope / notes
Frontend-only changes may live in FPSMS-frontend -- verify paired repo if UI behavior is expected.Rollback: revert this commit or redeploy previous backend build.

+ 16
- 0
docs/deploy/commit-plans/2026-07-22_173e6ce5_workbenchgoodpickexecutiondetail_ui.docx Datei anzeigen

@@ -0,0 +1,16 @@
Test plan -- 173e6ce5
WorkbenchGoodPickExecutionDetail UI 大改Commit: 173e6ce580b779c4577c3c89449b7bfbb18fd43f
Date: 2026-07-22 | Author: CANCERYS\kw093
Generated: 2026-08-01 15:55 UTCSummary
WorkbenchGoodPickExecutionDetail UI 大改WorkbenchTicketReleaseTable 加 user filter

DO release / FloorLanePanel:Truck X 票 + 搜尋依樓層Areas (auto-detected)
送貨訂單 / 成品出倉
Files touched
src/main/java/com/ffii/fpsms/modules/deliveryOrder/service/DoReleaseCoordinatorService.kt
src/main/java/com/ffii/fpsms/modules/deliveryOrder/service/DoWorkbenchMainService.kt
src/main/java/com/ffii/fpsms/modules/deliveryOrder/service/DoWorkbenchReleaseService.kt
src/main/java/com/ffii/fpsms/modules/deliveryOrder/web/DoWorkbenchController.kt
Test plan
Auto-generated from paths + commit message. Refine before sign-off on critical deploys.#StepsExpected result1「送貨訂單」依預計送貨日搜索相關單,必要時「批量放單」或詳情「放單」。放單成功;產生提料票,無未預期 500。2「成品出倉」`/doworkbench`:撳單 --> 掃碼提料 --> 填箱數列印。票可撳、可提、可完成;狀態「待撳單」-->「提貨中」-->「已完成」。3若涉及加單/車線-X:開「加單」分頁與「車線-X」對照。票出現在正確樓層/車線;指派篩選與顯示一致。4核對「車線-X」票在 2/F/4/F 顯示。出現在正確樓層區塊,可指派。5部署後煙霧:登入系統,開側欄主要入口一次(排程/工單/送貨訂單/成品出倉)。頁面可開、無全域錯誤橫幅。6(回歸)與本改動相鄰但未改的主流程走一輪 Happy path。無明顯回退。Out of scope / notes
Frontend-only changes may live in FPSMS-frontend -- verify paired repo if UI behavior is expected.Rollback: revert this commit or redeploy previous backend build.

+ 30
- 0
docs/deploy/commit-plans/2026-07-22_3ebf46e3_uom.docx Datei anzeigen

@@ -0,0 +1,30 @@
Test plan -- 3ebf46e3
建議批號 UOM 檢核/不符則擋、標籤列印只顯示同 UOMCommit: 3ebf46e304ccc847f178cb57dc5ed106f140e0ec
Date: 2026-07-22 | Author: CANCERYS\kw093
Generated: 2026-08-01 15:55 UTCSummary
建議批號 UOM 檢核/不符則擋、標籤列印只顯示同 UOMAreas (auto-detected)
提料單 / Workbench
建議批號 / UOM
庫存 / 上架 / 出入倉
來貨 / 品檢
主檔 (Item/Shop 等)
庫存查詢
Files touched
src/main/java/com/ffii/fpsms/modules/master/service/ItemsService.kt
src/main/java/com/ffii/fpsms/modules/master/web/ItemsController.kt
src/main/java/com/ffii/fpsms/modules/pickOrder/service/HierarchicalFgPayloadAssembler.kt
src/main/java/com/ffii/fpsms/modules/pickOrder/service/PickOrderWorkbenchService.kt
src/main/java/com/ffii/fpsms/modules/stock/entity/InventoryRepository.kt
src/main/java/com/ffii/fpsms/modules/stock/entity/projection/InventoryLotLineInfo.kt
src/main/java/com/ffii/fpsms/modules/stock/service/InventoryLotLineService.kt
src/main/java/com/ffii/fpsms/modules/stock/service/InventoryService.kt
src/main/java/com/ffii/fpsms/modules/stock/service/StockInLineService.kt
src/main/java/com/ffii/fpsms/modules/stock/service/StockOutLineWorkbenchService.kt
src/main/java/com/ffii/fpsms/modules/stock/service/SuggestedPickLotService.kt
src/main/java/com/ffii/fpsms/modules/stock/service/SuggestedPickLotWorkbenchService.kt
src/main/java/com/ffii/fpsms/modules/stock/web/InventoryController.kt
src/main/java/com/ffii/fpsms/modules/stock/web/InventoryLotLineController.kt
src/main/java/com/ffii/fpsms/modules/stock/web/model/LotLineInfo.kt
Test plan
Auto-generated from paths + commit message. Refine before sign-off on critical deploys.#StepsExpected result1「成品出倉」撳單並完成一張提料票(測試庫)。掃碼/提交正常;完成後記錄頁可見。2「查看提貨情況」核對該票狀態。狀態與負責人符合操作。3出倉或提料時掃建議批號;刻意掃 UOM 不符批號。不符 UOM 被擋並有明確提示;相符批號可提交。4標籤列印/批號列表只應出現同 UOM 選項(若本次改動涵蓋)。列表無錯誤 UOM 批號。5依改動點進「上架掃碼」或相關庫存查詢頁。掃碼/查詢結果與庫存數量合理。6做一筆小量入/出/調撥(測試庫)。成功;庫存異動可查。7「工單生產流程」-->「品檢」或待 QC 列表。可開品檢;確定後狀態更新。8主檔搜索受影響編號,核對顯示欄位。名稱/單位/類型等與預期一致。9庫存搜索頁用受影響貨品/倉位查詢。批號、數量、單位正確。10掃一筆 UOM 不符的批號/物料。系統拒絕或明確提示;相符者可過。11選打印機後列印標籤/送貨單標籤。成功列印;內容正確。12部署後煙霧:登入系統,開側欄主要入口一次(排程/工單/送貨訂單/成品出倉)。頁面可開、無全域錯誤橫幅。13(回歸)與本改動相鄰但未改的主流程走一輪 Happy path。無明顯回退。Out of scope / notes
Frontend-only changes may live in FPSMS-frontend -- verify paired repo if UI behavior is expected.Rollback: revert this commit or redeploy previous backend build.

+ 11
- 0
docs/deploy/commit-plans/2026-07-23_18a4d2db_no_message.docx Datei anzeigen

@@ -0,0 +1,11 @@
Test plan -- 18a4d2db
no messageCommit: 18a4d2dbffd46cba5582aae035bf75bc0af505ba
Date: 2026-07-23 | Author: Fai Luk
Generated: 2026-08-01 15:55 UTCSummary
no messageAreas (auto-detected)
資料庫變更
Files touched
src/main/resources/db/changelog/changes/20260723_onpack_qr_pp2404.sql
Test plan
Auto-generated from paths + commit message. Refine before sign-off on critical deploys.#StepsExpected result1部署後確認 Liquibase/changelog 已套用(或啟動 log 無 changeset 失敗)。DB schema/資料符合 changeset。2用相關畫面或 SQL 抽樣驗證新欄位/約束。讀寫正常,無缺欄錯誤。3選打印機後列印標籤/送貨單標籤。成功列印;內容正確。4部署後煙霧:登入系統,開側欄主要入口一次(排程/工單/送貨訂單/成品出倉)。頁面可開、無全域錯誤橫幅。5(回歸)與本改動相鄰但未改的主流程走一輪 Happy path。無明顯回退。6向作者確認此 commit 的實際意圖(訊息為 empty/no message)。補上說明後再簽核上線。Out of scope / notes
Frontend-only changes may live in FPSMS-frontend -- verify paired repo if UI behavior is expected.Rollback: revert this commit or redeploy previous backend build.

+ 13
- 0
docs/deploy/commit-plans/2026-07-23_a81c6f1c_added_onpack2030_for_pp2404.docx Datei anzeigen

@@ -0,0 +1,13 @@
Test plan -- a81c6f1c
added onpack2030 for pp2404Commit: a81c6f1cf66632c0c715b0f23ed9148fa019b79e
Date: 2026-07-23 | Author: Fai Luk
Generated: 2026-08-01 15:55 UTCSummary
added onpack2030 for pp2404Areas (auto-detected)
標籤 / OnPack
Files touched
src/main/resources/onpack2030/3960CFF6FC46C5A174CA3C78D690CA15.bmp
src/main/resources/onpack2030/PP2404.image
src/main/resources/onpack2030/PP2404.job
Test plan
Auto-generated from paths + commit message. Refine before sign-off on critical deploys.#StepsExpected result1對新增/修改的貨品編號列印標籤(測試機)。圖檔/job 正確;可印出。2選打印機後列印標籤/送貨單標籤。成功列印;內容正確。3部署後煙霧:登入系統,開側欄主要入口一次(排程/工單/送貨訂單/成品出倉)。頁面可開、無全域錯誤橫幅。4(回歸)與本改動相鄰但未改的主流程走一輪 Happy path。無明顯回退。Out of scope / notes
Frontend-only changes may live in FPSMS-frontend -- verify paired repo if UI behavior is expected.Rollback: revert this commit or redeploy previous backend build.

+ 14
- 0
docs/deploy/commit-plans/2026-07-24_5f628db3_no_message.docx Datei anzeigen

@@ -0,0 +1,14 @@
Test plan -- 5f628db3
no messageCommit: 5f628db30cb513b88f2aaa164700db0240700ea7
Date: 2026-07-24 | Author: Fai Luk
Generated: 2026-08-01 15:55 UTCSummary
no messageAreas (auto-detected)
標籤 / OnPack
Files touched
src/main/resources/onpack2030/PP2404.image
src/main/resources/onpack2030/PP2404.job
src/main/resources/onpack2030/pp2404.image
src/main/resources/onpack2030/pp2404.job
Test plan
Auto-generated from paths + commit message. Refine before sign-off on critical deploys.#StepsExpected result1對新增/修改的貨品編號列印標籤(測試機)。圖檔/job 正確;可印出。2選打印機後列印標籤/送貨單標籤。成功列印;內容正確。3部署後煙霧:登入系統,開側欄主要入口一次(排程/工單/送貨訂單/成品出倉)。頁面可開、無全域錯誤橫幅。4(回歸)與本改動相鄰但未改的主流程走一輪 Happy path。無明顯回退。5向作者確認此 commit 的實際意圖(訊息為 empty/no message)。補上說明後再簽核上線。Out of scope / notes
Frontend-only changes may live in FPSMS-frontend -- verify paired repo if UI behavior is expected.Rollback: revert this commit or redeploy previous backend build.

+ 11
- 0
docs/deploy/commit-plans/2026-07-24_93c3e931_no_message.docx Datei anzeigen

@@ -0,0 +1,11 @@
Test plan -- 93c3e931
no messageCommit: 93c3e9318fa3f5fc65c3ab1ca06b3e73d1caf0e6
Date: 2026-07-24 | Author: Fai Luk
Generated: 2026-08-01 15:55 UTCSummary
no messageAreas (auto-detected)
標籤 / OnPack
Files touched
src/main/resources/onpack2030/PP2404.image
Test plan
Auto-generated from paths + commit message. Refine before sign-off on critical deploys.#StepsExpected result1對新增/修改的貨品編號列印標籤(測試機)。圖檔/job 正確;可印出。2選打印機後列印標籤/送貨單標籤。成功列印;內容正確。3部署後煙霧:登入系統,開側欄主要入口一次(排程/工單/送貨訂單/成品出倉)。頁面可開、無全域錯誤橫幅。4(回歸)與本改動相鄰但未改的主流程走一輪 Happy path。無明顯回退。5向作者確認此 commit 的實際意圖(訊息為 empty/no message)。補上說明後再簽核上線。Out of scope / notes
Frontend-only changes may live in FPSMS-frontend -- verify paired repo if UI behavior is expected.Rollback: revert this commit or redeploy previous backend build.

+ 14
- 0
docs/deploy/commit-plans/2026-07-27_b12b9a49_isextra_truck_ticket_fix.docx Datei anzeigen

@@ -0,0 +1,14 @@
Test plan -- b12b9a49
isextra truck X ticket fixCommit: b12b9a499f63730c0a61e40075e6eda7785f6919
Date: 2026-07-27 | Author: CANCERYS\kw093
Generated: 2026-08-01 15:55 UTCSummary
isextra truck X ticket fixAreas (auto-detected)
送貨訂單 / 成品出倉
Files touched
src/main/java/com/ffii/fpsms/modules/deliveryOrder/service/DoWorkbenchDopoAssignmentService.kt
src/main/java/com/ffii/fpsms/modules/deliveryOrder/service/DoWorkbenchMainService.kt
src/main/java/com/ffii/fpsms/modules/deliveryOrder/service/WorkbenchReleaseTypeSupport.kt
src/main/java/com/ffii/fpsms/modules/deliveryOrder/web/models/DoDetailResponse.kt
Test plan
Auto-generated from paths + commit message. Refine before sign-off on critical deploys.#StepsExpected result1「送貨訂單」依預計送貨日搜索相關單,必要時「批量放單」或詳情「放單」。放單成功;產生提料票,無未預期 500。2「成品出倉」`/doworkbench`:撳單 --> 掃碼提料 --> 填箱數列印。票可撳、可提、可完成;狀態「待撳單」-->「提貨中」-->「已完成」。3若涉及加單/車線-X:開「加單」分頁與「車線-X」對照。票出現在正確樓層/車線;指派篩選與顯示一致。4「成品出倉」-->「加單」檢視該日票。加單票可見且可撳單。5核對「車線-X」票在 2/F/4/F 顯示。出現在正確樓層區塊,可指派。6重現原問題步驟一次。問題不再出現;無新副作用。7部署後煙霧:登入系統,開側欄主要入口一次(排程/工單/送貨訂單/成品出倉)。頁面可開、無全域錯誤橫幅。8(回歸)與本改動相鄰但未改的主流程走一輪 Happy path。無明顯回退。Out of scope / notes
Frontend-only changes may live in FPSMS-frontend -- verify paired repo if UI behavior is expected.Rollback: revert this commit or redeploy previous backend build.

+ 14
- 0
docs/deploy/commit-plans/_index.md Datei anzeigen

@@ -0,0 +1,14 @@
# Commit test plans (auto-generated)

Generated: 2026-08-01 15:55 UTC

| Date | SHA | Subject | Word |
|------|-----|---------|------|
| 2026-07-20 | `94dbc8db` | no message | [2026-07-20_94dbc8db_no_message.docx](2026-07-20_94dbc8db_no_message.docx) |
| 2026-07-22 | `3ebf46e3` | 建議批號 UOM 檢核/不符則擋、標籤列印只顯示同 UOM | [2026-07-22_3ebf46e3_uom.docx](2026-07-22_3ebf46e3_uom.docx) |
| 2026-07-22 | `173e6ce5` | WorkbenchGoodPickExecutionDetail UI 大改 | [2026-07-22_173e6ce5_workbenchgoodpickexecutiondetail_ui.docx](2026-07-22_173e6ce5_workbenchgoodpickexecutiondetail_ui.docx) |
| 2026-07-23 | `a81c6f1c` | added onpack2030 for pp2404 | [2026-07-23_a81c6f1c_added_onpack2030_for_pp2404.docx](2026-07-23_a81c6f1c_added_onpack2030_for_pp2404.docx) |
| 2026-07-23 | `18a4d2db` | no message | [2026-07-23_18a4d2db_no_message.docx](2026-07-23_18a4d2db_no_message.docx) |
| 2026-07-24 | `93c3e931` | no message | [2026-07-24_93c3e931_no_message.docx](2026-07-24_93c3e931_no_message.docx) |
| 2026-07-24 | `5f628db3` | no message | [2026-07-24_5f628db3_no_message.docx](2026-07-24_5f628db3_no_message.docx) |
| 2026-07-27 | `b12b9a49` | isextra truck X ticket fix | [2026-07-27_b12b9a49_isextra_truck_ticket_fix.docx](2026-07-27_b12b9a49_isextra_truck_ticket_fix.docx) |

+ 106
- 0
docs/exports/MTMS_BOM_UserGuide.docx Datei anzeigen

@@ -0,0 +1,106 @@
MTMS 使用說明:BOM(匯入/啟停/應用)
本手冊依目前前端畫面與繁中文案整理,按鈕/選單名稱以畫面上「」內文字為準。
適用範圍:側欄「設定」下的 BOM 相關頁、建立工單時選 BOM、排程「自動生成工單」。
「一附」章節使用本機資料庫 fpsmsdb 的真實例子(查詢當下快照);其他環境請改條件再對。
產生日期:依原始碼現況(若畫面改版,請以實際 UI 為準)。
這份手冊怎麼用
符號意思「......」畫面上看得到的按鈕、選單、標題-->下一步操作建議閱讀順序:
先匯入/啟用 BOM(「設定」-->「匯入 BOM」)
再檢查單位問題(ADMIN:「BOM / 物料單位問題」)
再建工單/排程產單(選 active BOM)
一、整體流程(BOM 從主檔到工單)
【匯入 BOM】Excel 上傳並檢查 -->「確認匯入」
【BOM 明細】確認「啟用」/必要時「停用」
↓(選做)「BOM 權重得分」調整加權
↓(ADMIN)「BOM / 物料單位問題」巡檢
【建工單】「建立工單」選「BOM」
或【排程】「自動生成工單」(僅含具 BOM 的物料)一附、本地資料庫實例(方便對照畫面)
以下數字來自本機 fpsmsdb 查詢結果。不同環境/日期資料會不同。
BOM 狀態:active-->「啟用」、inactive-->「停用」。本機約有 214 筆啟用、3 筆停用。
例 A:啟用中的成品 BOM(適合在「BOM 明細」搜索)
「物品編號」「物品名稱」「BOM 狀態」約材料筆數PP2167牛丼啟用10PP2277烚意粉啟用3PP2390熱情香果醬啟用4PP1224柚子蒜蓉汁啟用5PP1136白粥啟用9PP0259牛肉水(2KG/包)啟用0(無材料列,排程/提料前宜先確認)怎麼練:
「設定」-->「匯入 BOM」--> Tab「BOM 明細」。
「物品編號」填 PP2167 -->「搜索」。
載入後看「基本資訊」「材料清單」「製程與設備」;「BOM 狀態」應為「啟用」。
例 B:材料清單長什麼樣(PP2167 牛丼)
「物品編號」「物品名稱」約「基本數量」MA0471日式超薄肥牛272.16FA0164洋蔥絲145.15GI3236清水60.00PP2214丼飯汁(2磅/包)41.73MG1507江泉牌燒肉汁(1.8L/支)9.00MG1299李錦記特選老抽(8L/桶)8.00MG0302佛手牌味粉(10lb/罐)4.54MG0239韓國雪白幼砂糖(30kg/包)4.54例 C:用 BOM 建工單(接《排程與工單》手冊)
本機 2026-07-29 手動工單多掛在 active BOM 上,例如:「工單編號」「狀態」成品 BOM「需求數量」JO-260729-038待處理PP2277 烚意粉51JO-260729-044提料中PP2383 辣椒菜脯265怎麼練:「搜索工單/ 建立工單」-->「建立工單」-->「BOM」下拉選 PP2277(僅顯示啟用中的 BOM)。
自行查核用 SQL(選用)
SELECT b.code, i.name, b.status,
(SELECT COUNT(*) FROM bom_material bm
WHERE bm.bomId = b.id AND IFNULL(bm.deleted,0)=0) AS mats
FROM bom b
JOIN items i ON i.id = b.itemId
WHERE b.deleted = 0 AND b.code = 'PP2167';

SELECT i.code, i.name, bm.qty
FROM bom_material bm
JOIN items i ON i.id = bm.itemId
JOIN bom b ON b.id = bm.bomId
WHERE b.code = 'PP2167' AND IFNULL(bm.deleted,0)=0;二、匯入 BOM(「設定」-->「匯入 BOM」)
2.1 入口
側欄 「設定」 --> 「匯入 BOM」(路徑通常為 /settings/importBom)
頁內標題可能顯示英文 Import BOM;側欄與 Tab 以繁中「匯入 BOM」為準。
2.2 兩個分頁
Tab用途「匯入 BOM」上傳 Excel、檢查、確認寫入「BOM 明細」查已有 BOM、啟用/停用2.3 操作步驟:上傳並匯入
開 Tab 「匯入 BOM」。
「選擇 BOM Excel 檔案」 -->「選擇檔案」或「選擇資料夾」(可多選 .xlsx)。
確認「已選 N 個檔案」後,點 「上傳並檢查」。
進行中:「上傳與檢查中...」、進度「已檢查 x / y 個檔案...」。
檢視結果:
「正確 BOM 列表(可匯入)」
「問題 BOM 列表」
必要時勾選類型相關選項(如「飲料」「箱料粉」等)或用「搜索檔名」篩選。
確認無誤後點 「確認匯入」。
成功提示類似 「匯入完成」;亦可先「下載檢查結果 Excel」留底。
若要重來:點「返回重選檔案」。
2.4 常見錯誤(匯入)
畫面提示建議處理「請至少選擇一個 .xlsx 檔案」先選檔再上傳「上傳或檢查失敗,請稍後再試。」/伺服器 500檢查網路/檔案格式後重試檔名重複相關提示(_2、_3...)依提示整理檔名後重傳「匯入失敗,請查看主控台。」記下失敗檔,修正 Excel 後只重傳問題檔三、BOM 明細:查詢與啟停
3.1 搜索
Tab 「BOM 明細」。
「物品編號」/「物品名稱」-->「搜索」/「重置」。
多筆時:「找到多筆 BOM,請選擇一筆載入明細」(按鈕上會標「成品|半成品」「啟用|停用」)。
3.2 可改什麼
區塊說明「BOM 狀態」「啟用」/「停用」-->「儲存狀態」「基本資訊」產出數量、類型、過敏原、色深/浮沉/濃淡、時段、複雜度、基礎得分等(多為檢視)「材料清單」物品編號/名稱、基本/庫存/銷售數量與單位「製程與設備」製程檢視 注意: 完整欄位線上「編輯/儲存」目前前端關閉(SHOW_BOM_FULL_EDIT = false)。日常維護以 Excel 匯入 與 啟用/停用 為主。
3.3 何時「停用」
配方作廢、暫不允許再建工單時,將「BOM 狀態」改「停用」並「儲存狀態」。
「建立工單」的 BOM 下拉只列出啟用項目;停用後新單選不到該 BOM。
四、BOM 權重得分
4.1 入口
「設定」--> 「BOM 權重得分」(/settings/bomWeighting)
4.2 分頁
Tab用途「BOM 加權」調整各評分項目的「權重」(總和須=1)「BOM得分」查看各貨品「基礎得分」4.3 操作
「BOM 加權」-->「編輯」-->改「權重」-->「儲存」。
校驗失敗時:「權重必須為數字」或「權重總和必須等於 1(目前總和: x)」。
成功:「更新成功(已重新計算 N 筆 BOM 基礎分)」。
到「BOM得分」核對「貨品編號」「物品名稱」「基礎得分」。
五、BOM/物料單位問題(ADMIN)
5.1 入口
「設定」--> 「BOM / 物料單位問題」(僅 ADMIN;/settings/masterDataIssues)
頁標題常顯示:「BOM/貨品單位問題」
5.2 操作
Tab「BOM」或「貨品」。
「搜索」「類型」(「全部」「BOM 總表」「BOM 原材料」)。
「重新檢查」更新清單;「複製清單」方便貼到表單/郵件。
點列開詳情:「問題」「應為」「實際」-->「關閉」。
5.3 常見問題文案(節錄)
「BOM 編號為空」「BOM 名稱為空」
「BOM 產出單位與成品銷售單位不一致」
「BOM 原料銷售/基本/庫存單位與貨品主檔不一致」
「BOM 編號與關聯貨品不一致」
側欄紅點例:BOM N 筆 · 貨品 M 筆。空狀態:「目前沒有問題。」六、應用:建立工單時選 BOM
6.1 手動建立
「管理工單」-->「搜索工單/ 建立工單」-->「建立工單」。
「BOM」(必填)選成品/半成品;同名時可能標「(成品)」「(半成品)」。
「標準生產數」x「批數」=「需求數量」(單位來自 BOM 產出 UOM)。
「預計生產日期」等填妥 -->「建立」。
成功常提示「成功更新資料」。未選 BOM 時可能出現「請選擇 BOM」/Bom required!。
6.2 排程自動生成
「排程」-->「生產排程」-->「詳細」。
「自動生成工單」(僅處理具 BOM 的物料;說明文案類似「選擇日期範圍(僅含具 BOM 的物料...)」)。
舊細排頁另有「生成工單」「查看 BOM」(材料表含「編號」「名稱」「可用數量」「需求數量」)。
詳見《MTMS 排程與工單 使用說明》。七、權限與路徑速查
畫面路徑備註匯入 BOM/settings/importBom「設定」下BOM 權重得分/settings/bomWeightingBOM/物料單位問題/settings/masterDataIssuesADMIN建立工單/jo選 active BOM排程產單/ps需 BOM八、常見問題速查
情況建議建工單下拉找不到某成品到「BOM 明細」確認是否「啟用」;或尚未匯入排程產單跳過某物料該物料可能無 BOM/不在排期 BOM 範圍單位對不上、品檢/提料異常ADMIN 開「BOM / 物料單位問題」對照主檔後修正 Excel 再匯入權重儲存失敗確認各權重為數字且總和=1

+ 149
- 0
docs/exports/MTMS_DO_Shipping_UserGuide.docx Datei anzeigen

@@ -0,0 +1,149 @@
MTMS 使用說明:送貨訂單 --> 放單 --> 成品出倉
本手冊依目前前端畫面與繁中文案整理,按鈕/選單名稱以畫面上「」內文字為準。
適用範圍:側欄「送貨訂單」、倉庫「成品出倉」(撳單/掃碼出倉/列印標籤)、加單與車線-X。
「一附」章節使用本機資料庫 fpsmsdb 的真實例子(查詢當下快照)。
產生日期:依原始碼現況(若畫面改版,請以實際 UI 為準)。
這份手冊怎麼用
符號意思「......」畫面上看得到的按鈕、選單、標題-->下一步操作角色分工(白話):
角色動作主要畫面看單、放單、加單、補貨「送貨訂單」/do撳單、掃碼提貨、列印 DN/標籤「成品出倉」/doworkbench調整提料順序等(ADMIN)「成品出倉管理」 「放單」!=「撳單」:放單=由送貨訂單產生提料票;撳單=倉庫領取該票開始出倉。
建議閱讀順序:送貨訂單篩選 --> 放單 --> 撳單 --> 掃碼出倉 --> 填箱數列印。一、整體流程
【送貨訂單 /do】篩選「2/F」「4/F」「車線-X」「加單」
↓ 「批量放單」或詳情「放單」
【產生提料單/提票】狀態進入待撳單
【成品出倉 /doworkbench】「撳單/提料單詳情」
↓ 選日期/批量|單量/樓層票 --> 點車線「確認分配」
【掃碼提料】「開始QR掃描」-->「提交所有已掃描項目」
【成品提貨記錄】輸入「箱數」-->「列印送貨單標籤」等
【查看提貨情況】核對「已完成」一附、本地資料庫實例
DO「來貨狀態」:pending-->「待處理」、receiving-->「接收中」、completed-->「已完成」。
提票在「查看提貨情況」:pending-->「待撳單」、released-->「提貨中」、completed-->「已完成」。
例 A:待放單/待出貨的送貨訂單(2026-07-30)
本機「預計送貨日期」2026-07-30 仍有多張「待處理」,例如:「門店訂單編號」「來貨狀態」店鋪TOUR03PO26070304待處理UR03TOCF28PO26070199待處理CF28TOCF28PO26070200待處理CF28TOCF02PO26070176待處理CF02TOCF18PO26070194待處理CF18怎麼練:
「送貨訂單」--> 選樓層分頁 -->「預計送貨日期」填 2026-07-30 --> 搜索。
找上表編號點「詳情」看行項與「庫存可用」。
測試庫才建議真的「放單」/「批量放單」(會產生提料票)。
例 B:近一週 DO 狀態量級(本機 2026-07-28~08-05)
「來貨狀態」約筆數使用者常做待處理1,425篩選後「批量放單」已完成1,059查歷史/補貨原單接收中96出倉進行中對應例 C:提料票(撳單前「待撳單」風格樣本)
本機較早提票例(do_pick_order,狀態 pending≈待撳/待處理):「提票號碼」車線資訊店鋪「需求日期」放單類型TI-S-20260504-4F-001P06B_Sat_區1_港島東MC492026-05-04single(單量)TI-S-20260504-2F-001車線-F1MC492026-05-04singleTI-S-20260504-2F-001車線-XHP652026-05-04singleTI-S-20260505-4F-002P06B_Tue_區5_九龍中HP152026-05-05single怎麼練:「成品出倉」-->「撳單/提料單詳情」--> 日期選對應「是日/翌日...」;或「成品提貨記錄(全部)」用「提票號碼」搜索。注意「車線-X」會獨立分組。
自行查核用 SQL(選用)
SELECT d.code, d.status, DATE(d.estimatedArrivalDate) AS eta, s.code AS shop
FROM delivery_order d
LEFT JOIN shop s ON s.id = d.shopId
WHERE d.deleted = 0 AND DATE(d.estimatedArrivalDate) = '2026-07-30'
ORDER BY d.id DESC
LIMIT 20;

SELECT ticket_no, TruckLanceCode, ticket_status, ShopCode,
DATE(RequiredDeliveryDate) AS req_date, release_type
FROM do_pick_order
WHERE deleted = 0 AND RequiredDeliveryDate >= '2026-05-01'
ORDER BY id DESC
LIMIT 20;二、送貨訂單(/do)
2.1 入口與分頁
側欄 「送貨訂單」。
分頁用途「2/F」「4/F」依樓層票別看/放單「車線-X」無匹配車線或歸入 X 的訂單「加單」isExtra 加單;批量放單可合併「補貨」已完成原單補到目標單2.2 搜索欄
「門店訂單編號」「店鋪名稱」「車線號碼」「預計送貨日期」「來貨狀態」
狀態選項:「待處理」「接收中」「已完成」(及「全部」)
注意:「已填寫車線號碼時,請一併選擇預計送貨日期後再搜索。」/「需選擇預計送貨日期」
2.3 結果表常見欄
「詳情」「門店訂單編號」「店鋪名稱」「供應商名稱」「車線號碼」「訂單日期」「預計送貨日期」「來貨狀態」。2.4 詳情頁(/do/edit?id=)
標題:「編輯送貨訂單詳情」
動作:「放單」「提料單分配」「分配2/F」「分配4/F」「放單2/F」「放單4/F」「返回」
行表:「商品編號」「貨品名稱」「數量」「庫存可用」「庫存狀態」
單張成功提示:「送貨訂單放單成功!提料單已建立。」
三、放單詳解
3.1 批量放單(常用)
在「送貨訂單」搜出目標日/樓層的列,勾選需要的店(可取消勾選排除)。
點 「批量放單」。
對話框顯示「已選擇店舖數量: N」;「確認」執行。
「加單」分頁額外選項:
「確認合併放單」(合併同車線 --> TI-M- 合併票;文案含「合併同車線送貨訂單(TI-M- 合併票)」)
「確認不放合併放單」
成功:「已完成批量放單」。
Workbench 路徑按鈕亦可能顯示為「批量放單」(鍵名 Workbench Batch Release)。3.2 單張放單
詳情頁「放單」,或先「分配2/F/4/F」再「放單2/F/4/F」。3.3 放單前/失敗常見提示
提示處理「沒有選擇送貨訂單進行批量放單...」先勾選列「車線可用性警告」「問題送貨訂單」核對目標日是否有車線;或走「車線-X」「放單提料單失敗,請稍後再試。」稍後重試;查該店是否已放過四、成品出倉(/doworkbench) -- 主路徑
4.1 入口
「倉庫管理」--> 「成品出倉」(現行主選單指向 /doworkbench)。
舊頁 /finishedGood 標題同為「成品出倉」,一般以 Workbench 為準。
4.2 頁頂打印機
「A4 打印機」「標籤打印機」「列印全部草稿 (N)」
未選機:「請先選擇打印機」/「請先選擇標籤打印機」
4.3 分頁一覽
tab標籤0「撳單/提料單詳情」1「加單」(徽章:當日未完成加單票數)2「成品提貨記錄」3「成品提貨記錄(全部)」4「查看提貨情況」5「成品出倉出箱數量」6「送貨路線摘要」五、撳單(領票)
5.1 條件列
「請選擇日期」:「是日」「翌日」「後日」
「放單類型」:「批量」「單量」
「票別(樓層)」:「2/F 票」「4/F 票」
5.2 車線面板
車線按鈕顯示「(未撳數/總單數)」、裝載序/出發時間等。
點車線 -->「確認分配」(含「位置」「車線號碼」「裝載順序」「出發時間」「所需日期」「可用訂單」)-->「確認」。
成功後進入提料明細掃碼。
無單時:「該樓層未有需處理訂單」/「此樓層沒有可用的提料單」。
「未完成提料單」可搜商店/車線/送貨單編號再「選擇」。
限制:「請先完成目前的提料單,再提取下一張」。
5.3 「車線-X」
DO 與出倉皆有獨立「車線-X」區塊;無匹配車線時顯示「車線-X」。
「以前」:今日前未完成的車線-X。
出箱儀表會統計「車線-X 出箱數」。
六、掃碼出倉(提料執行)
在已撳單的明細中看「所有提料單批號」「進度」。
「開始QR掃描」/「停止QR掃描」;「掃描結果」正確時「二維碼驗證成功。」
可「改數」「提交數量」;問題回報含不良/遺失等。
「提交所有已掃描項目」;可選「列印空白頁數標籤」。
無掃碼可直接完成的列可用「已完成」(Just Completed)。
全部完成後通常導向「成品提貨記錄」(帶提票號)。
掃碼常見錯誤
提示處理「二維碼不符合當前訂單中的任何貨品。」確認掃的是本票貨品「此批號不可用...」「此批次尚未上架」換批或先完成上架「此批號單位不符...」「此批號已提貨...」換批「掃描批號已過期...」換未過期批「此批次貨品已被其他送貨單留起...」換批或協調留貨換批雙掃說明依畫面再掃一次確認七、加單專章
7.1 在「送貨訂單」
開「加單」分頁搜索與「批量放單」。
合併選項見 §3.1(TI-M- 合併票)。
7.2 在「成品出倉」
Tab「加單」;進入前確認:「進入加單檢視?」
說明:「加單檢視會依選定日期,將 isExtra 票依店鋪與車線顯示。」
「目前是加單票,顯示與操作已切換為加單模式。」/「離開加單檢視」「返回一般指派分頁」。
「合併加單提料單」:僅「未分配」且同店鋪、樓層(2/F、4/F 或車線-X)、車線、出發時間可合併。
類型顯示可能為「合拼單」「加單」「批量」「單量」。
八、補貨(「送貨訂單」-->「補貨」)
「補貨填表」「對單」-->「待提交列表」-->「提交」/「清空」。
「送貨單號末四位」「原送貨單」「目標送貨單」「補貨數量」「原出貨數」「車線」。
無車線時畫面可能顯示「車線-X」。
「補貨進度追蹤」:待處理/處理中/已完成。
限制例:「只有已送貨(completed)的送貨單可作為原送貨單。」「補貨數量必須大於零」。
九、箱數與列印
9.1 草稿/空白
「列印全部草稿 (N)」--> 確認「確認列印全部草稿?(總數量:N份)」-->「成功列印」。
提料中:「列印空白頁數標籤」-->「請輸入要列印的標籤數量:」。
9.2 完成後正式列印(「成品提貨記錄」)
「查看詳情」或列表動作。
「列印提料單」「列印送貨單標籤」「列印提料單和送貨單標籤」「補印標籤」。
彈窗「請輸入總箱數」,欄位「箱數」(>=1)。
成功:「成功列印」。
9.3 補印
「補印送貨單標籤」:「起始箱號」「結束箱號」「總箱數」
校驗:起始>=1、結束>=起始、結束<=總箱數等。
9.4 送貨路線摘要
Tab「送貨路線摘要」--> 選「車線」-->「下載報告 (PDF)」。
若未執完:「此車線仍有 N 張訂單未執拾。是否仍要列印 / 下載送貨路線摘要?」
9.5 出箱數量
Tab「成品出倉出箱數量」:按日「2/F 出箱數」「4/F 出箱數」「車線-X 出箱數」「總出箱數」(來自完成時填的箱數)。
十、查看提貨情況與管理動作
10.1 查詢
「目標日期」「重新載入」「樓層」「狀態」(「待撳單」「提貨中」「已完成」)。
欄含貨車/車線/裝載順序/提票號碼/負責員工/訂單項目數量等。
10.2 管理(常需 ADMIN)
動作意義(畫面說明意涵)「撤銷領取」清空負責人,單據回待分配,他人可再領「強制完成提貨單」標完成並歸檔,不改已揀數量;適用已全部提交但系統未結案未授權:「僅管理員(ADMIN 權限)可使用」。
十一、狀態對照(避免搞混三套名稱)
11.1 送貨訂單「來貨狀態」
鍵畫面pending「待處理」receiving「接收中」completed「已完成」(部分流程)released / picking「已放單」/「提料中」11.2 「查看提貨情況」提票狀態
鍵畫面白話pending「待撳單」已放單、尚未領取released「提貨中」已撳單/出倉中completed「已完成」提貨完成11.3 提料單通用(pickOrder)
「待處理」「已放單」「提料中」「已完成」 -- -- 與上表用詞接近但場景不同;操作時以目前所在分頁的 Chip 文案為準。十二、成品出倉管理(ADMIN)
「倉庫管理」-->「成品出倉管理」(/finishedGood/management)。
「提料順序」:上移/下移/置頂/置底、「新增物品」「儲存」「重新載入」。
「出貨倉位」「入貨倉位」等主檔維護。
十三、路徑速查
畫面路徑送貨訂單/doDO 詳情/放單/do/edit?id=成品出倉(主)/doworkbench成品出倉管理/finishedGood/management舊成品出倉/finishedGood(附錄對照用)十四、常見問題速查
情況建議批量放單後倉庫看不到票核對日期「是日/翌日」、樓層票別、批量/單量、是否加單檢視車線按鈕 0 單換日期/樓層;查「車線-X」「未完成提料單」掃碼一直失敗批號是否上架、是否被留貨/過期/單位不符印不出標籤先選 A4/標籤打印機;完成後記得填「箱數」加單與正單混在一起明確進/出「加單」檢視;合併規則要同店同線同時段畫面突然英文少數錯誤字串尚未進 zh,以實機為準並回報補譯

+ 105
- 0
docs/exports/MTMS_JO_Pick_Production_PutAway_UserGuide.docx Datei anzeigen

@@ -0,0 +1,105 @@
MTMS 使用說明:工單提料/報工/上架
本手冊依目前前端畫面與繁中文案整理,按鈕/選單名稱以畫面上「」內文字為準。
適用範圍:放單之後的「工單提料」「工單生產流程」「上架掃碼」(品檢/上架)。
建單與排程請先看《MTMS 排程與工單 使用說明》;BOM 請看《MTMS BOM 使用說明》。
「一附」章節使用本機資料庫 fpsmsdb 的真實例子(查詢當下快照)。
產生日期:依原始碼現況(若畫面改版,請以實際 UI 為準)。
這份手冊怎麼用
符號意思「......」畫面上看得到的按鈕、選單、標題-->下一步操作建議閱讀順序:
放單(「搜索工單/ 建立工單」)
提料(「工單提料」)
對料 --> 工序 --> 完成工單(「工單生產流程」)
品檢 --> 上架(「品檢」+「上架掃碼」)
一、整體流程
【/jo】「規劃中」──「放單」──►「待處理」/「提料中」
【/jodetail】「工單提料」──「查看詳情」──► 掃碼提料 ──「提交」/「提交所有已掃描項目」
【/productionProcess】「工單生產流程」
├─(可選)「工單對料」--> 二次掃碼 -->「確認所有提料」
├─「查看詳情」--> 各工序「開始」/「訂單完成」或「已完成」(Just Pass)
「完成工單」(確認:「確認要完成此工單嗎?」)
「品檢」-->「確定品檢結果」(可列印/下載 QR)
【/putAway】「上架掃碼」:掃貨品 QR --> 掃倉庫 QR -->「確定及上架貨物」
「已上架工單」;工單「已完成」一附、本地資料庫實例
狀態中文:pending-->「待處理」、packaging/picking-->「提料中」、processing-->「生產中」、storing-->「待品檢入倉」、completed-->「已完成」。
例 A:適合練提料(2026-07-29)
「工單編號」「狀態」成品「需求數量」建議下一步JO-260729-038待處理PP2277 烚意粉51「工單提料」開單掃碼JO-260729-008提料中PP2257 咖哩汁箱料粉1繼續提交提料JO-260729-013提料中PP1043 豆豉汁(2磅/包)309繼續提交提料JO-260729-044提料中PP2383 辣椒菜脯265繼續提交提料JO-260729-034生產中PP2302 酸甜蘿蔔粒箱料粉1「工單生產流程」做工序/完成工單怎麼練:
「搜索工單/ 建立工單」-->「預計生產日期」2026-07-29 -->「搜索」。
「狀態」篩「待處理」找 JO-260729-038,或直接到「工單提料」找同日卡片。
「生產中」單到「工單生產流程」練「查看詳情」/「完成工單」(完成會改資料,請用測試庫)。
例 B:狀態與畫面入口對照
「狀態」常用入口待處理/提料中「工單提料」生產中「工單生產流程」-->「工藝流程」待品檢/待品檢入倉「工單生產流程」-->「品檢」或「待QC上架工單」已完成「已上架工單」/工單搜索「已完成」自行查核用 SQL(選用)
SELECT jo.code, jo.status, i.code, i.name, jo.reqQty
FROM job_order jo
LEFT JOIN bom b ON b.id = jo.bomId
LEFT JOIN items i ON i.id = b.itemId
WHERE jo.deleted = 0 AND DATE(jo.planStart) = '2026-07-29'
AND jo.status IN ('pending','packaging','processing','storing')
ORDER BY jo.status, jo.code
LIMIT 20;二、放單(前置,在「搜索工單/ 建立工單」)
側欄「管理工單」-->「搜索工單/ 建立工單」(/jo)。
找到「規劃中」工單 -->「放單」或「放單 (N)」。
放單後狀態變「待處理」/「提料中」,即可去「工單提料」。
詳情內可見庫存摘要字樣如「可提料項目數量:」「未能提料項目數量:」。
亦可在生產流程詳情內對仍屬規劃中的單按「放單」。
三、工單提料(/jodetail)
3.1 入口與分頁
側欄「管理工單」--> 「工單提料」;頁標題「工單提料」。
Tab文案0「工單提料詳情」1「已完成工單記錄」2「物料提料狀態」3「膠茜數目使用數量」3.2 「工單提料詳情」列表
品類:「全部」「飲料」「箱料粉」「其他」
樓層:「2F」「3F」「4F」「沒有批號」等
卡片常見:「工單」「批號」「提料單」「物品名稱」「需求數量」、狀態 Chip
點 「查看詳情」 進入掃碼提料
3.3 掃碼提料步驟
點「開始掃碼」(可「停止掃碼」)。
掃描物料/批號 QR;必要時開「批號QR碼掃描」或「手動輸入」-->「提交」。
畫面上應出現「QR碼驗證成功。」/「驗證成功!」;進度見「掃碼結果」「提交數量」。
單行「提交」或一次「提交所有已掃描項目」(進行中「提交中...」)。
完成後「返回列表」。
3.4 「已完成工單記錄」
「查看詳情」「打印版頭紙」(2F/3F/4F)、「打印數量」「打印機」
「對料狀態」:「對料待處理」/「對料已完成」
提示語例:「工單已完成提料和對料」
3.5 提料常見錯誤
提示處理「此批次已拒收,請掃描另一個批次。」換批「掃描的批次已被其他用戶完全提料。請掃描其他批次。」換可用批「物品數量不足」/數量大於需求/可用量改「提交數量」「請先選擇打印機」列印版頭紙前先選機四、工單生產流程(/productionProcess)
4.1 入口與頂層分頁
側欄「管理工單」--> 「工單生產流程」。
Tab文案0「工藝流程」1「待QC上架工單」2「已上架工單」3 - 6各類「儀表板 - ...」4.2 「工藝流程」卡片動作
按鈕用途「查看詳情」進工序/BOM/對料等「工單對料」提料完成後二次掃批號確認「完成工單」整張 JO 完工(確認:「確認要完成此工單嗎?」;權限常限 ADMIN)「品檢」開品檢 Modal(條件滿足且有入庫行時才出現)詳情內 Tabs:「工單信息」「BOM 材料」「工藝流程」「工藝明細」「工單對料」;另有「返回列表」「取消工單」「刪除工單」等。
4.3 工單對料(二次掃)
點「工單對料」。
對已提料批再掃「批號QR碼掃描」(或「手動輸入」)。
「驗證成功!」後,全部核對完點 「確認所有提料」。
「返回列表」可能解除指派,勿中途亂退。
4.4 工序報工(單步)
「查看詳情」-->「工藝流程」/「工藝明細」。
待處理工序點 「開始」 --> Dialog「掃描操作員和設備」。
「開始掃碼」:先操作員/員工,再設備 -->「提交並開始」。
執行中可「暫停」/「繼續」(「暫停原因」);結束該步用 「訂單完成」(填「工序產出」「不良品」「損耗」)。
若允許略過執行:按鈕「已完成」(Just Pass),確認「確認要通過此工序嗎?」。
勿混淆: 工序「訂單完成」=結束一步;卡片「完成工單」=結束整張工單。
4.5 品檢
條件滿足後點「品檢」。
Modal 常見 Tab:「處理來貨及品檢」/「來貨及品檢詳情」。
填結果後 「確定品檢結果」。
可「打印機」「列印數量」「列印」「下載QR碼」(供之後上架掃碼)。
校驗例:「請決定品檢結果」「有未完成品檢項目」「請輸入不合格數量」「請輸入到期日!」。
亦可從 Tab「待QC上架工單」或提醒鈴深連結進入。五、上架掃碼(/putAway)
5.1 入口
側欄「倉庫管理」--> 「上架掃碼」;頁標題「上架」。
5.2 兩段掃碼
待機:「等待掃瞄中,請掃瞄貨品二維碼開始上架程序」。
掃貨品/來貨行 QR --> 開 Modal。
填「是次上架數量」;再掃倉庫 QR(「掃瞄倉庫二維碼」/「請掃瞄倉庫二維碼」)。
點 「確定及上架貨物」。
可在「是次上架記錄」核對。
失敗:「讀取不成功,請重新掃瞄」;數量:「上架數量不得大於 ...」「最小為1」等。入庫行狀態語意:「待上架」-->「已部分上架」-->「已上架」。完成後可在「已上架工單」看到。六、狀態對照(操作頁為準)
代碼畫面planning「規劃中」pending「待處理」packaging / picking「提料中」processing「生產中」pendingQC「待品檢」storing「待品檢入倉」completed「已完成」cancelled「已取消」工序行:「待處理」-->「進行中」-->(可「已暫停」)-->「完成」/「已完成」(Pass)。
七、常見問題速查
情況建議提料頁找不到單確認已「放單」;日期/樓層/品類篩選是否過窄對料按鈕灰/沒有提料單未完成、已指派他人、或對料已完成「完成工單」按不到權限或工序未齊;確認提示「確認要完成此工單嗎?」沒有「品檢」按鈕尚未完成工單/無 stock-in 行上架掃不到先品檢並列印/下載 QR;或用 ?stockInLineId= 深連結掃碼驗證失敗換批、確認未拒收、確認單位/可用量八、路徑速查
畫面路徑搜索/放單/jo工單提料/jodetail工單生產流程/productionProcess上架掃碼/putAway

+ 67
- 0
docs/exports/MTMS_M18_DATA_MAPPING.docx Datei anzeigen

@@ -0,0 +1,67 @@
MTMS (FPSMS) <--> M18 資料對照手冊
本文件說明 MTMS / FPSMS 與 M18 之間的主檔與交易對應、同步方向,以及已知陷阱。區塊維護方式本手冊(說明、流程、陷阱)人手維護docs/generated/ 對照表腳本產生(見下方)重新產生自動表(改完 enum / mapping 後請跑):
python scripts/generate_m18_mapping_docs.py1. 系統與名詞
名稱說明MTMS / FPSMS本後端 FPSMS-backend + 前端 FPSMS-frontendM18外部 ERP/主檔與採購/送貨來源系統PullM18 --> MTMS(product / vendor / unit / currency / BOM / business unit / PO / DO)PushMTMS --> M18(例如 GRN、BOM for shop)設定入口:m18/M18Config.kt(m18.config.)、scheduler 見 application.yml / application-prod.yml(scheduler.m18Sync、scheduler.m18Grn)。
主程式目錄:src/main/java/com/ffii/fpsms/m18/。2. Master API 類型(StSearchType)
完整表見自動產生檔:--> generated/m18-stsearch-types.md摘要:M18 stSearchMTMS 落點proitemsvenshop(type=supplier)virDeptshop(type=shop)unituom_conversion(+ cunit)curcurrencyudfbomforshopbom / materials實作:M18MasterDataService。
3. 貨品類型(最常查)
3.1 同步規則(自動表)
--> generated/m18-item-type-mapping.md程式:M18MasterDataService.saveProduct / saveProducts 依 pro.udfProducttype:Consumable Material --> consumables
Non-consumable Material --> non-consumables
Product --> fg
WIP --> sfg
Item --> item
(其他,含 CMB) --> mat <-- defaultEnum 定義:modules/master/web/models/NewItemRequest.kt(ItemType、M18ItemType)。3.2 UI 顯示(存貨)
存貨 Type 欄:t(itemType),翻譯在 FPSMS-frontend/src/i18n/zh/inventory.json。items.type存貨頁(zh)mat原料fg成品sfg / wip半成品consumables / cmb消耗品non-consumables / nm非消耗品/雜項 系統 沒有「產品」這個 items.type。M18 的 Product 對應 MTMS fg(成品)。
3.3 可手動改嗎?
可以:Settings --> Items --> Edit --> Type(ProductDetails.tsx:fg / wip / mat / cmb / nm)。注意:之後若再跑 product sync,type 會依 M18 udfProducttype 覆寫(含再次落到 mat)。3.4 已知陷阱:CMB
M18 實務上可出現 "udfProducttype": "CMB"(例如蔗糖水 MG1852)。"CMB" != "Consumable Material"
也不等於前端的 cmb
--> sync 走 else --> mat --> 存貨顯示 原料
若要顯示消耗品:需改 mapping(例如把 CMB 對到 consumables),或在 M18 改成已支援的字串;僅手動改 MTMS 可能被下次 sync 蓋掉。4. 供應商與店鋪
方向M18MTMSPull vendorsvenshop,ShopType.SUPPLIER(supplier)Pull business unitsvirDeptshop,ShopType.SHOP(shop)鍵:shop.m18Id、shop.code。名稱優先 descZhTW --> descZhCN --> desc。
ShopType:modules/master/enums/ShopType.kt。5. 單位(UoM)
M18MTMSUnit master (unit)uom_conversion(code、udfudesc、udfShortDesc、m18Id...)Cunit 明細M18CunitService.replaceForUnitItem 級採購/庫存/銷售單位在 sync product price 時寫入 item_uom(見 M18MasterDataService product 區塊)。
PO/DO 行常同時保留:欄位意義qty / uomIdMTMS 業務單位(例如採購單位換算後)qtyM18 / uomIdM18M18 原始單位數量PO 換算邏輯見 M18PurchaseOrderService(convertQtyToPurchaseQty)。
6. 貨幣、BOM
M18MTMSServiceCurrencycurrencysaveCurrenciesBOM (udfbomforshop)bom / bom_materialsaveBomsShop BOM 回寫 M18:M18BomForShopService(push)。
7. 交易文件(摘要)
文件方向MTMS 主表筆記POM18 --> MTMSpurchase_order / purchase_order_linem18Id / data log;qty 可能換算DOM18 --> MTMSdelivery_order / delivery_order_line含 qtyM18、uomIdM18GRNMTMS --> M18stock-in --> M18 GRN API部分 m18CreatedUId 不送 GRN(見下)GRN 略過規則
m18/M18GrnRules.kt:M18 PO createUid備註行為2569legato不 post GRN2676xtech不 post GRN8. Config 鍵(對照時常用)
見 M18Config / application-.yml:m18.config.seriesId.pp|pf|sc|se|sf|sr
m18.config.beId.pp|pf|toa
m18.config.supplier-not.material-po
m18.config.supplier.shop-po / oem-po
scheduler.m18Sync.enabled
scheduler.m18Grn.createEnabled
9. 驗證用 SQL 範例
-- 某貨品目前 type(決定存貨顯示)
SELECT code, name, type, m18Id, m18LastModifyDate
FROM items
WHERE deleted = 0 AND code = 'MG1852';

-- 統計 type 分佈
SELECT type, COUNT(*) AS cnt
FROM items
WHERE deleted = 0
GROUP BY type
ORDER BY cnt DESC;若 M18 回傳 udfProducttype 可與上表比對;對不上表中「exact string」者皆會變 mat。10. 維護約定
改 mapping:先改 Kotlin enum / when,再跑 python scripts/generate_m18_mapping_docs.py,把 docs/generated/ 一併 commit。
改說明/陷阱:只改本檔,勿手改 docs/generated/。
新發現的 M18 值(如新的 udfProducttype):記入 generated 腳本的 KNOWN_UNMAPPED_M18_VALUES,或補正式 mapping 後重生。
PR 若動到 NewItemRequest.kt / M18MasterDataService product type 分支,review 應檢查 generated docs 是否已更新。
11. 相關程式索引
主題路徑Item / M18 type enumsmodules/master/web/models/NewItemRequest.ktProduct syncm18/service/M18MasterDataService.ktStSearchm18/model/M18MasterDataRequest.ktShop typemodules/master/enums/ShopType.ktGRN skipm18/M18GrnRules.ktPO syncm18/service/M18PurchaseOrderService.ktDO syncm18/service/M18DeliveryOrderService.ktBOM-->M18m18/service/M18BomForShopService.kt存貨 Type 顯示InventoryTable.tsx + i18n/zh/inventory.json物品 Type 下拉CreateItem/ProductDetails.tsx
M18 udfProducttype --> MTMS items.type
_Generated: 2026-08-01 10:01 UTC_Source of truthEnums: NewItemRequest.kt --> ItemType, M18ItemType
Sync: M18MasterDataService.saveProduct / saveProducts (when (pro.udfProducttype))
UI labels (inventory): FPSMS-frontend/src/i18n/zh/inventory.json
Sync mapping
M18 udfProducttype (exact string)M18ItemTypeMTMS items.typeItemTypeInventory UI (zh)Consumable MaterialCONSUMABLESconsumablesCONSUMABLES消耗品Non-consumable MaterialNONCONSUMABLESnon-consumablesNONCONSUMABLES非消耗品ProductFGfgFG成品WIPSFGsfgSFG半成品ItemITEMitemITEM貨品(any other value / empty) -- matMATERIAL原料Enum inventories
M18ItemType
ConstantString valueUsed in sync when?CONSUMABLESConsumable MaterialyesNONCONSUMABLESNon-consumable MaterialyesFGProductyesSFGWIPyesITEMItemyesItemType (MTMS stored values)
Constantitems.typeInventory UI (zh)MATERIALmat原料CONSUMABLESconsumables消耗品NONCONSUMABLESnon-consumables非消耗品FGfg成品SFGsfg半成品ITEMitem貨品Known gaps (not auto-mapped)
M18 value seenEffectNotesCMB--> mat (else)Seen on M18 pro.udfProducttype (e.g. MG1852). Falls through to mat.Frontend Settings --> Items edit also offers cmb / wip / nm as local types;
those are not written by the current M18 udfProducttype mapper.Regenerate
python scripts/generate_m18_mapping_docs.pyM18 StSearchType (master list APIs)
_Generated: 2026-08-01 10:01 UTC_Source: m18/model/M18MasterDataRequest.ktConstantstSearch valueTypical MTMS sync targetPRODUCTproitems (+ item_uom via prices)VENDORvenshop (type=supplier)CUSTOMERcus(enum present; sync usage varies)UNITunituom_conversion (+ m18 cunit)CURRENCYcurcurrencyBOMudfbomforshopbom / bom_material (udfbomforshop)BUSINESS_UNITvirDeptshop (type=shop)

BIN
docs/exports/MTMS_M18_DATA_MAPPING.xlsx Datei anzeigen


+ 177
- 0
docs/exports/MTMS_Schedule_JobOrder_UserGuide.docx Datei anzeigen

@@ -0,0 +1,177 @@
MTMS 使用說明:排程 --> 開工單
本手冊依目前前端畫面與繁中文案整理,按鈕/選單名稱以畫面上「」內文字為準。
適用範圍:側欄「排程」、管理工單(搜索/建立、提料、生產流程)。
「一附」章節使用本機資料庫 fpsmsdb 的真實例子(查詢當下快照);其他環境請改日期再對。
產生日期:依原始碼現況(若畫面改版,請以實際 UI 為準)。
這份手冊怎麼用
符號意思「......」畫面上看得到的按鈕、選單、標題-->下一步操作節點流程中的一個階段(狀態/畫面)建議閱讀順序:
先排期(側欄「排程」)
再放單/建工單(「管理工單」-->「搜索工單/ 建立工單」)
提料 --> 生產 --> 完成
一、整體流程(從排期到完工)
【排程】預測/查看排期
↓ 「自動生成工單」 或 手動「建立工單」
【規劃中】工單已建立、尚未放單
↓ 「放單」
【待提料/提料中】
↓ 「工單提料」掃碼提交
【生產中】「工單生產流程」各工序開始/完成
↓ 「完成工單」
【品檢/上架】「待QC上架工單」-->「已上架工單」也可不經排程,在「搜索工單/ 建立工單」直接「建立工單」(手動工單)。一附、本地資料庫實例(方便對照畫面)
以下數字來自本機 fpsmsdb 查詢結果,用於說明「畫面上大概會看到什麼」。
不同環境/日期資料會不同;請用「搜索」條件改成你們當天日期再核對。
狀態中文依前端翻譯:planning-->「規劃中」、pending-->「待處理」、packaging-->「提料中」、processing-->「生產中」、storing-->「待品檢入倉」、completed-->「已完成」。
例 A:已有排期、尚未產工單(適合練習「詳細」-->「自動生成工單」)
在「生產排程」用「生產日期」搜 2026-08-03,本機有一筆細排(production_schedule.id = 928,type = detailed):畫面概念本機資料「生產日期」2026-08-03「預計生產數」(約)17,334「成品款數」(約)47打開「詳細」後,明細列會類似(節錄):
「編號」「名稱」約「需求數量」約「存貨量」約需工單數PP1175鮮檸檬汁(P+4)1,4061,40074PP1224柚子蒜蓉汁140501PP0259牛肉水(2KG/包)415141PP2284油醋汁(1KG/包)28321PP2390熱情香果醬12164怎麼練:
「排程」--> 生產日期選 2026-08-03 -->「搜索」-->「詳細」。
對照上表成品是否出現在明細。
若環境允許,再試「自動生成工單」(會真正建 JO,請在測試庫操作)。
查詢當下:此排期尚未有透過 prodScheduleLineId 掛上的工單(適合示範「產工單前」)。
例 B:排期已產工單且已完成(歷史成功路徑)
本機較早一筆:2026-06-17 細排(約預計生產 17,421、成品款數 33),曾產生多張 type = detailed 工單,例如:「工單編號」「狀態」「需求數量」成品JO-260617-008已完成568PP1234 日式咖哩汁JO-260617-012已完成15PP2288 香水檸檬汁P+3JO-260617-015已完成600PP1136 白粥JO-260617-024已完成28PP2284 油醋汁(1KG/包)JO-260617-030已完成508PP2290 韓式豬軟骨怎麼練:
「搜索工單/ 建立工單」-->「預計生產日期」填 2026-06-17 -->「搜索」。
找上表工單編號,點「查看」看已完成工單長怎樣。
對照:這類工單來自排程 release(type 在庫為 detailed),不是手動「建立工單」的 manual。
例 C:手動「建立工單」(不經排程)
本機 2026-07-29 工單幾乎皆為手動(type = manual,且未掛排期行)。例子:「工單編號」「狀態」「需求數量」成品JO-260729-045待處理3PP2390 熱情香果醬JO-260729-038待處理51PP2277 烚意粉JO-260729-034生產中1PP2302 酸甜蘿蔔粒箱料粉JO-260729-044提料中265PP2383 辣椒菜脯怎麼練:
「預計生產日期」選 2026-07-29 -->「搜索」。
用「狀態」篩「待處理」--> 應能看到類似 JO-260729-045。
若該單仍「規劃中」,可練習「放單」;若已是「待處理」,可接「工單提料」。
同日狀態分佈(本機快照):約 17 張「待處理」、24 張「提料中」、4 張「生產中」 -- -- 正好對應「放單後 --> 提料 --> 生產」不同節點。例 D:近兩週工單狀態分佈(看流程卡在哪)
本機最近約 14 天(未隱藏工單)概況:「狀態」約筆數使用者下一步常做什麼已完成359可當完成範本「查看」待處理42「工單提料」提料中37繼續掃碼/提交提料待品檢入倉33「工單生產流程」品檢/上架生產中4「工單生產流程」繼續工序例 E:還在「規劃中」的單(適合練刪除/放單)
本機仍有例如:JO-260427-039(沙薑醬 PP2205,需求約 291,「規劃中」)。若只需練習「查看」--> 看「放單」「刪除工單」按鈕是否出現。
勿在正式/共用庫隨意刪除;測試庫才建議真的按「刪除工單」。
自行查核用 SQL(選用)
-- 某日排期摘要
SELECT id, DATE(produceAt) AS produce_date, type,
totalEstProdCount, totalFGType
FROM production_schedule
WHERE deleted = 0 AND DATE(produceAt) = '2026-08-03';

-- 該排期成品明細(前 20)
SELECT i.code, i.name, psl.prodQty, psl.stockQty, psl.needNoOfJobOrder
FROM production_schedule_line psl
JOIN production_schedule ps ON ps.id = psl.prodScheduleId
JOIN items i ON i.id = psl.itemId
WHERE ps.deleted = 0 AND psl.deleted = 0
AND DATE(ps.produceAt) = '2026-08-03'
ORDER BY psl.itemPriority, i.code
LIMIT 20;

-- 某日工單+狀態
SELECT code, status, type, reqQty, DATE(planStart) AS plan_date
FROM job_order
WHERE deleted = 0 AND (isHidden = 0 OR isHidden IS NULL)
AND DATE(planStart) = '2026-07-29'
ORDER BY status, code;二、排程(側欄「排程」)
2.1 入口
側欄點 「排程」
進入頁面標題:「生產排程」(路徑通常為 /ps)
說明:系統另有舊版「需求預測」「詳細排程」頁(/scheduling/...),目前側欄主入口是「排程」-->「生產排程」。以下以主入口為準。
2.2 畫面上常見按鈕
按鈕/功能用途(白話)「預測排期」依日期/天數計算產生預計排期「搜索」依「生產日期」等條件查已有排期「詳細」打開該筆排期的明細「自動生成工單」依此排期一次產生多張工單(在詳情裡)「關閉」關閉詳情視窗「排期設定」庫存/排期相關設定與匯入匯出「匯出計劃/物料需求Excel」匯出計劃與物料需求「匯出送貨單數量」匯出送貨單數量區間資料列表常見欄位:「生產日期」「預計生產數」「成品款數」等。
2.3 操作步驟:做出排期(Happy path)
進入 「排程」 --> 「生產排程」。
點 「預測排期」。
在對話框 「準備生成預計排期」 中填:
「開始日期」
「排期日數」
點 「計算預測排期」。
成功時畫面會提示類似 「成功計算排期!」;失敗會提示計算錯誤或不明狀況。
選擇「生產日期」後點 「搜索」,在列表找到該日排期。
點該列 「詳細」,打開 「排期詳細」。
確認內容無誤後,點 「自動生成工單」 --> 系統依排期建立工單。
點 「關閉」 結束。
2.4 節點說明(排程)
節點使用者在做什麼下一個常見動作尚未有排期進「生產排程」但列表空/無當日資料「預測排期」已有排期列表用「搜索」找日期「詳細」排期詳細已打開檢查預計生產內容「自動生成工單」已生成工單工單出現在「搜索工單/ 建立工單」去「放單」2.5 排程常見問題
情況建議處理「計算預測排期」失敗記下畫面錯誤訊息;檢查開始日期/天數;稍後再試或聯絡系統/IT「自動生成工單」失敗畫面可能顯示失敗訊息;確認排期內容是否完整、BOM/物料是否齊全找不到某日排期確認「生產日期」與「搜索」條件;必要時再跑一次「預測排期」想改數量再開工單主入口 /ps 詳情偏「一次自動生成」;若需逐行改量/發佈,需使用舊版「詳細排程」編輯頁(見附錄)2.6 附錄:舊版「詳細排程」/「需求預測」(進階)
若單位仍使用直連網址:畫面標題(約)重點按鈕需求預測列表「需求預測」「測試粗排」「搜索」「詳情」需求預測詳情「成品及物料需求預測詳情」多為檢視(依成品/依物料、「查看 BOM」)詳細排程列表「詳細排程」「詳細排程」(產生)、「匯出排程」「詳情」FG 生產排程「成品生產排程」/「FG 生產排程」列「發佈」、改「需求數量」後儲存、「生成工單」、「返回」注意(舊版細排詳情):
「生成工單」 常僅允許生產日期為今天;否則可能跳出英文提示(畫面未必有完整中文翻譯)。
前端沒有「從某一筆需求預測一鍵跳到對應詳細排程」的按鈕;兩條線在畫面上是分開的。
設定選單另有 「需求預測設定」(成品排除日、星期等),屬主檔設定,不是每日排期操作。三、工單:搜索/建立/放單
3.1 入口
側欄 「管理工單」 --> 「搜索工單/ 建立工單」頁面標題:「搜索工單/建立工單」3.2 畫面上常見按鈕
按鈕用途「建立工單」手動開一張新工單「放單」/「放單 (N)」將「規劃中」工單放出,進入後續提料「重置」清空搜尋條件「查看」開「工單詳情」「取消工單」取消後工單從列表隱藏(非規劃中等情況)搜尋條件常見:「工單編號」「成品/半成品名稱」「預計生產日期」~「預計生產日期至」「工單類型」「狀態」。
3.3 操作步驟:手動建立工單
點 「建立工單」,打開標題為 「建立工單」 的視窗。
填寫:
「BOM」
「標準生產數」(通常唯讀)
「批數」
「需求數量」(常由標準x批數自動帶出)
「工單類型」(選 BOM 後可能自動對應)
「生產優先序」(常見預設約 50,範圍約 1 - 100)
「預計生產日期」
可勾選「記住為預設日期」
點 「建立」。
成功後通常提示資料已更新;新工單多為 「規劃中」 狀態。
在列表勾選/找到該工單,點 「放單」(或上方「放單 (N)」批量)。
3.4 操作步驟:從排程來的工單
在「生產排程」詳情已按 「自動生成工單」(或舊版「生成工單」)。
到 「搜索工單/ 建立工單」,用「預計生產日期」等搜尋。
確認工單後執行 「放單」。
3.5 工單詳情(「查看」)
標題:「工單詳情」常見分頁/區塊:「工單信息」
「BOM 材料」
「工藝流程」/「工藝明細」
常見動作:按鈕何時可用(概念)說明「放單」多為「規劃中」放出工單「刪除工單」多為仍在「規劃中」確認後無法復原「取消工單」已非規劃中等確認後從列表隱藏;已上架通常不可取消「儲存」/「取消」編輯 Dialog存檔或放棄「返回列表」 -- 回搜尋頁庫存摘要常見字樣:「所需貨品項目數量:」「可提料項目數量:」「未能提料項目數量:」。
刪除確認:「確認刪除工單」
「確定要刪除此工單嗎?此操作無法復原。」
取消確認:「確認取消工單」
「確定要取消此工單嗎?工單將從列表中隱藏。」
按鈕:「取消工單」/「取消」(後者為放棄這次取消操作)
3.6 工單狀態(搜尋/列表常見)
畫面上可能出現(實際以該列顯示為準):狀態(常見中文)白話「規劃中」已建立,尚未放單「待處理」/待提料相關已放單,等提料「提料中」/「進行中」正在提料或進行中「已開始工序」/「生產中」已進入生產工序「成品入倉中」/「待品檢入倉」生產後待品檢/入倉「已上架」已完成上架「已取消」已取消篩選下拉亦可能見:「待處理」「提料中」「已開始工序」「成品入倉中」「已上架」「已取消」等。
3.7 工單建立/放單常見問題
情況建議處理「建立」沒反應檢查 BOM、批數、日期、工單類型是否已填;看是否有必填未選「放單」失敗記下提示;檢查是否仍為可放單狀態、物料/權限想刪掉剛建錯的單「規劃中」-->「查看」-->「刪除工單」--> 確認已放單但不想做了「取消工單」--> 確認隱藏;已上架通常不能取消批量「放單 (N)」部分失敗依成功/失敗筆數訊息,對失敗單再個別處理四、工單提料
4.1 入口
「管理工單」 --> 「工單提料」4.2 常見分頁
「工單提料詳情」
「已完成工單記錄」
「物料提料狀態」
(及其他與膠茜/用量相關分頁,以畫面為準)
4.3 操作步驟(概念)
在「工單提料詳情」找到已放單、待提料的工單。
點 「查看詳情」 進入提料執行畫面。
依畫面使用 「開始掃碼」、掃描物料/批號。
需要時點 「提交」/「提交所有已掃描項目」/「確認」。
完成後可在「已完成工單記錄」核對。
對料相關:生產流程中可能有 「工單對料」,並有 「確認所有提料」。4.4 提料常見問題
情況建議處理批號不符依 Dialog 提示按「確認」,改掃正確批號庫存不足/過期依畫面提示;先補貨或調批號,勿強行提交掃錯要重來用畫面上的「取消」/清除已掃項目(以當頁按鈕為準)後重掃提料狀態常見:「待提料」「已掃碼」「對料待處理」「對料已完成」;提料單「已放單」「已完成」。
五、工單生產流程
5.1 入口
「管理工單」 --> 「工單生產流程」5.2 常見分頁/區塊
「工藝流程」
「待QC上架工單」
「已上架工單」
「儀表板 - 工單狀態」等
5.3 工序操作(Happy path)
在「工藝流程」找到工單,點 「查看詳情」。
(可選)「工單對料」 確認物料。
各工序:
「開始」 開工序
可依畫面掃 操作員/設備(未掃可能提示「請先掃描操作員編號」「請掃描設備編號」)
可 「暫停」
「完成步驟」/「通過」/「已完成」(Just Pass 等,以畫面為準)
確認通過時可能問:「確認要通過此工序嗎?」
全部就緒後點 「完成工單」,確認:「確認要完成此工單嗎?」
需要時做 「品檢」,再到「待QC上架工單」完成上架,最後在「已上架工單」可見。
其他可能按鈕:「提交並開始」「提交包裝袋消耗」等。5.4 工序狀態(常見)
「待處理」「進行中」「已暫停」「完成」/「已完成」「通過」「未開始」「已停止」「已取消」等。5.5 生產常見問題
情況建議處理「完成工單」按不了通常尚有工序未完成或條件未滿足;先完成各步驟掃碼驗證失敗「驗證失敗. 請檢查操作員和設備.」--> 重掃正確編號API/系統錯誤「發生錯誤,請稍後再試。」--> 稍後重試或聯絡支援做到一半要停用「暫停」;勿與「取消工單」混淆(取消是整張工單層級)六、取消、刪除、返回 -- -- 怎麼選?
你想做的事建議用哪個按鈕結果建錯、還在規劃中,不要了「刪除工單」刪除,不可復原已放單/進行中,不要繼續「取消工單」確認後從列表隱藏只是關掉視窗、不改資料「關閉」/「取消」/「返回」「返回列表」不取消工單本身工序暫停一下「暫停」工單仍在,之後可再「開始」七、快速檢查清單(每日)
「排程」--> 需要時「預測排期」-->「搜索」-->「詳細」-->「自動生成工單」
「搜索工單/ 建立工單」--> 核對當日工單 -->「放單」
「工單提料」--> 掃碼提交
「工單生產流程」--> 各工序開始/完成 -->「完成工單」--> 品檢/上架
手動補單:同頁「建立工單」-->「建立」-->「放單」。八、文件維護說明(給內部)
文案來源:前端 i18n/zh/navigation.json、jo.json、schedule.json、productionProcess.json,以及「生產排程」頁硬編碼繁中。
主程式入口:
排程:FPSMS-frontend/src/app/(main)/ps/page.tsx
工單搜尋/建立:.../jo/page.tsx、JoWorkbenchSearch、JoCreateFormModal
提料:.../jodetail/
生產:.../productionProcess/
若按鈕改名或流程改版,請同步改本手冊,並重新匯出 Word。
重新匯出 Word(需已安裝套件):pip install python-docx
python scripts/export_user_guide_office.py產出:docs/exports/MTMS_Schedule_JobOrder_UserGuide.docx

+ 60
- 0
docs/generated/m18-item-type-mapping.md Datei anzeigen

@@ -0,0 +1,60 @@
<!-- AUTO-GENERATED by scripts/generate_m18_mapping_docs.py — do not edit by hand -->

# M18 `udfProducttype` → MTMS `items.type`

_Generated: 2026-08-01 10:01 UTC_

**Source of truth**

- Enums: `NewItemRequest.kt` → `ItemType`, `M18ItemType`
- Sync: `M18MasterDataService.saveProduct` / `saveProducts` (`when (pro.udfProducttype)`)
- UI labels (inventory): `FPSMS-frontend/src/i18n/zh/inventory.json`

## Sync mapping

| M18 `udfProducttype` (exact string) | `M18ItemType` | MTMS `items.type` | `ItemType` | Inventory UI (zh) |
|---|---|---|---|---|
| `Consumable Material` | `CONSUMABLES` | `consumables` | `CONSUMABLES` | 消耗品 |
| `Non-consumable Material` | `NONCONSUMABLES` | `non-consumables` | `NONCONSUMABLES` | 非消耗品 |
| `Product` | `FG` | `fg` | `FG` | 成品 |
| `WIP` | `SFG` | `sfg` | `SFG` | 半成品 |
| `Item` | `ITEM` | `item` | `ITEM` | 貨品 |
| *(any other value / empty)* | — | `mat` | `MATERIAL` | 原料 |

## Enum inventories

### `M18ItemType`

| Constant | String value | Used in sync `when`? |
|---|---|---|
| `CONSUMABLES` | `Consumable Material` | yes |
| `NONCONSUMABLES` | `Non-consumable Material` | yes |
| `FG` | `Product` | yes |
| `SFG` | `WIP` | yes |
| `ITEM` | `Item` | yes |

### `ItemType` (MTMS stored values)

| Constant | `items.type` | Inventory UI (zh) |
|---|---|---|
| `MATERIAL` | `mat` | 原料 |
| `CONSUMABLES` | `consumables` | 消耗品 |
| `NONCONSUMABLES` | `non-consumables` | 非消耗品 |
| `FG` | `fg` | 成品 |
| `SFG` | `sfg` | 半成品 |
| `ITEM` | `item` | 貨品 |

## Known gaps (not auto-mapped)

| M18 value seen | Effect | Notes |
|---|---|---|
| `CMB` | → `mat` (else) | Seen on M18 pro.udfProducttype (e.g. MG1852). Falls through to mat. |

Frontend Settings → Items edit also offers `cmb` / `wip` / `nm` as local types;
those are **not** written by the current M18 `udfProducttype` mapper.

## Regenerate

```bash
python scripts/generate_m18_mapping_docs.py
```

+ 17
- 0
docs/generated/m18-stsearch-types.md Datei anzeigen

@@ -0,0 +1,17 @@
<!-- AUTO-GENERATED by scripts/generate_m18_mapping_docs.py — do not edit by hand -->

# M18 `StSearchType` (master list APIs)

_Generated: 2026-08-01 10:01 UTC_

**Source:** `m18/model/M18MasterDataRequest.kt`

| Constant | `stSearch` value | Typical MTMS sync target |
|---|---|---|
| `PRODUCT` | `pro` | items (+ item_uom via prices) |
| `VENDOR` | `ven` | shop (`type=supplier`) |
| `CUSTOMER` | `cus` | (enum present; sync usage varies) |
| `UNIT` | `unit` | uom_conversion (+ m18 cunit) |
| `CURRENCY` | `cur` | currency |
| `BOM` | `udfbomforshop` | bom / bom_material (udfbomforshop) |
| `BUSINESS_UNIT` | `virDept` | shop (`type=shop`) |

+ 256
- 0
docs/user-guides/MTMS_BOM_使用說明.md Datei anzeigen

@@ -0,0 +1,256 @@
# MTMS 使用說明:BOM(匯入/啟停/應用)

> 本手冊依目前前端畫面與繁中文案整理,**按鈕/選單名稱以畫面上「」內文字為準**。
> 適用範圍:側欄「設定」下的 BOM 相關頁、建立工單時選 BOM、排程「自動生成工單」。
> **「一附」章節**使用本機資料庫 `fpsmsdb` 的真實例子(查詢當下快照);其他環境請改條件再對。
> 產生日期:依原始碼現況(若畫面改版,請以實際 UI 為準)。

---

## 這份手冊怎麼用

| 符號 | 意思 |
|------|------|
| 「……」 | 畫面上看得到的按鈕、選單、標題 |
| → | 下一步操作 |

建議閱讀順序:

1. **先匯入/啟用 BOM**(「設定」→「匯入 BOM」)
2. **再檢查單位問題**(ADMIN:「BOM / 物料單位問題」)
3. **再建工單/排程產單**(選 active BOM)

---

## 一、整體流程(BOM 從主檔到工單)

```text
【匯入 BOM】Excel 上傳並檢查 →「確認匯入」
【BOM 明細】確認「啟用」/必要時「停用」
↓(選做)「BOM 權重得分」調整加權
↓(ADMIN)「BOM / 物料單位問題」巡檢
【建工單】「建立工單」選「BOM」
或【排程】「自動生成工單」(僅含具 BOM 的物料)
```

---

## 一附、本地資料庫實例(方便對照畫面)

> 以下數字來自本機 `fpsmsdb` 查詢結果。**不同環境/日期資料會不同**。
> BOM 狀態:`active`→「啟用」、`inactive`→「停用」。本機約有 **214** 筆啟用、**3** 筆停用。

### 例 A:啟用中的成品 BOM(適合在「BOM 明細」搜索)

| 「物品編號」 | 「物品名稱」 | 「BOM 狀態」 | 約材料筆數 |
|--------------|--------------|--------------|------------|
| PP2167 | 牛丼 | 啟用 | 10 |
| PP2277 | 烚意粉 | 啟用 | 3 |
| PP2390 | 熱情香果醬 | 啟用 | 4 |
| PP1224 | 柚子蒜蓉汁 | 啟用 | 5 |
| PP1136 | 白粥 | 啟用 | 9 |
| PP0259 | 牛肉水(2KG/包) | 啟用 | 0(無材料列,排程/提料前宜先確認) |

**怎麼練:**

1. 「設定」→「匯入 BOM」→ Tab「BOM 明細」。
2. 「物品編號」填 **PP2167** →「搜索」。
3. 載入後看「基本資訊」「材料清單」「製程與設備」;「BOM 狀態」應為「啟用」。

### 例 B:材料清單長什麼樣(PP2167 牛丼)

| 「物品編號」 | 「物品名稱」 | 約「基本數量」 |
|--------------|--------------|----------------|
| MA0471 | 日式超薄肥牛 | 272.16 |
| FA0164 | 洋蔥絲 | 145.15 |
| GI3236 | 清水 | 60.00 |
| PP2214 | 丼飯汁(2磅/包) | 41.73 |
| MG1507 | 江泉牌燒肉汁(1.8L/支) | 9.00 |
| MG1299 | 李錦記特選老抽(8L/桶) | 8.00 |
| MG0302 | 佛手牌味粉(10lb/罐) | 4.54 |
| MG0239 | 韓國雪白幼砂糖(30kg/包) | 4.54 |

### 例 C:用 BOM 建工單(接《排程與工單》手冊)

本機 **2026-07-29** 手動工單多掛在 active BOM 上,例如:

| 「工單編號」 | 「狀態」 | 成品 BOM | 「需求數量」 |
|--------------|----------|----------|--------------|
| JO-260729-038 | 待處理 | PP2277 烚意粉 | 51 |
| JO-260729-044 | 提料中 | PP2383 辣椒菜脯 | 265 |

**怎麼練:**「搜索工單/ 建立工單」→「建立工單」→「BOM」下拉選 **PP2277**(僅顯示啟用中的 BOM)。

### 自行查核用 SQL(選用)

```sql
SELECT b.code, i.name, b.status,
(SELECT COUNT(*) FROM bom_material bm
WHERE bm.bomId = b.id AND IFNULL(bm.deleted,0)=0) AS mats
FROM bom b
JOIN items i ON i.id = b.itemId
WHERE b.deleted = 0 AND b.code = 'PP2167';

SELECT i.code, i.name, bm.qty
FROM bom_material bm
JOIN items i ON i.id = bm.itemId
JOIN bom b ON b.id = bm.bomId
WHERE b.code = 'PP2167' AND IFNULL(bm.deleted,0)=0;
```

---

## 二、匯入 BOM(「設定」→「匯入 BOM」)

### 2.1 入口

- 側欄 **「設定」** → **「匯入 BOM」**(路徑通常為 `/settings/importBom`)
- 頁內標題可能顯示英文 **Import BOM**;側欄與 Tab 以繁中「匯入 BOM」為準。

### 2.2 兩個分頁

| Tab | 用途 |
|-----|------|
| 「匯入 BOM」 | 上傳 Excel、檢查、確認寫入 |
| 「BOM 明細」 | 查已有 BOM、啟用/停用 |

### 2.3 操作步驟:上傳並匯入

1. 開 Tab **「匯入 BOM」**。
2. **「選擇 BOM Excel 檔案」** →「選擇檔案」或「選擇資料夾」(可多選 `.xlsx`)。
3. 確認「已選 N 個檔案」後,點 **「上傳並檢查」**。
- 進行中:「上傳與檢查中…」、進度「已檢查 x / y 個檔案…」。
4. 檢視結果:
- 「正確 BOM 列表(可匯入)」
- 「問題 BOM 列表」
5. 必要時勾選類型相關選項(如「飲料」「箱料粉」等)或用「搜索檔名」篩選。
6. 確認無誤後點 **「確認匯入」**。
7. 成功提示類似 **「匯入完成」**;亦可先「下載檢查結果 Excel」留底。
8. 若要重來:點「返回重選檔案」。

### 2.4 常見錯誤(匯入)

| 畫面提示 | 建議處理 |
|----------|----------|
| 「請至少選擇一個 .xlsx 檔案」 | 先選檔再上傳 |
| 「上傳或檢查失敗,請稍後再試。」/伺服器 500 | 檢查網路/檔案格式後重試 |
| 檔名重複相關提示(_2、_3…) | 依提示整理檔名後重傳 |
| 「匯入失敗,請查看主控台。」 | 記下失敗檔,修正 Excel 後只重傳問題檔 |

---

## 三、BOM 明細:查詢與啟停

### 3.1 搜索

1. Tab **「BOM 明細」**。
2. 「物品編號」/「物品名稱」→「搜索」/「重置」。
3. 多筆時:「找到多筆 BOM,請選擇一筆載入明細」(按鈕上會標「成品|半成品」「啟用|停用」)。

### 3.2 可改什麼

| 區塊 | 說明 |
|------|------|
| 「BOM 狀態」 | 「啟用」/「停用」→「儲存狀態」 |
| 「基本資訊」 | 產出數量、類型、過敏原、色深/浮沉/濃淡、時段、複雜度、基礎得分等(多為檢視) |
| 「材料清單」 | 物品編號/名稱、基本/庫存/銷售數量與單位 |
| 「製程與設備」 | 製程檢視 |

> **注意:** 完整欄位線上「編輯/儲存」目前前端關閉(`SHOW_BOM_FULL_EDIT = false`)。日常維護以 **Excel 匯入** 與 **啟用/停用** 為主。

### 3.3 何時「停用」

- 配方作廢、暫不允許再建工單時,將「BOM 狀態」改「停用」並「儲存狀態」。
- 「建立工單」的 BOM 下拉**只列出啟用**項目;停用後新單選不到該 BOM。

---

## 四、BOM 權重得分

### 4.1 入口

- 「設定」→ **「BOM 權重得分」**(`/settings/bomWeighting`)

### 4.2 分頁

| Tab | 用途 |
|-----|------|
| 「BOM 加權」 | 調整各評分項目的「權重」(總和須=1) |
| 「BOM得分」 | 查看各貨品「基礎得分」 |

### 4.3 操作

1. 「BOM 加權」→「編輯」→改「權重」→「儲存」。
2. 校驗失敗時:「權重必須為數字」或「權重總和必須等於 1(目前總和: x)」。
3. 成功:「更新成功(已重新計算 N 筆 BOM 基礎分)」。
4. 到「BOM得分」核對「貨品編號」「物品名稱」「基礎得分」。

---

## 五、BOM/物料單位問題(ADMIN)

### 5.1 入口

- 「設定」→ **「BOM / 物料單位問題」**(僅 **ADMIN**;`/settings/masterDataIssues`)
- 頁標題常顯示:「BOM/貨品單位問題」

### 5.2 操作

1. Tab「BOM」或「貨品」。
2. 「搜索」「類型」(「全部」「BOM 總表」「BOM 原材料」)。
3. 「重新檢查」更新清單;「複製清單」方便貼到表單/郵件。
4. 點列開詳情:「問題」「應為」「實際」→「關閉」。

### 5.3 常見問題文案(節錄)

- 「BOM 編號為空」「BOM 名稱為空」
- 「BOM 產出單位與成品銷售單位不一致」
- 「BOM 原料銷售/基本/庫存單位與貨品主檔不一致」
- 「BOM 編號與關聯貨品不一致」

側欄紅點例:`BOM N 筆 · 貨品 M 筆`。空狀態:「目前沒有問題。」

---

## 六、應用:建立工單時選 BOM

### 6.1 手動建立

1. 「管理工單」→「搜索工單/ 建立工單」→「建立工單」。
2. **「BOM」**(必填)選成品/半成品;同名時可能標「(成品)」「(半成品)」。
3. 「標準生產數」×「批數」=「需求數量」(單位來自 BOM 產出 UOM)。
4. 「預計生產日期」等填妥 →「建立」。
5. 成功常提示「成功更新資料」。未選 BOM 時可能出現「請選擇 BOM」/`Bom required!`。

### 6.2 排程自動生成

1. 「排程」→「生產排程」→「詳細」。
2. 「自動生成工單」(僅處理**具 BOM** 的物料;說明文案類似「選擇日期範圍(僅含具 BOM 的物料…)」)。
3. 舊細排頁另有「生成工單」「查看 BOM」(材料表含「編號」「名稱」「可用數量」「需求數量」)。

詳見《MTMS 排程與工單 使用說明》。

---

## 七、權限與路徑速查

| 畫面 | 路徑 | 備註 |
|------|------|------|
| 匯入 BOM | `/settings/importBom` | 「設定」下 |
| BOM 權重得分 | `/settings/bomWeighting` | |
| BOM/物料單位問題 | `/settings/masterDataIssues` | **ADMIN** |
| 建立工單 | `/jo` | 選 active BOM |
| 排程產單 | `/ps` | 需 BOM |

---

## 八、常見問題速查

| 情況 | 建議 |
|------|------|
| 建工單下拉找不到某成品 | 到「BOM 明細」確認是否「啟用」;或尚未匯入 |
| 排程產單跳過某物料 | 該物料可能無 BOM/不在排期 BOM 範圍 |
| 單位對不上、品檢/提料異常 | ADMIN 開「BOM / 物料單位問題」對照主檔後修正 Excel 再匯入 |
| 權重儲存失敗 | 確認各權重為數字且總和=1 |

+ 259
- 0
docs/user-guides/MTMS_工單提料報工上架_使用說明.md Datei anzeigen

@@ -0,0 +1,259 @@
# MTMS 使用說明:工單提料/報工/上架

> 本手冊依目前前端畫面與繁中文案整理,**按鈕/選單名稱以畫面上「」內文字為準**。
> 適用範圍:放單之後的「工單提料」「工單生產流程」「上架掃碼」(品檢/上架)。
> 建單與排程請先看《MTMS 排程與工單 使用說明》;BOM 請看《MTMS BOM 使用說明》。
> **「一附」章節**使用本機資料庫 `fpsmsdb` 的真實例子(查詢當下快照)。
> 產生日期:依原始碼現況(若畫面改版,請以實際 UI 為準)。

---

## 這份手冊怎麼用

| 符號 | 意思 |
|------|------|
| 「……」 | 畫面上看得到的按鈕、選單、標題 |
| → | 下一步操作 |

建議閱讀順序:

1. **放單**(「搜索工單/ 建立工單」)
2. **提料**(「工單提料」)
3. **對料 → 工序 → 完成工單**(「工單生產流程」)
4. **品檢 → 上架**(「品檢」+「上架掃碼」)

---

## 一、整體流程

```text
【/jo】「規劃中」──「放單」──►「待處理」/「提料中」
【/jodetail】「工單提料」──「查看詳情」──► 掃碼提料 ──「提交」/「提交所有已掃描項目」
【/productionProcess】「工單生產流程」
├─(可選)「工單對料」→ 二次掃碼 →「確認所有提料」
├─「查看詳情」→ 各工序「開始」/「訂單完成」或「已完成」(Just Pass)
「完成工單」(確認:「確認要完成此工單嗎?」)
「品檢」→「確定品檢結果」(可列印/下載 QR)
【/putAway】「上架掃碼」:掃貨品 QR → 掃倉庫 QR →「確定及上架貨物」
「已上架工單」;工單「已完成」
```

---

## 一附、本地資料庫實例

> 狀態中文:`pending`→「待處理」、`packaging`/`picking`→「提料中」、`processing`→「生產中」、`storing`→「待品檢入倉」、`completed`→「已完成」。

### 例 A:適合練提料(2026-07-29)

| 「工單編號」 | 「狀態」 | 成品 | 「需求數量」 | 建議下一步 |
|--------------|----------|------|--------------|------------|
| JO-260729-038 | 待處理 | PP2277 烚意粉 | 51 | 「工單提料」開單掃碼 |
| JO-260729-008 | 提料中 | PP2257 咖哩汁箱料粉 | 1 | 繼續提交提料 |
| JO-260729-013 | 提料中 | PP1043 豆豉汁(2磅/包) | 309 | 繼續提交提料 |
| JO-260729-044 | 提料中 | PP2383 辣椒菜脯 | 265 | 繼續提交提料 |
| JO-260729-034 | 生產中 | PP2302 酸甜蘿蔔粒箱料粉 | 1 | 「工單生產流程」做工序/完成工單 |

**怎麼練:**

1. 「搜索工單/ 建立工單」→「預計生產日期」**2026-07-29** →「搜索」。
2. 「狀態」篩「待處理」找 JO-260729-038,或直接到「工單提料」找同日卡片。
3. 「生產中」單到「工單生產流程」練「查看詳情」/「完成工單」(完成會改資料,請用測試庫)。

### 例 B:狀態與畫面入口對照

| 「狀態」 | 常用入口 |
|----------|----------|
| 待處理/提料中 | 「工單提料」 |
| 生產中 | 「工單生產流程」→「工藝流程」 |
| 待品檢/待品檢入倉 | 「工單生產流程」→「品檢」或「待QC上架工單」 |
| 已完成 | 「已上架工單」/工單搜索「已完成」 |

### 自行查核用 SQL(選用)

```sql
SELECT jo.code, jo.status, i.code, i.name, jo.reqQty
FROM job_order jo
LEFT JOIN bom b ON b.id = jo.bomId
LEFT JOIN items i ON i.id = b.itemId
WHERE jo.deleted = 0 AND DATE(jo.planStart) = '2026-07-29'
AND jo.status IN ('pending','packaging','processing','storing')
ORDER BY jo.status, jo.code
LIMIT 20;
```

---

## 二、放單(前置,在「搜索工單/ 建立工單」)

1. 側欄「管理工單」→「搜索工單/ 建立工單」(`/jo`)。
2. 找到「規劃中」工單 →「放單」或「放單 (N)」。
3. 放單後狀態變「待處理」/「提料中」,即可去「工單提料」。
4. 詳情內可見庫存摘要字樣如「可提料項目數量:」「未能提料項目數量:」。

> 亦可在生產流程詳情內對仍屬規劃中的單按「放單」。

---

## 三、工單提料(`/jodetail`)

### 3.1 入口與分頁

- 側欄「管理工單」→ **「工單提料」**;頁標題「工單提料」。

| Tab | 文案 |
|-----|------|
| 0 | 「工單提料詳情」 |
| 1 | 「已完成工單記錄」 |
| 2 | 「物料提料狀態」 |
| 3 | 「膠茜數目使用數量」 |

### 3.2 「工單提料詳情」列表

- 品類:「全部」「飲料」「箱料粉」「其他」
- 樓層:「2F」「3F」「4F」「沒有批號」等
- 卡片常見:「工單」「批號」「提料單」「物品名稱」「需求數量」、狀態 Chip
- 點 **「查看詳情」** 進入掃碼提料

### 3.3 掃碼提料步驟

1. 點「開始掃碼」(可「停止掃碼」)。
2. 掃描物料/批號 QR;必要時開「批號QR碼掃描」或「手動輸入」→「提交」。
3. 畫面上應出現「QR碼驗證成功。」/「驗證成功!」;進度見「掃碼結果」「提交數量」。
4. 單行「提交」或一次「提交所有已掃描項目」(進行中「提交中...」)。
5. 完成後「返回列表」。

### 3.4 「已完成工單記錄」

- 「查看詳情」「打印版頭紙」(2F/3F/4F)、「打印數量」「打印機」
- 「對料狀態」:「對料待處理」/「對料已完成」
- 提示語例:「工單已完成提料和對料」

### 3.5 提料常見錯誤

| 提示 | 處理 |
|------|------|
| 「此批次已拒收,請掃描另一個批次。」 | 換批 |
| 「掃描的批次已被其他用戶完全提料。請掃描其他批次。」 | 換可用批 |
| 「物品數量不足」/數量大於需求/可用量 | 改「提交數量」 |
| 「請先選擇打印機」 | 列印版頭紙前先選機 |

---

## 四、工單生產流程(`/productionProcess`)

### 4.1 入口與頂層分頁

- 側欄「管理工單」→ **「工單生產流程」**。

| Tab | 文案 |
|-----|------|
| 0 | 「工藝流程」 |
| 1 | 「待QC上架工單」 |
| 2 | 「已上架工單」 |
| 3–6 | 各類「儀表板 - …」 |

### 4.2 「工藝流程」卡片動作

| 按鈕 | 用途 |
|------|------|
| 「查看詳情」 | 進工序/BOM/對料等 |
| 「工單對料」 | 提料完成後二次掃批號確認 |
| 「完成工單」 | 整張 JO 完工(確認:「確認要完成此工單嗎?」;權限常限 ADMIN) |
| 「品檢」 | 開品檢 Modal(條件滿足且有入庫行時才出現) |

詳情內 Tabs:「工單信息」「BOM 材料」「工藝流程」「工藝明細」「工單對料」;另有「返回列表」「取消工單」「刪除工單」等。

### 4.3 工單對料(二次掃)

1. 點「工單對料」。
2. 對已提料批再掃「批號QR碼掃描」(或「手動輸入」)。
3. 「驗證成功!」後,全部核對完點 **「確認所有提料」**。
4. 「返回列表」可能解除指派,勿中途亂退。

### 4.4 工序報工(單步)

1. 「查看詳情」→「工藝流程」/「工藝明細」。
2. 待處理工序點 **「開始」** → Dialog「掃描操作員和設備」。
3. 「開始掃碼」:先操作員/員工,再設備 →「提交並開始」。
4. 執行中可「暫停」/「繼續」(「暫停原因」);結束該步用 **「訂單完成」**(填「工序產出」「不良品」「損耗」)。
5. 若允許略過執行:按鈕「已完成」(Just Pass),確認「確認要通過此工序嗎?」。

> **勿混淆:** 工序「訂單完成」=結束**一步**;卡片「完成工單」=結束**整張工單**。

### 4.5 品檢

1. 條件滿足後點「品檢」。
2. Modal 常見 Tab:「處理來貨及品檢」/「來貨及品檢詳情」。
3. 填結果後 **「確定品檢結果」**。
4. 可「打印機」「列印數量」「列印」「下載QR碼」(供之後上架掃碼)。
5. 校驗例:「請決定品檢結果」「有未完成品檢項目」「請輸入不合格數量」「請輸入到期日!」。

亦可從 Tab「待QC上架工單」或提醒鈴深連結進入。

---

## 五、上架掃碼(`/putAway`)

### 5.1 入口

- 側欄「倉庫管理」→ **「上架掃碼」**;頁標題「上架」。

### 5.2 兩段掃碼

1. 待機:「等待掃瞄中,請掃瞄貨品二維碼開始上架程序」。
2. 掃**貨品/來貨行** QR → 開 Modal。
3. 填「是次上架數量」;再掃**倉庫** QR(「掃瞄倉庫二維碼」/「請掃瞄倉庫二維碼」)。
4. 點 **「確定及上架貨物」**。
5. 可在「是次上架記錄」核對。

失敗:「讀取不成功,請重新掃瞄」;數量:「上架數量不得大於 …」「最小為1」等。

入庫行狀態語意:「待上架」→「已部分上架」→「已上架」。完成後可在「已上架工單」看到。

---

## 六、狀態對照(操作頁為準)

| 代碼 | 畫面 |
|------|------|
| planning | 「規劃中」 |
| pending | 「待處理」 |
| packaging / picking | 「提料中」 |
| processing | 「生產中」 |
| pendingQC | 「待品檢」 |
| storing | 「待品檢入倉」 |
| completed | 「已完成」 |
| cancelled | 「已取消」 |

工序行:「待處理」→「進行中」→(可「已暫停」)→「完成」/「已完成」(Pass)。

---

## 七、常見問題速查

| 情況 | 建議 |
|------|------|
| 提料頁找不到單 | 確認已「放單」;日期/樓層/品類篩選是否過窄 |
| 對料按鈕灰/沒有 | 提料單未完成、已指派他人、或對料已完成 |
| 「完成工單」按不到 | 權限或工序未齊;確認提示「確認要完成此工單嗎?」 |
| 沒有「品檢」按鈕 | 尚未完成工單/無 stock-in 行 |
| 上架掃不到 | 先品檢並列印/下載 QR;或用 `?stockInLineId=` 深連結 |
| 掃碼驗證失敗 | 換批、確認未拒收、確認單位/可用量 |

---

## 八、路徑速查

| 畫面 | 路徑 |
|------|------|
| 搜索/放單 | `/jo` |
| 工單提料 | `/jodetail` |
| 工單生產流程 | `/productionProcess` |
| 上架掃碼 | `/putAway` |

+ 459
- 0
docs/user-guides/MTMS_排程與工單_使用說明.md Datei anzeigen

@@ -0,0 +1,459 @@
# MTMS 使用說明:排程 → 開工單

> 本手冊依目前前端畫面與繁中文案整理,**按鈕/選單名稱以畫面上「」內文字為準**。
> 適用範圍:側欄「排程」、管理工單(搜索/建立、提料、生產流程)。
> **「一附」章節**使用本機資料庫 `fpsmsdb` 的真實例子(查詢當下快照);其他環境請改日期再對。
> 產生日期:依原始碼現況(若畫面改版,請以實際 UI 為準)。

---

## 這份手冊怎麼用

| 符號 | 意思 |
|------|------|
| 「……」 | 畫面上看得到的按鈕、選單、標題 |
| → | 下一步操作 |
| 節點 | 流程中的一個階段(狀態/畫面) |

建議閱讀順序:

1. **先排期**(側欄「排程」)
2. **再放單/建工單**(「管理工單」→「搜索工單/ 建立工單」)
3. **提料 → 生產 → 完成**

---

## 一、整體流程(從排期到完工)

```text
【排程】預測/查看排期
↓ 「自動生成工單」 或 手動「建立工單」
【規劃中】工單已建立、尚未放單
↓ 「放單」
【待提料/提料中】
↓ 「工單提料」掃碼提交
【生產中】「工單生產流程」各工序開始/完成
↓ 「完成工單」
【品檢/上架】「待QC上架工單」→「已上架工單」
```

也可**不經排程**,在「搜索工單/ 建立工單」直接「建立工單」(手動工單)。

---

## 一附、本地資料庫實例(方便對照畫面)

> 以下數字來自本機 `fpsmsdb` 查詢結果,用於說明「畫面上大概會看到什麼」。
> **不同環境/日期資料會不同**;請用「搜索」條件改成你們當天日期再核對。
> 狀態中文依前端翻譯:`planning`→「規劃中」、`pending`→「待處理」、`packaging`→「提料中」、`processing`→「生產中」、`storing`→「待品檢入倉」、`completed`→「已完成」。

### 例 A:已有排期、尚未產工單(適合練習「詳細」→「自動生成工單」)

在「生產排程」用「生產日期」搜 **2026-08-03**,本機有一筆細排(`production_schedule.id = 928`,`type = detailed`):

| 畫面概念 | 本機資料 |
|----------|----------|
| 「生產日期」 | 2026-08-03 |
| 「預計生產數」(約) | 17,334 |
| 「成品款數」(約) | 47 |

打開「詳細」後,明細列會類似(節錄):

| 「編號」 | 「名稱」 | 約「需求數量」 | 約「存貨量」 | 約需工單數 |
|----------|----------|----------------|--------------|------------|
| PP1175 | 鮮檸檬汁(P+4) | 1,406 | 1,400 | 74 |
| PP1224 | 柚子蒜蓉汁 | 140 | 50 | 1 |
| PP0259 | 牛肉水(2KG/包) | 41 | 51 | 41 |
| PP2284 | 油醋汁(1KG/包) | 28 | 32 | 1 |
| PP2390 | 熱情香果醬 | 12 | 16 | 4 |

**怎麼練:**

1. 「排程」→ 生產日期選 **2026-08-03** →「搜索」→「詳細」。
2. 對照上表成品是否出現在明細。
3. 若環境允許,再試「自動生成工單」(會真正建 JO,請在測試庫操作)。

> 查詢當下:此排期**尚未**有透過 `prodScheduleLineId` 掛上的工單(適合示範「產工單前」)。

### 例 B:排期已產工單且已完成(歷史成功路徑)

本機較早一筆:**2026-06-17** 細排(約預計生產 17,421、成品款數 33),曾產生多張 `type = detailed` 工單,例如:

| 「工單編號」 | 「狀態」 | 「需求數量」 | 成品 |
|--------------|----------|--------------|------|
| JO-260617-008 | 已完成 | 568 | PP1234 日式咖哩汁 |
| JO-260617-012 | 已完成 | 15 | PP2288 香水檸檬汁P+3 |
| JO-260617-015 | 已完成 | 600 | PP1136 白粥 |
| JO-260617-024 | 已完成 | 28 | PP2284 油醋汁(1KG/包) |
| JO-260617-030 | 已完成 | 508 | PP2290 韓式豬軟骨 |

**怎麼練:**

1. 「搜索工單/ 建立工單」→「預計生產日期」填 **2026-06-17** →「搜索」。
2. 找上表工單編號,點「查看」看已完成工單長怎樣。
3. 對照:這類工單來自排程 release(`type` 在庫為 `detailed`),不是手動「建立工單」的 `manual`。

### 例 C:手動「建立工單」(不經排程)

本機 **2026-07-29** 工單幾乎皆為手動(`type = manual`,且未掛排期行)。例子:

| 「工單編號」 | 「狀態」 | 「需求數量」 | 成品 |
|--------------|----------|--------------|------|
| JO-260729-045 | 待處理 | 3 | PP2390 熱情香果醬 |
| JO-260729-038 | 待處理 | 51 | PP2277 烚意粉 |
| JO-260729-034 | 生產中 | 1 | PP2302 酸甜蘿蔔粒箱料粉 |
| JO-260729-044 | 提料中 | 265 | PP2383 辣椒菜脯 |

**怎麼練:**

1. 「預計生產日期」選 **2026-07-29** →「搜索」。
2. 用「狀態」篩「待處理」→ 應能看到類似 JO-260729-045。
3. 若該單仍「規劃中」,可練習「放單」;若已是「待處理」,可接「工單提料」。

同日狀態分佈(本機快照):約 17 張「待處理」、24 張「提料中」、4 張「生產中」——正好對應「放單後 → 提料 → 生產」不同節點。

### 例 D:近兩週工單狀態分佈(看流程卡在哪)

本機最近約 14 天(未隱藏工單)概況:

| 「狀態」 | 約筆數 | 使用者下一步常做什麼 |
|----------|--------|----------------------|
| 已完成 | 359 | 可當完成範本「查看」 |
| 待處理 | 42 | 「工單提料」 |
| 提料中 | 37 | 繼續掃碼/提交提料 |
| 待品檢入倉 | 33 | 「工單生產流程」品檢/上架 |
| 生產中 | 4 | 「工單生產流程」繼續工序 |

### 例 E:還在「規劃中」的單(適合練刪除/放單)

本機仍有例如:**JO-260427-039**(沙薑醬 PP2205,需求約 291,「規劃中」)。

- 若只需練習「查看」→ 看「放單」「刪除工單」按鈕是否出現。
- **勿在正式/共用庫隨意刪除**;測試庫才建議真的按「刪除工單」。

### 自行查核用 SQL(選用)

```sql
-- 某日排期摘要
SELECT id, DATE(produceAt) AS produce_date, type,
totalEstProdCount, totalFGType
FROM production_schedule
WHERE deleted = 0 AND DATE(produceAt) = '2026-08-03';

-- 該排期成品明細(前 20)
SELECT i.code, i.name, psl.prodQty, psl.stockQty, psl.needNoOfJobOrder
FROM production_schedule_line psl
JOIN production_schedule ps ON ps.id = psl.prodScheduleId
JOIN items i ON i.id = psl.itemId
WHERE ps.deleted = 0 AND psl.deleted = 0
AND DATE(ps.produceAt) = '2026-08-03'
ORDER BY psl.itemPriority, i.code
LIMIT 20;

-- 某日工單+狀態
SELECT code, status, type, reqQty, DATE(planStart) AS plan_date
FROM job_order
WHERE deleted = 0 AND (isHidden = 0 OR isHidden IS NULL)
AND DATE(planStart) = '2026-07-29'
ORDER BY status, code;
```

---

## 二、排程(側欄「排程」)

### 2.1 入口

- 側欄點 **「排程」**
- 進入頁面標題:**「生產排程」**(路徑通常為 `/ps`)

> 說明:系統另有舊版「需求預測」「詳細排程」頁(`/scheduling/...`),**目前側欄主入口是「排程」→「生產排程」**。以下以主入口為準。

### 2.2 畫面上常見按鈕

| 按鈕/功能 | 用途(白話) |
|------------|--------------|
| 「預測排期」 | 依日期/天數**計算產生**預計排期 |
| 「搜索」 | 依「生產日期」等條件查已有排期 |
| 「詳細」 | 打開該筆排期的明細 |
| 「自動生成工單」 | 依此排期**一次產生多張工單**(在詳情裡) |
| 「關閉」 | 關閉詳情視窗 |
| 「排期設定」 | 庫存/排期相關設定與匯入匯出 |
| 「匯出計劃/物料需求Excel」 | 匯出計劃與物料需求 |
| 「匯出送貨單數量」 | 匯出送貨單數量區間資料 |

列表常見欄位:「生產日期」「預計生產數」「成品款數」等。

### 2.3 操作步驟:做出排期(Happy path)

1. 進入 **「排程」** → **「生產排程」**。
2. 點 **「預測排期」**。
3. 在對話框 **「準備生成預計排期」** 中填:
- 「開始日期」
- 「排期日數」
4. 點 **「計算預測排期」**。
5. 成功時畫面會提示類似 **「成功計算排期!」**;失敗會提示計算錯誤或不明狀況。
6. 選擇「生產日期」後點 **「搜索」**,在列表找到該日排期。
7. 點該列 **「詳細」**,打開 **「排期詳細」**。
8. 確認內容無誤後,點 **「自動生成工單」** → 系統依排期建立工單。
9. 點 **「關閉」** 結束。

### 2.4 節點說明(排程)

| 節點 | 使用者在做什麼 | 下一個常見動作 |
|------|----------------|----------------|
| 尚未有排期 | 進「生產排程」但列表空/無當日資料 | 「預測排期」 |
| 已有排期列表 | 用「搜索」找日期 | 「詳細」 |
| 排期詳細已打開 | 檢查預計生產內容 | 「自動生成工單」 |
| 已生成工單 | 工單出現在「搜索工單/ 建立工單」 | 去「放單」 |

### 2.5 排程常見問題

| 情況 | 建議處理 |
|------|----------|
| 「計算預測排期」失敗 | 記下畫面錯誤訊息;檢查開始日期/天數;稍後再試或聯絡系統/IT |
| 「自動生成工單」失敗 | 畫面可能顯示失敗訊息;確認排期內容是否完整、BOM/物料是否齊全 |
| 找不到某日排期 | 確認「生產日期」與「搜索」條件;必要時再跑一次「預測排期」 |
| 想改數量再開工單 | 主入口 `/ps` 詳情偏「一次自動生成」;若需逐行改量/發佈,需使用舊版「詳細排程」編輯頁(見附錄) |

### 2.6 附錄:舊版「詳細排程」/「需求預測」(進階)

若單位仍使用直連網址:

| 畫面 | 標題(約) | 重點按鈕 |
|------|------------|----------|
| 需求預測列表 | 「需求預測」 | 「測試粗排」「搜索」「詳情」 |
| 需求預測詳情 | 「成品及物料需求預測詳情」 | 多為**檢視**(依成品/依物料、「查看 BOM」) |
| 詳細排程列表 | 「詳細排程」 | 「詳細排程」(產生)、「匯出排程」「詳情」 |
| FG 生產排程 | 「成品生產排程」/「FG 生產排程」 | 列「發佈」、改「需求數量」後儲存、**「生成工單」**、「返回」 |

注意(舊版細排詳情):

- **「生成工單」** 常僅允許**生產日期為今天**;否則可能跳出英文提示(畫面未必有完整中文翻譯)。
- 前端**沒有**「從某一筆需求預測一鍵跳到對應詳細排程」的按鈕;兩條線在畫面上是分開的。

設定選單另有 **「需求預測設定」**(成品排除日、星期等),屬主檔設定,不是每日排期操作。

---

## 三、工單:搜索/建立/放單

### 3.1 入口

側欄 **「管理工單」** → **「搜索工單/ 建立工單」**

頁面標題:**「搜索工單/建立工單」**

### 3.2 畫面上常見按鈕

| 按鈕 | 用途 |
|------|------|
| 「建立工單」 | 手動開一張新工單 |
| 「放單」/「放單 (N)」 | 將「規劃中」工單放出,進入後續提料 |
| 「重置」 | 清空搜尋條件 |
| 「查看」 | 開「工單詳情」 |
| 「取消工單」 | 取消後工單從列表隱藏(非規劃中等情況) |

搜尋條件常見:「工單編號」「成品/半成品名稱」「預計生產日期」~「預計生產日期至」「工單類型」「狀態」。

### 3.3 操作步驟:手動建立工單

1. 點 **「建立工單」**,打開標題為 **「建立工單」** 的視窗。
2. 填寫:
- 「BOM」
- 「標準生產數」(通常唯讀)
- 「批數」
- 「需求數量」(常由標準×批數自動帶出)
- 「工單類型」(選 BOM 後可能自動對應)
- 「生產優先序」(常見預設約 50,範圍約 1–100)
- 「預計生產日期」
- 可勾選「記住為預設日期」
3. 點 **「建立」**。
4. 成功後通常提示資料已更新;新工單多為 **「規劃中」** 狀態。
5. 在列表勾選/找到該工單,點 **「放單」**(或上方「放單 (N)」批量)。

### 3.4 操作步驟:從排程來的工單

1. 在「生產排程」詳情已按 **「自動生成工單」**(或舊版「生成工單」)。
2. 到 **「搜索工單/ 建立工單」**,用「預計生產日期」等搜尋。
3. 確認工單後執行 **「放單」**。

### 3.5 工單詳情(「查看」)

標題:**「工單詳情」**

常見分頁/區塊:

- 「工單信息」
- 「BOM 材料」
- 「工藝流程」/「工藝明細」

常見動作:

| 按鈕 | 何時可用(概念) | 說明 |
|------|------------------|------|
| 「放單」 | 多為「規劃中」 | 放出工單 |
| 「刪除工單」 | 多為仍在「規劃中」 | 確認後**無法復原** |
| 「取消工單」 | 已非規劃中等 | 確認後從列表**隱藏**;已上架通常不可取消 |
| 「儲存」/「取消」 | 編輯 Dialog | 存檔或放棄 |
| 「返回列表」 | — | 回搜尋頁 |

庫存摘要常見字樣:「所需貨品項目數量:」「可提料項目數量:」「未能提料項目數量:」。

刪除確認:

- 「確認刪除工單」
- 「確定要刪除此工單嗎?此操作無法復原。」

取消確認:

- 「確認取消工單」
- 「確定要取消此工單嗎?工單將從列表中隱藏。」
- 按鈕:「取消工單」/「取消」(後者為放棄這次取消操作)

### 3.6 工單狀態(搜尋/列表常見)

畫面上可能出現(實際以該列顯示為準):

| 狀態(常見中文) | 白話 |
|------------------|------|
| 「規劃中」 | 已建立,尚未放單 |
| 「待處理」/待提料相關 | 已放單,等提料 |
| 「提料中」/「進行中」 | 正在提料或進行中 |
| 「已開始工序」/「生產中」 | 已進入生產工序 |
| 「成品入倉中」/「待品檢入倉」 | 生產後待品檢/入倉 |
| 「已上架」 | 已完成上架 |
| 「已取消」 | 已取消 |

篩選下拉亦可能見:「待處理」「提料中」「已開始工序」「成品入倉中」「已上架」「已取消」等。

### 3.7 工單建立/放單常見問題

| 情況 | 建議處理 |
|------|----------|
| 「建立」沒反應 | 檢查 BOM、批數、日期、工單類型是否已填;看是否有必填未選 |
| 「放單」失敗 | 記下提示;檢查是否仍為可放單狀態、物料/權限 |
| 想刪掉剛建錯的單 | 「規劃中」→「查看」→「刪除工單」→ 確認 |
| 已放單但不想做了 | 「取消工單」→ 確認隱藏;**已上架**通常不能取消 |
| 批量「放單 (N)」部分失敗 | 依成功/失敗筆數訊息,對失敗單再個別處理 |

---

## 四、工單提料

### 4.1 入口

**「管理工單」** → **「工單提料」**

### 4.2 常見分頁

- 「工單提料詳情」
- 「已完成工單記錄」
- 「物料提料狀態」
- (及其他與膠茜/用量相關分頁,以畫面為準)

### 4.3 操作步驟(概念)

1. 在「工單提料詳情」找到已放單、待提料的工單。
2. 點 **「查看詳情」** 進入提料執行畫面。
3. 依畫面使用 **「開始掃碼」**、掃描物料/批號。
4. 需要時點 **「提交」**/**「提交所有已掃描項目」**/**「確認」**。
5. 完成後可在「已完成工單記錄」核對。

對料相關:生產流程中可能有 **「工單對料」**,並有 **「確認所有提料」**。

### 4.4 提料常見問題

| 情況 | 建議處理 |
|------|----------|
| 批號不符 | 依 Dialog 提示按「確認」,改掃正確批號 |
| 庫存不足/過期 | 依畫面提示;先補貨或調批號,勿強行提交 |
| 掃錯要重來 | 用畫面上的「取消」/清除已掃項目(以當頁按鈕為準)後重掃 |

提料狀態常見:「待提料」「已掃碼」「對料待處理」「對料已完成」;提料單「已放單」「已完成」。

---

## 五、工單生產流程

### 5.1 入口

**「管理工單」** → **「工單生產流程」**

### 5.2 常見分頁/區塊

- 「工藝流程」
- 「待QC上架工單」
- 「已上架工單」
- 「儀表板 - 工單狀態」等

### 5.3 工序操作(Happy path)

1. 在「工藝流程」找到工單,點 **「查看詳情」**。
2. (可選)**「工單對料」** 確認物料。
3. 各工序:
- **「開始」** 開工序
- 可依畫面掃 **操作員**/**設備**(未掃可能提示「請先掃描操作員編號」「請掃描設備編號」)
- 可 **「暫停」**
- **「完成步驟」**/**「通過」**/**「已完成」**(Just Pass 等,以畫面為準)
- 確認通過時可能問:**「確認要通過此工序嗎?」**
4. 全部就緒後點 **「完成工單」**,確認:**「確認要完成此工單嗎?」**
5. 需要時做 **「品檢」**,再到「待QC上架工單」完成上架,最後在「已上架工單」可見。

其他可能按鈕:「提交並開始」「提交包裝袋消耗」等。

### 5.4 工序狀態(常見)

「待處理」「進行中」「已暫停」「完成」/「已完成」「通過」「未開始」「已停止」「已取消」等。

### 5.5 生產常見問題

| 情況 | 建議處理 |
|------|----------|
| 「完成工單」按不了 | 通常尚有工序未完成或條件未滿足;先完成各步驟 |
| 掃碼驗證失敗 | 「驗證失敗. 請檢查操作員和設備.」→ 重掃正確編號 |
| API/系統錯誤 | 「發生錯誤,請稍後再試。」→ 稍後重試或聯絡支援 |
| 做到一半要停 | 用「暫停」;勿與「取消工單」混淆(取消是整張工單層級) |

---

## 六、取消、刪除、返回——怎麼選?

| 你想做的事 | 建議用哪個按鈕 | 結果 |
|------------|----------------|------|
| 建錯、還在規劃中,不要了 | 「刪除工單」 | 刪除,**不可復原** |
| 已放單/進行中,不要繼續 | 「取消工單」 | 確認後從列表**隱藏** |
| 只是關掉視窗、不改資料 | 「關閉」/「取消」/「返回」「返回列表」 | 不取消工單本身 |
| 工序暫停一下 | 「暫停」 | 工單仍在,之後可再「開始」 |

---

## 七、快速檢查清單(每日)

1. 「排程」→ 需要時「預測排期」→「搜索」→「詳細」→「自動生成工單」
2. 「搜索工單/ 建立工單」→ 核對當日工單 →「放單」
3. 「工單提料」→ 掃碼提交
4. 「工單生產流程」→ 各工序開始/完成 →「完成工單」→ 品檢/上架

手動補單:同頁「建立工單」→「建立」→「放單」。

---

## 八、文件維護說明(給內部)

- 文案來源:前端 `i18n/zh/navigation.json`、`jo.json`、`schedule.json`、`productionProcess.json`,以及「生產排程」頁硬編碼繁中。
- 主程式入口:
- 排程:`FPSMS-frontend/src/app/(main)/ps/page.tsx`
- 工單搜尋/建立:`.../jo/page.tsx`、`JoWorkbenchSearch`、`JoCreateFormModal`
- 提料:`.../jodetail/`
- 生產:`.../productionProcess/`
- 若按鈕改名或流程改版,請同步改本手冊,並重新匯出 Word。

重新匯出 Word(需已安裝套件):

```bash
pip install python-docx
python scripts/export_user_guide_office.py
```

產出:`docs/exports/MTMS_Schedule_JobOrder_UserGuide.docx`

+ 375
- 0
docs/user-guides/MTMS_送貨訂單與出貨_使用說明.md Datei anzeigen

@@ -0,0 +1,375 @@
# MTMS 使用說明:送貨訂單 → 放單 → 成品出倉

> 本手冊依目前前端畫面與繁中文案整理,**按鈕/選單名稱以畫面上「」內文字為準**。
> 適用範圍:側欄「送貨訂單」、倉庫「成品出倉」(撳單/掃碼出倉/列印標籤)、加單與車線-X。
> **「一附」章節**使用本機資料庫 `fpsmsdb` 的真實例子(查詢當下快照)。
> 產生日期:依原始碼現況(若畫面改版,請以實際 UI 為準)。

---

## 這份手冊怎麼用

| 符號 | 意思 |
|------|------|
| 「……」 | 畫面上看得到的按鈕、選單、標題 |
| → | 下一步操作 |

**角色分工(白話):**

| 角色動作 | 主要畫面 |
|----------|----------|
| 看單、放單、加單、補貨 | 「送貨訂單」`/do` |
| 撳單、掃碼提貨、列印 DN/標籤 | 「成品出倉」`/doworkbench` |
| 調整提料順序等(ADMIN) | 「成品出倉管理」 |

> **「放單」≠「撳單」**:放單=由送貨訂單產生提料票;撳單=倉庫領取該票開始出倉。

建議閱讀順序:送貨訂單篩選 → 放單 → 撳單 → 掃碼出倉 → 填箱數列印。

---

## 一、整體流程

```text
【送貨訂單 /do】篩選「2/F」「4/F」「車線-X」「加單」
↓ 「批量放單」或詳情「放單」
【產生提料單/提票】狀態進入待撳單
【成品出倉 /doworkbench】「撳單/提料單詳情」
↓ 選日期/批量|單量/樓層票 → 點車線「確認分配」
【掃碼提料】「開始QR掃描」→「提交所有已掃描項目」
【成品提貨記錄】輸入「箱數」→「列印送貨單標籤」等
【查看提貨情況】核對「已完成」
```

---

## 一附、本地資料庫實例

> DO「來貨狀態」:`pending`→「待處理」、`receiving`→「接收中」、`completed`→「已完成」。
> 提票在「查看提貨情況」:`pending`→「待撳單」、`released`→「提貨中」、`completed`→「已完成」。

### 例 A:待放單/待出貨的送貨訂單(2026-07-30)

本機「預計送貨日期」**2026-07-30** 仍有多張「待處理」,例如:

| 「門店訂單編號」 | 「來貨狀態」 | 店鋪 |
|------------------|--------------|------|
| TOUR03PO26070304 | 待處理 | UR03 |
| TOCF28PO26070199 | 待處理 | CF28 |
| TOCF28PO26070200 | 待處理 | CF28 |
| TOCF02PO26070176 | 待處理 | CF02 |
| TOCF18PO26070194 | 待處理 | CF18 |

**怎麼練:**

1. 「送貨訂單」→ 選樓層分頁 →「預計送貨日期」填 **2026-07-30** → 搜索。
2. 找上表編號點「詳情」看行項與「庫存可用」。
3. 測試庫才建議真的「放單」/「批量放單」(會產生提料票)。

### 例 B:近一週 DO 狀態量級(本機 2026-07-28~08-05)

| 「來貨狀態」 | 約筆數 | 使用者常做 |
|--------------|--------|------------|
| 待處理 | 1,425 | 篩選後「批量放單」 |
| 已完成 | 1,059 | 查歷史/補貨原單 |
| 接收中 | 96 | 出倉進行中對應 |

### 例 C:提料票(撳單前「待撳單」風格樣本)

本機較早提票例(`do_pick_order`,狀態 pending≈待撳/待處理):

| 「提票號碼」 | 車線資訊 | 店鋪 | 「需求日期」 | 放單類型 |
|--------------|----------|------|--------------|----------|
| TI-S-20260504-4F-001 | P06B_Sat_區1_港島東 | MC49 | 2026-05-04 | single(單量) |
| TI-S-20260504-2F-001 | 車線-F1 | MC49 | 2026-05-04 | single |
| TI-S-20260504-2F-001 | 車線-X | HP65 | 2026-05-04 | single |
| TI-S-20260505-4F-002 | P06B_Tue_區5_九龍中 | HP15 | 2026-05-05 | single |

**怎麼練:**「成品出倉」→「撳單/提料單詳情」→ 日期選對應「是日/翌日…」;或「成品提貨記錄(全部)」用「提票號碼」搜索。注意「車線-X」會獨立分組。

### 自行查核用 SQL(選用)

```sql
SELECT d.code, d.status, DATE(d.estimatedArrivalDate) AS eta, s.code AS shop
FROM delivery_order d
LEFT JOIN shop s ON s.id = d.shopId
WHERE d.deleted = 0 AND DATE(d.estimatedArrivalDate) = '2026-07-30'
ORDER BY d.id DESC
LIMIT 20;

SELECT ticket_no, TruckLanceCode, ticket_status, ShopCode,
DATE(RequiredDeliveryDate) AS req_date, release_type
FROM do_pick_order
WHERE deleted = 0 AND RequiredDeliveryDate >= '2026-05-01'
ORDER BY id DESC
LIMIT 20;
```

---

## 二、送貨訂單(`/do`)

### 2.1 入口與分頁

- 側欄 **「送貨訂單」**。

| 分頁 | 用途 |
|------|------|
| 「2/F」「4/F」 | 依樓層票別看/放單 |
| 「車線-X」 | 無匹配車線或歸入 X 的訂單 |
| 「加單」 | isExtra 加單;批量放單可合併 |
| 「補貨」 | 已完成原單補到目標單 |

### 2.2 搜索欄

- 「門店訂單編號」「店鋪名稱」「車線號碼」「預計送貨日期」「來貨狀態」
- 狀態選項:「待處理」「接收中」「已完成」(及「全部」)
- 注意:「已填寫車線號碼時,請一併選擇預計送貨日期後再搜索。」/「需選擇預計送貨日期」

### 2.3 結果表常見欄

「詳情」「門店訂單編號」「店鋪名稱」「供應商名稱」「車線號碼」「訂單日期」「預計送貨日期」「來貨狀態」。

### 2.4 詳情頁(`/do/edit?id=`)

- 標題:「編輯送貨訂單詳情」
- 動作:「放單」「提料單分配」「分配2/F」「分配4/F」「放單2/F」「放單4/F」「返回」
- 行表:「商品編號」「貨品名稱」「數量」「庫存可用」「庫存狀態」
- 單張成功提示:「送貨訂單放單成功!提料單已建立。」

---

## 三、放單詳解

### 3.1 批量放單(常用)

1. 在「送貨訂單」搜出目標日/樓層的列,勾選需要的店(可取消勾選排除)。
2. 點 **「批量放單」**。
3. 對話框顯示「已選擇店舖數量: N」;「確認」執行。
4. **「加單」分頁**額外選項:
- 「確認合併放單」(合併同車線 → TI-M- 合併票;文案含「合併同車線送貨訂單(TI-M- 合併票)」)
- 「確認不放合併放單」
5. 成功:「已完成批量放單」。

Workbench 路徑按鈕亦可能顯示為「批量放單」(鍵名 Workbench Batch Release)。

### 3.2 單張放單

詳情頁「放單」,或先「分配2/F/4/F」再「放單2/F/4/F」。

### 3.3 放單前/失敗常見提示

| 提示 | 處理 |
|------|------|
| 「沒有選擇送貨訂單進行批量放單…」 | 先勾選列 |
| 「車線可用性警告」「問題送貨訂單」 | 核對目標日是否有車線;或走「車線-X」 |
| 「放單提料單失敗,請稍後再試。」 | 稍後重試;查該店是否已放過 |

---

## 四、成品出倉(`/doworkbench`)— 主路徑

### 4.1 入口

- 「倉庫管理」→ **「成品出倉」**(現行主選單指向 `/doworkbench`)。
- 舊頁 `/finishedGood` 標題同為「成品出倉」,一般以 Workbench 為準。

### 4.2 頁頂打印機

- 「A4 打印機」「標籤打印機」「列印全部草稿 (N)」
- 未選機:「請先選擇打印機」/「請先選擇標籤打印機」

### 4.3 分頁一覽

| tab | 標籤 |
|-----|------|
| 0 | 「撳單/提料單詳情」 |
| 1 | 「加單」(徽章:當日未完成加單票數) |
| 2 | 「成品提貨記錄」 |
| 3 | 「成品提貨記錄(全部)」 |
| 4 | 「查看提貨情況」 |
| 5 | 「成品出倉出箱數量」 |
| 6 | 「送貨路線摘要」 |

---

## 五、撳單(領票)

### 5.1 條件列

- 「請選擇日期」:「是日」「翌日」「後日」
- 「放單類型」:「批量」「單量」
- 「票別(樓層)」:「2/F 票」「4/F 票」

### 5.2 車線面板

1. 車線按鈕顯示「(未撳數/總單數)」、裝載序/出發時間等。
2. 點車線 →「確認分配」(含「位置」「車線號碼」「裝載順序」「出發時間」「所需日期」「可用訂單」)→「確認」。
3. 成功後進入提料明細掃碼。
4. 無單時:「該樓層未有需處理訂單」/「此樓層沒有可用的提料單」。
5. 「未完成提料單」可搜商店/車線/送貨單編號再「選擇」。
6. **限制:**「請先完成目前的提料單,再提取下一張」。

### 5.3 「車線-X」

- DO 與出倉皆有獨立「車線-X」區塊;無匹配車線時顯示「車線-X」。
- 「以前」:今日前未完成的車線-X。
- 出箱儀表會統計「車線-X 出箱數」。

---

## 六、掃碼出倉(提料執行)

1. 在已撳單的明細中看「所有提料單批號」「進度」。
2. 「開始QR掃描」/「停止QR掃描」;「掃描結果」正確時「二維碼驗證成功。」
3. 可「改數」「提交數量」;問題回報含不良/遺失等。
4. 「提交所有已掃描項目」;可選「列印空白頁數標籤」。
5. 無掃碼可直接完成的列可用「已完成」(Just Completed)。
6. 全部完成後通常導向「成品提貨記錄」(帶提票號)。

### 掃碼常見錯誤

| 提示 | 處理 |
|------|------|
| 「二維碼不符合當前訂單中的任何貨品。」 | 確認掃的是本票貨品 |
| 「此批號不可用…」「此批次尚未上架」 | 換批或先完成上架 |
| 「此批號單位不符…」「此批號已提貨…」 | 換批 |
| 「掃描批號已過期…」 | 換未過期批 |
| 「此批次貨品已被其他送貨單留起…」 | 換批或協調留貨 |
| 換批雙掃說明 | 依畫面再掃一次確認 |

---

## 七、加單專章

### 7.1 在「送貨訂單」

- 開「加單」分頁搜索與「批量放單」。
- 合併選項見 §3.1(TI-M- 合併票)。

### 7.2 在「成品出倉」

1. Tab「加單」;進入前確認:「進入加單檢視?」
2. 說明:「加單檢視會依選定日期,將 isExtra 票依店鋪與車線顯示。」
3. 「目前是加單票,顯示與操作已切換為加單模式。」/「離開加單檢視」「返回一般指派分頁」。
4. 「合併加單提料單」:僅「未分配」且同店鋪、樓層(2/F、4/F 或車線-X)、車線、出發時間可合併。
5. 類型顯示可能為「合拼單」「加單」「批量」「單量」。

---

## 八、補貨(「送貨訂單」→「補貨」)

1. 「補貨填表」「對單」→「待提交列表」→「提交」/「清空」。
2. 「送貨單號末四位」「原送貨單」「目標送貨單」「補貨數量」「原出貨數」「車線」。
3. 無車線時畫面可能顯示「車線-X」。
4. 「補貨進度追蹤」:待處理/處理中/已完成。
5. 限制例:「只有已送貨(completed)的送貨單可作為原送貨單。」「補貨數量必須大於零」。

---

## 九、箱數與列印

### 9.1 草稿/空白

- 「列印全部草稿 (N)」→ 確認「確認列印全部草稿?(總數量:N份)」→「成功列印」。
- 提料中:「列印空白頁數標籤」→「請輸入要列印的標籤數量:」。

### 9.2 完成後正式列印(「成品提貨記錄」)

1. 「查看詳情」或列表動作。
2. 「列印提料單」「列印送貨單標籤」「列印提料單和送貨單標籤」「補印標籤」。
3. 彈窗「請輸入總箱數」,欄位「箱數」(≥1)。
4. 成功:「成功列印」。

### 9.3 補印

- 「補印送貨單標籤」:「起始箱號」「結束箱號」「總箱數」
- 校驗:起始≥1、結束≥起始、結束≤總箱數等。

### 9.4 送貨路線摘要

- Tab「送貨路線摘要」→ 選「車線」→「下載報告 (PDF)」。
- 若未執完:「此車線仍有 N 張訂單未執拾。是否仍要列印 / 下載送貨路線摘要?」

### 9.5 出箱數量

- Tab「成品出倉出箱數量」:按日「2/F 出箱數」「4/F 出箱數」「車線-X 出箱數」「總出箱數」(來自完成時填的箱數)。

---

## 十、查看提貨情況與管理動作

### 10.1 查詢

- 「目標日期」「重新載入」「樓層」「狀態」(「待撳單」「提貨中」「已完成」)。
- 欄含貨車/車線/裝載順序/提票號碼/負責員工/訂單項目數量等。

### 10.2 管理(常需 ADMIN)

| 動作 | 意義(畫面說明意涵) |
|------|----------------------|
| 「撤銷領取」 | 清空負責人,單據回待分配,他人可再領 |
| 「強制完成提貨單」 | 標完成並歸檔,不改已揀數量;適用已全部提交但系統未結案 |

未授權:「僅管理員(ADMIN 權限)可使用」。

---

## 十一、狀態對照(避免搞混三套名稱)

### 11.1 送貨訂單「來貨狀態」

| 鍵 | 畫面 |
|----|------|
| pending | 「待處理」 |
| receiving | 「接收中」 |
| completed | 「已完成」 |
| (部分流程)released / picking | 「已放單」/「提料中」 |

### 11.2 「查看提貨情況」提票狀態

| 鍵 | 畫面 | 白話 |
|----|------|------|
| pending | 「待撳單」 | 已放單、尚未領取 |
| released | 「提貨中」 | 已撳單/出倉中 |
| completed | 「已完成」 | 提貨完成 |

### 11.3 提料單通用(pickOrder)

「待處理」「已放單」「提料中」「已完成」——與上表用詞接近但場景不同;操作時以**目前所在分頁**的 Chip 文案為準。

---

## 十二、成品出倉管理(ADMIN)

- 「倉庫管理」→「成品出倉管理」(`/finishedGood/management`)。
- 「提料順序」:上移/下移/置頂/置底、「新增物品」「儲存」「重新載入」。
- 「出貨倉位」「入貨倉位」等主檔維護。

---

## 十三、路徑速查

| 畫面 | 路徑 |
|------|------|
| 送貨訂單 | `/do` |
| DO 詳情/放單 | `/do/edit?id=` |
| 成品出倉(主) | `/doworkbench` |
| 成品出倉管理 | `/finishedGood/management` |
| 舊成品出倉 | `/finishedGood`(附錄對照用) |

---

## 十四、常見問題速查

| 情況 | 建議 |
|------|------|
| 批量放單後倉庫看不到票 | 核對日期「是日/翌日」、樓層票別、批量/單量、是否加單檢視 |
| 車線按鈕 0 單 | 換日期/樓層;查「車線-X」「未完成提料單」 |
| 掃碼一直失敗 | 批號是否上架、是否被留貨/過期/單位不符 |
| 印不出標籤 | 先選 A4/標籤打印機;完成後記得填「箱數」 |
| 加單與正單混在一起 | 明確進/出「加單」檢視;合併規則要同店同線同時段 |
| 畫面突然英文 | 少數錯誤字串尚未進 zh,以實機為準並回報補譯 |

+ 345
- 0
scripts/export_m18_mapping_office.py Datei anzeigen

@@ -0,0 +1,345 @@
#!/usr/bin/env python3
# -*- coding: utf-8 -*-
"""
Export MTMS ↔ M18 mapping docs to Word (.docx) and Excel (.xlsx).

Prereqs:
pip install python-docx openpyxl

Usage (from repo root):
python scripts/export_m18_mapping_office.py

Outputs:
docs/exports/MTMS_M18_DATA_MAPPING.docx
docs/exports/MTMS_M18_DATA_MAPPING.xlsx
"""

from __future__ import annotations

import re
from datetime import datetime, timezone
from pathlib import Path

from docx import Document
from docx.enum.text import WD_ALIGN_PARAGRAPH
from docx.oxml.ns import qn
from docx.shared import Cm, Pt, RGBColor
from openpyxl import Workbook
from openpyxl.styles import Alignment, Border, Font, PatternFill, Side
from openpyxl.utils import get_column_letter

ROOT = Path(__file__).resolve().parents[1]
DOCS = ROOT / "docs"
GEN = DOCS / "generated"
OUT = DOCS / "exports"
HANDBOOK = DOCS / "MTMS_M18_DATA_MAPPING.md"
ITEM_TYPE_MD = GEN / "m18-item-type-mapping.md"
STSEARCH_MD = GEN / "m18-stsearch-types.md"


def strip_md_inline(s: str) -> str:
s = s.strip()
s = re.sub(r"\[([^\]]+)\]\([^)]+\)", r"\1", s) # links
s = s.replace("**", "").replace("`", "").replace("*", "")
return s.strip()


def parse_md_tables(text: str) -> list[tuple[str, list[str], list[list[str]]]]:
"""
Return list of (section_title, headers, rows) for each markdown table.
section_title = nearest preceding ## / ### heading.
"""
lines = text.splitlines()
current_h = ""
tables: list[tuple[str, list[str], list[list[str]]]] = []
i = 0
while i < len(lines):
line = lines[i]
if line.startswith("#"):
current_h = strip_md_inline(re.sub(r"^#+\s*", "", line))
i += 1
continue
if line.strip().startswith("|") and i + 1 < len(lines) and re.match(
r"^\|[\s\-:|]+\|$", lines[i + 1].strip()
):
header = [strip_md_inline(c) for c in line.strip().strip("|").split("|")]
i += 2
rows: list[list[str]] = []
while i < len(lines) and lines[i].strip().startswith("|"):
row = [strip_md_inline(c) for c in lines[i].strip().strip("|").split("|")]
rows.append(row)
i += 1
tables.append((current_h, header, rows))
continue
i += 1
return tables


def add_runs_with_code(paragraph, text: str) -> None:
"""Simple split on backticks for monospace-ish plain text."""
parts = re.split(r"`([^`]+)`", text)
for idx, part in enumerate(parts):
if not part:
continue
run = paragraph.add_run(part)
run.font.name = "Calibri"
run._element.rPr.rFonts.set(qn("w:eastAsia"), "Microsoft JhengHei")
if idx % 2 == 1:
run.font.name = "Consolas"
run.font.size = Pt(9)


def md_to_docx(md_path: Path, out_path: Path, extra_md_files: list[Path] | None = None) -> None:
doc = Document()
section = doc.sections[0]
section.top_margin = Cm(2)
section.bottom_margin = Cm(2)
section.left_margin = Cm(2.2)
section.right_margin = Cm(2.2)

style = doc.styles["Normal"]
style.font.name = "Calibri"
style.font.size = Pt(11)
style._element.rPr.rFonts.set(qn("w:eastAsia"), "Microsoft JhengHei")

files = [md_path] + (extra_md_files or [])
first = True
for path in files:
if not path.is_file():
continue
if not first:
doc.add_page_break()
first = False
_append_md_file(doc, path)

footer = doc.sections[0].footer.paragraphs[0]
footer.text = (
f"MTMS ↔ M18 mapping · exported {datetime.now(timezone.utc).strftime('%Y-%m-%d %H:%M UTC')}"
)
footer.alignment = WD_ALIGN_PARAGRAPH.CENTER

out_path.parent.mkdir(parents=True, exist_ok=True)
doc.save(out_path)
print(f"Wrote {out_path.relative_to(ROOT)}")


def _append_md_file(doc: Document, path: Path) -> None:
lines = path.read_text(encoding="utf-8").splitlines()
i = 0
in_code = False
code_buf: list[str] = []

while i < len(lines):
line = lines[i]

if line.startswith("<!--"):
i += 1
continue

if line.strip().startswith("```"):
if not in_code:
in_code = True
code_buf = []
else:
in_code = False
p = doc.add_paragraph()
run = p.add_run("\n".join(code_buf))
run.font.name = "Consolas"
run.font.size = Pt(9)
i += 1
continue

if in_code:
code_buf.append(line)
i += 1
continue

if line.startswith("#"):
level = len(re.match(r"^#+", line).group(0))
text = strip_md_inline(re.sub(r"^#+\s*", "", line))
if level == 1:
doc.add_heading(text, level=0)
else:
doc.add_heading(text, level=min(level, 3))
i += 1
continue

if line.strip().startswith("|") and i + 1 < len(lines) and re.match(
r"^\|[\s\-:|]+\|$", lines[i + 1].strip()
):
header = [strip_md_inline(c) for c in line.strip().strip("|").split("|")]
i += 2
rows: list[list[str]] = []
while i < len(lines) and lines[i].strip().startswith("|"):
rows.append(
[strip_md_inline(c) for c in lines[i].strip().strip("|").split("|")]
)
i += 1
table = doc.add_table(rows=1 + len(rows), cols=len(header))
table.style = "Table Grid"
for c, h in enumerate(header):
cell = table.rows[0].cells[c]
cell.text = h
for p in cell.paragraphs:
for r in p.runs:
r.bold = True
for r_idx, row in enumerate(rows):
for c, val in enumerate(row):
if c < len(header):
table.rows[r_idx + 1].cells[c].text = val
doc.add_paragraph()
continue

if line.strip().startswith("> "):
p = doc.add_paragraph()
p.paragraph_format.left_indent = Cm(0.5)
add_runs_with_code(p, strip_md_inline(line.strip()[2:]))
for r in p.runs:
r.italic = True
i += 1
continue

if re.match(r"^[-*]\s+", line.strip()):
text = strip_md_inline(re.sub(r"^[-*]\s+", "", line.strip()))
p = doc.add_paragraph(style="List Bullet")
add_runs_with_code(p, text)
i += 1
continue

if re.match(r"^\d+\.\s+", line.strip()):
text = strip_md_inline(re.sub(r"^\d+\.\s+", "", line.strip()))
p = doc.add_paragraph(style="List Number")
add_runs_with_code(p, text)
i += 1
continue

if line.strip() == "" or line.strip() == "---":
i += 1
continue

p = doc.add_paragraph()
add_runs_with_code(p, strip_md_inline(line))
i += 1


def write_xlsx(out_path: Path) -> None:
wb = Workbook()
# remove default later if we create named sheets first
header_fill = PatternFill("solid", fgColor="D9D9D9")
header_font = Font(bold=True, name="Calibri", size=11)
thin = Border(
left=Side(style="thin", color="B0B0B0"),
right=Side(style="thin", color="B0B0B0"),
top=Side(style="thin", color="B0B0B0"),
bottom=Side(style="thin", color="B0B0B0"),
)
wrap = Alignment(wrap_text=True, vertical="center")

def style_sheet(ws, headers: list[str], rows: list[list[str]], title: str) -> None:
ws["A1"] = title
ws["A1"].font = Font(bold=True, size=14, name="Calibri")
ws.merge_cells(start_row=1, start_column=1, end_row=1, end_column=max(len(headers), 1))
ws["A2"] = f"Exported {datetime.now(timezone.utc).strftime('%Y-%m-%d %H:%M UTC')}"
ws["A2"].font = Font(italic=True, color="666666", size=9)

start = 4
for c, h in enumerate(headers, 1):
cell = ws.cell(start, c, h)
cell.fill = header_fill
cell.font = header_font
cell.border = thin
cell.alignment = Alignment(wrap_text=True, vertical="center", horizontal="center")
for r_idx, row in enumerate(rows, start + 1):
for c, val in enumerate(row, 1):
cell = ws.cell(r_idx, c, val)
cell.border = thin
cell.alignment = wrap
for c in range(1, len(headers) + 1):
maxlen = len(headers[c - 1])
for row in rows:
if c - 1 < len(row):
maxlen = max(maxlen, len(row[c - 1]))
ws.column_dimensions[get_column_letter(c)].width = min(max(12, maxlen + 2), 48)

# Collect tables from generated + handbook
sheets_spec: list[tuple[str, Path]] = [
("ItemType_Sync", ITEM_TYPE_MD),
("StSearch", STSEARCH_MD),
("Handbook_Tables", HANDBOOK),
]

first = True
for sheet_name, md_path in sheets_spec:
if not md_path.is_file():
continue
tables = parse_md_tables(md_path.read_text(encoding="utf-8"))
if sheet_name == "Handbook_Tables":
# one sheet per handbook table (limited name length)
for idx, (sec, headers, rows) in enumerate(tables, 1):
name = f"H{idx}_{sec[:20]}" if sec else f"H{idx}"
name = re.sub(r"[\\/*?:\[\]]", "_", name)[:31]
ws = wb.active if first else wb.create_sheet(name)
if first:
ws.title = name
first = False
style_sheet(ws, headers, rows, f"{sec or 'Table'} (from handbook)")
continue

# For generated files: put Sync mapping as main sheet; other tables as extra sheets
if not tables:
continue
if first:
ws = wb.active
ws.title = sheet_name[:31]
first = False
else:
ws = wb.create_sheet(sheet_name[:31])

# Prefer table titled Sync mapping / first table
main = next((t for t in tables if "Sync" in t[0] or "mapping" in t[0].lower()), tables[0])
style_sheet(ws, main[1], main[2], main[0] or sheet_name)

for sec, headers, rows in tables:
if (sec, headers, rows) == main:
continue
extra_name = re.sub(r"[\\/*?:\[\]]", "_", f"{sheet_name[:8]}_{sec}")[:31]
ws2 = wb.create_sheet(extra_name)
style_sheet(ws2, headers, rows, sec or extra_name)

# Readme sheet
ws = wb.create_sheet("README", 0)
ws["A1"] = "MTMS (FPSMS) ↔ M18 資料對照 — Excel 匯出"
ws["A1"].font = Font(bold=True, size=14)
ws["A3"] = "來源"
ws["B3"] = "docs/MTMS_M18_DATA_MAPPING.md + docs/generated/*.md"
ws["A4"] = "重新產生 Markdown 表"
ws["B4"] = "python scripts/generate_m18_mapping_docs.py"
ws["A5"] = "重新匯出 Word/Excel"
ws["B5"] = "python scripts/export_m18_mapping_office.py"
ws["A7"] = "說明"
ws["B7"] = (
"對照表以 sheet 分開;完整敘述請看 Word 檔 MTMS_M18_DATA_MAPPING.docx。"
"已知陷阱:M18 udfProducttype=CMB 會落到 items.type=mat(原料)。"
)
ws.column_dimensions["A"].width = 28
ws.column_dimensions["B"].width = 80
for r in range(3, 8):
ws.cell(r, 2).alignment = wrap

out_path.parent.mkdir(parents=True, exist_ok=True)
wb.save(out_path)
print(f"Wrote {out_path.relative_to(ROOT)}")


def main() -> None:
OUT.mkdir(parents=True, exist_ok=True)
md_to_docx(
HANDBOOK,
OUT / "MTMS_M18_DATA_MAPPING.docx",
extra_md_files=[ITEM_TYPE_MD, STSEARCH_MD],
)
write_xlsx(OUT / "MTMS_M18_DATA_MAPPING.xlsx")


if __name__ == "__main__":
main()

+ 193
- 0
scripts/export_user_guide_office.py Datei anzeigen

@@ -0,0 +1,193 @@
#!/usr/bin/env python3
# -*- coding: utf-8 -*-
"""
Export user-facing guides (Markdown) to Word (.docx).

Usage (from repo root):
pip install python-docx
python scripts/export_user_guide_office.py

Outputs under docs/exports/
"""

from __future__ import annotations

import re
from datetime import datetime, timezone
from pathlib import Path

from docx import Document
from docx.enum.text import WD_ALIGN_PARAGRAPH
from docx.oxml.ns import qn
from docx.shared import Cm, Pt

ROOT = Path(__file__).resolve().parents[1]
GUIDE_DIR = ROOT / "docs" / "user-guides"
OUT_DIR = ROOT / "docs" / "exports"

GUIDES = [
# (markdown path, output docx filename — English names avoid Windows garbling)
(
GUIDE_DIR / "MTMS_排程與工單_使用說明.md",
"MTMS_Schedule_JobOrder_UserGuide.docx",
),
(
GUIDE_DIR / "MTMS_BOM_使用說明.md",
"MTMS_BOM_UserGuide.docx",
),
(
GUIDE_DIR / "MTMS_工單提料報工上架_使用說明.md",
"MTMS_JO_Pick_Production_PutAway_UserGuide.docx",
),
(
GUIDE_DIR / "MTMS_送貨訂單與出貨_使用說明.md",
"MTMS_DO_Shipping_UserGuide.docx",
),
]


def strip_md_inline(s: str) -> str:
s = s.strip()
s = re.sub(r"\[([^\]]+)\]\([^)]+\)", r"\1", s)
s = s.replace("**", "").replace("`", "").replace("*", "")
return s.strip()


def add_runs(paragraph, text: str) -> None:
parts = re.split(r"(「[^」]+」)", text)
for part in parts:
if not part:
continue
run = paragraph.add_run(part)
run.font.name = "Calibri"
run._element.rPr.rFonts.set(qn("w:eastAsia"), "Microsoft JhengHei")
if part.startswith("「") and part.endswith("」"):
run.bold = True
run.font.color.rgb = None


def md_to_docx(md_path: Path, out_path: Path) -> None:
doc = Document()
section = doc.sections[0]
section.top_margin = Cm(2)
section.bottom_margin = Cm(2)
section.left_margin = Cm(2.2)
section.right_margin = Cm(2.2)

style = doc.styles["Normal"]
style.font.name = "Calibri"
style.font.size = Pt(11)
style._element.rPr.rFonts.set(qn("w:eastAsia"), "Microsoft JhengHei")

lines = md_path.read_text(encoding="utf-8").splitlines()
i = 0
in_code = False
code_buf: list[str] = []

while i < len(lines):
line = lines[i]

if line.strip().startswith("```"):
if not in_code:
in_code = True
code_buf = []
else:
in_code = False
p = doc.add_paragraph()
run = p.add_run("\n".join(code_buf))
run.font.name = "Consolas"
run.font.size = Pt(9)
i += 1
continue

if in_code:
code_buf.append(line)
i += 1
continue

if line.startswith("#"):
level = len(re.match(r"^#+", line).group(0))
text = strip_md_inline(re.sub(r"^#+\s*", "", line))
doc.add_heading(text, level=0 if level == 1 else min(level, 3))
i += 1
continue

if line.strip().startswith("|") and i + 1 < len(lines) and re.match(
r"^\|[\s\-:|]+\|$", lines[i + 1].strip()
):
header = [strip_md_inline(c) for c in line.strip().strip("|").split("|")]
i += 2
rows: list[list[str]] = []
while i < len(lines) and lines[i].strip().startswith("|"):
rows.append(
[strip_md_inline(c) for c in lines[i].strip().strip("|").split("|")]
)
i += 1
table = doc.add_table(rows=1 + len(rows), cols=len(header))
table.style = "Table Grid"
for c, h in enumerate(header):
cell = table.rows[0].cells[c]
cell.text = h
for p in cell.paragraphs:
for r in p.runs:
r.bold = True
for r_idx, row in enumerate(rows):
for c, val in enumerate(row):
if c < len(header):
table.rows[r_idx + 1].cells[c].text = val
doc.add_paragraph()
continue

if line.strip().startswith("> "):
p = doc.add_paragraph()
p.paragraph_format.left_indent = Cm(0.4)
add_runs(p, strip_md_inline(line.strip()[2:]))
for r in p.runs:
r.italic = True
i += 1
continue

if re.match(r"^[-*]\s+", line.strip()):
p = doc.add_paragraph(style="List Bullet")
add_runs(p, strip_md_inline(re.sub(r"^[-*]\s+", "", line.strip())))
i += 1
continue

if re.match(r"^\d+\.\s+", line.strip()):
p = doc.add_paragraph(style="List Number")
add_runs(p, strip_md_inline(re.sub(r"^\d+\.\s+", "", line.strip())))
i += 1
continue

if line.strip() in ("", "---"):
i += 1
continue

p = doc.add_paragraph()
add_runs(p, strip_md_inline(line))
i += 1

footer = doc.sections[0].footer.paragraphs[0]
footer.text = (
f"MTMS 使用說明 · {md_path.name} · "
f"{datetime.now(timezone.utc).strftime('%Y-%m-%d %H:%M UTC')}"
)
footer.alignment = WD_ALIGN_PARAGRAPH.CENTER

out_path.parent.mkdir(parents=True, exist_ok=True)
doc.save(out_path)
print(f"Wrote {out_path.relative_to(ROOT)}")


def main() -> None:
OUT_DIR.mkdir(parents=True, exist_ok=True)
for guide, out_name in GUIDES:
if not guide.is_file():
print(f"Skip missing: {guide}")
continue
out = OUT_DIR / out_name
md_to_docx(guide, out)


if __name__ == "__main__":
main()

+ 505
- 0
scripts/generate_commit_test_plans_docx.py Datei anzeigen

@@ -0,0 +1,505 @@
#!/usr/bin/env python3
# -*- coding: utf-8 -*-
"""
Auto-generate a Word (.docx) test plan for each commit in a git range.

Usage (from repo root):
pip install python-docx
python scripts/generate_commit_test_plans_docx.py HEAD~10..HEAD
python scripts/generate_commit_test_plans_docx.py abc123..def456 --out-dir docs/deploy/commit-plans
python scripts/generate_commit_test_plans_docx.py HEAD~5..HEAD --also-md

Output folder (default): docs/deploy/commit-plans/
<date>_<shortsha>_<slug>.docx — one file per commit
_index.md — list of generated plans

Notes:
- Plans are heuristic from commit message + touched paths (good starting point for QA).
- For critical releases, ask the agent to refine steps against the actual diff.
"""

from __future__ import annotations

import argparse
import re
import subprocess
import sys
from dataclasses import dataclass, field
from datetime import datetime, timezone
from pathlib import Path

from docx import Document
from docx.enum.text import WD_ALIGN_PARAGRAPH
from docx.oxml.ns import qn
from docx.shared import Cm, Pt, RGBColor

ROOT = Path(__file__).resolve().parents[1]
DEFAULT_OUT = ROOT / "docs" / "deploy" / "commit-plans"

# (path substring, area label, suggested steps, expected results)
AREA_RULES: list[tuple[str, str, list[tuple[str, str]]]] = [
(
"deliveryOrder",
"送貨訂單 / 成品出倉",
[
("「送貨訂單」依預計送貨日搜索相關單,必要時「批量放單」或詳情「放單」。", "放單成功;產生提料票,無未預期 500。"),
("「成品出倉」`/doworkbench`:撳單 → 掃碼提料 → 填箱數列印。", "票可撳、可提、可完成;狀態「待撳單」→「提貨中」→「已完成」。"),
("若涉及加單/車線-X:開「加單」分頁與「車線-X」對照。", "票出現在正確樓層/車線;指派篩選與顯示一致。"),
],
),
(
"pickOrder",
"提料單 / Workbench",
[
("「成品出倉」撳單並完成一張提料票(測試庫)。", "掃碼/提交正常;完成後記錄頁可見。"),
("「查看提貨情況」核對該票狀態。", "狀態與負責人符合操作。"),
],
),
(
"SuggestedPickLot",
"建議批號 / UOM",
[
("出倉或提料時掃建議批號;刻意掃 UOM 不符批號。", "不符 UOM 被擋並有明確提示;相符批號可提交。"),
("標籤列印/批號列表只應出現同 UOM 選項(若本次改動涵蓋)。", "列表無錯誤 UOM 批號。"),
],
),
(
"job_order",
"工單",
[
("「搜索工單/ 建立工單」建立或搜索受影響工單。", "列表/詳情資料正確。"),
("依狀態走「放單」→「工單提料」或「工單生產流程」。", "狀態轉換符合預期,無阻塞錯誤。"),
],
),
(
"JobOrder",
"工單",
[
("「搜索工單/ 建立工單」驗證建立/搜索/放單。", "成功提示;狀態正確。"),
],
),
(
"modules/bom",
"BOM",
[
("「設定」→「匯入 BOM」/「BOM 明細」搜索受影響成品。", "可載入明細;啟用狀態正確。"),
("「建立工單」下拉是否仍能選該 BOM。", "啟用可選、停用不可選。"),
],
),
(
"production_schedule",
"排程",
[
("「排程」→「生產排程」搜索相關生產日 →「詳細」。", "明細數量合理。"),
("(測試庫)「自動生成工單」。", "產生工單或明確錯誤訊息。"),
],
),
(
"ProductionSchedule",
"排程",
[
("「排程」頁驗證預測/搜索/詳細。", "畫面與 API 正常。"),
],
),
(
"stock",
"庫存 / 上架 / 出入倉",
[
("依改動點進「上架掃碼」或相關庫存查詢頁。", "掃碼/查詢結果與庫存數量合理。"),
("做一筆小量入/出/調撥(測試庫)。", "成功;庫存異動可查。"),
],
),
(
"StockIn",
"來貨 / 品檢",
[
("「工單生產流程」→「品檢」或待 QC 列表。", "可開品檢;確定後狀態更新。"),
],
),
(
"putAway",
"上架",
[
("「上架掃碼」:貨品 QR → 倉庫 QR →「確定及上架貨物」。", "上架成功;待上架數量減少。"),
],
),
(
"m18",
"M18 同步",
[
("對受影響主檔執行同步或查看最近同步結果(測試環境)。", "對應欄位寫入 MTMS;錯誤有日誌。"),
],
),
(
"onpack2030",
"標籤 / OnPack",
[
("對新增/修改的貨品編號列印標籤(測試機)。", "圖檔/job 正確;可印出。"),
],
),
(
"db/changelog",
"資料庫變更",
[
("部署後確認 Liquibase/changelog 已套用(或啟動 log 無 changeset 失敗)。", "DB schema/資料符合 changeset。"),
("用相關畫面或 SQL 抽樣驗證新欄位/約束。", "讀寫正常,無缺欄錯誤。"),
],
),
(
"modules/master",
"主檔 (Item/Shop 等)",
[
("主檔搜索受影響編號,核對顯示欄位。", "名稱/單位/類型等與預期一致。"),
],
),
(
"Inventory",
"庫存查詢",
[
("庫存搜索頁用受影響貨品/倉位查詢。", "批號、數量、單位正確。"),
],
),
]

KEYWORD_HINTS: list[tuple[re.Pattern[str], tuple[str, str]]] = [
(
re.compile(r"uom|單位", re.I),
("掃一筆 UOM 不符的批號/物料。", "系統拒絕或明確提示;相符者可過。"),
),
(
re.compile(r"isextra|etra|加單", re.I),
("「成品出倉」→「加單」檢視該日票。", "加單票可見且可撳單。"),
),
(
re.compile(r"truck\s*x|車線-?x|車線x", re.I),
("核對「車線-X」票在 2/F/4/F 顯示。", "出現在正確樓層區塊,可指派。"),
),
(
re.compile(r"label|print|列印|標籤|onpack", re.I),
("選打印機後列印標籤/送貨單標籤。", "成功列印;內容正確。"),
),
(
re.compile(r"fix|bug|repair|修正|修復", re.I),
("重現原問題步驟一次。", "問題不再出現;無新副作用。"),
),
]


@dataclass
class CommitPlan:
sha: str
short: str
subject: str
body: str
author: str
date: str
files: list[str] = field(default_factory=list)
areas: list[str] = field(default_factory=list)
tests: list[tuple[str, str]] = field(default_factory=list)


def run(cmd: list[str]) -> str:
r = subprocess.run(
cmd,
cwd=ROOT,
capture_output=True,
text=True,
encoding="utf-8",
errors="replace",
)
if r.returncode != 0:
raise SystemExit((r.stderr or r.stdout or f"failed: {cmd}").strip())
return r.stdout.strip()


def list_commits(rev_range: str) -> list[str]:
out = run(["git", "log", "--reverse", "--format=%H", rev_range])
return [ln.strip() for ln in out.splitlines() if ln.strip()]


def load_commit(sha: str) -> CommitPlan:
subject = run(["git", "log", "-1", "--format=%s", sha])
body = run(["git", "log", "-1", "--format=%b", sha])
author = run(["git", "log", "-1", "--format=%an", sha])
date = run(["git", "log", "-1", "--format=%cs", sha])
files_raw = run(["git", "diff-tree", "--no-commit-id", "--name-only", "-r", sha])
files = [f for f in files_raw.splitlines() if f.strip()]
plan = CommitPlan(
sha=sha,
short=sha[:8],
subject=subject or "(no message)",
body=(body or "").strip(),
author=author,
date=date,
files=files,
)
build_tests(plan)
return plan


def build_tests(plan: CommitPlan) -> None:
seen_areas: set[str] = set()
seen_steps: set[str] = set()
tests: list[tuple[str, str]] = []

def add(step: str, expected: str) -> None:
key = step.strip()
if key in seen_steps:
return
seen_steps.add(key)
tests.append((step, expected))

blob = " ".join(plan.files) + "\n" + plan.subject + "\n" + plan.body

for path_key, area, pairs in AREA_RULES:
if any(path_key in f.replace("\\", "/") for f in plan.files) or path_key.lower() in blob.lower():
if area not in seen_areas:
seen_areas.add(area)
plan.areas.append(area)
for step, exp in pairs:
add(step, exp)

for pat, pair in KEYWORD_HINTS:
if pat.search(blob):
add(pair[0], pair[1])

# Generic fallbacks
if not tests:
if plan.files:
add(
f"依 commit 變更檔抽樣驗證(共 {len(plan.files)} 個檔)。主要檔:{plan.files[0]}",
"相關 API/畫面無 500;行為符合 commit 說明。",
)
else:
add("確認此 commit 無業務檔變更(empty / merge)。", "無需功能測試或僅煙霧測試。")

add(
"部署後煙霧:登入系統,開側欄主要入口一次(排程/工單/送貨訂單/成品出倉)。",
"頁面可開、無全域錯誤橫幅。",
)
add(
"(回歸)與本改動相鄰但未改的主流程走一輪 Happy path。",
"無明顯回退。",
)

if not plan.subject or plan.subject.strip().lower() in ("no message", "(no message)"):
add(
"向作者確認此 commit 的實際意圖(訊息為 empty/no message)。",
"補上說明後再簽核上線。",
)

plan.tests = tests


def slugify(text: str, max_len: int = 40) -> str:
"""ASCII-only filename slug (avoids Windows console / zip garbling)."""
tokens = re.findall(r"[a-z0-9]+", text.lower())
seen: set[str] = set()
ordered: list[str] = []
for t in tokens:
if t in seen or len(t) < 2:
continue
seen.add(t)
ordered.append(t)
s = "_".join(ordered)[:max_len].strip("_")
return s or "commit"


def set_run_font(run, *, east_asia: str = "Microsoft JhengHei", ascii_font: str = "Calibri", size: Pt | None = None) -> None:
run.font.name = ascii_font
run._element.rPr.rFonts.set(qn("w:eastAsia"), east_asia)
if size is not None:
run.font.size = size


def add_para(doc: Document, text: str, *, bold: bool = False, italic: bool = False, size: Pt | None = None) -> None:
p = doc.add_paragraph()
run = p.add_run(text)
set_run_font(run, size=size or Pt(11))
run.bold = bold
run.italic = italic


def write_docx(plan: CommitPlan, out_path: Path) -> None:
doc = Document()
section = doc.sections[0]
section.top_margin = Cm(1.8)
section.bottom_margin = Cm(1.8)
section.left_margin = Cm(2)
section.right_margin = Cm(2)

style = doc.styles["Normal"]
style.font.name = "Calibri"
style.font.size = Pt(11)
style._element.rPr.rFonts.set(qn("w:eastAsia"), "Microsoft JhengHei")

title = doc.add_heading(level=0)
tr = title.add_run(f"Test plan — {plan.short}")
set_run_font(tr, size=Pt(18))

add_para(doc, plan.subject, bold=True, size=Pt(12))
meta = (
f"Commit: {plan.sha}\n"
f"Date: {plan.date} | Author: {plan.author}\n"
f"Generated: {datetime.now(timezone.utc).strftime('%Y-%m-%d %H:%M UTC')}"
)
add_para(doc, meta, italic=True, size=Pt(9))

doc.add_heading("Summary", level=1)
add_para(doc, plan.subject)
if plan.body:
add_para(doc, plan.body)

doc.add_heading("Areas (auto-detected)", level=1)
if plan.areas:
for a in plan.areas:
doc.add_paragraph(a, style="List Bullet")
else:
add_para(doc, "(No area rule matched — generic steps only)")

doc.add_heading("Files touched", level=1)
for f in plan.files[:40]:
doc.add_paragraph(f, style="List Bullet")
if len(plan.files) > 40:
add_para(doc, f"… and {len(plan.files) - 40} more files")

doc.add_heading("Test plan", level=1)
note = doc.add_paragraph()
nr = note.add_run(
"Auto-generated from paths + commit message. Refine before sign-off on critical deploys."
)
set_run_font(nr, size=Pt(9))
nr.italic = True
nr.font.color.rgb = RGBColor(0x66, 0x66, 0x66)

table = doc.add_table(rows=1 + len(plan.tests), cols=3)
table.style = "Table Grid"
headers = ["#", "Steps", "Expected result"]
for i, h in enumerate(headers):
cell = table.rows[0].cells[i]
cell.text = h
for p in cell.paragraphs:
for r in p.runs:
r.bold = True
set_run_font(r)

for idx, (step, exp) in enumerate(plan.tests, start=1):
row = table.rows[idx]
row.cells[0].text = str(idx)
row.cells[1].text = step
row.cells[2].text = exp
for c in row.cells:
for p in c.paragraphs:
for r in p.runs:
set_run_font(r, size=Pt(10))

doc.add_heading("Out of scope / notes", level=1)
add_para(
doc,
"Frontend-only changes may live in FPSMS-frontend — verify paired repo if UI behavior is expected.",
)
add_para(doc, "Rollback: revert this commit or redeploy previous backend build.")

footer = doc.sections[0].footer.paragraphs[0]
footer.text = f"MTMS commit test plan · {plan.short} · {plan.date}"
footer.alignment = WD_ALIGN_PARAGRAPH.CENTER

out_path.parent.mkdir(parents=True, exist_ok=True)
doc.save(out_path)


def write_md(plan: CommitPlan, out_path: Path) -> None:
lines = [
f"# Test plan — {plan.short}",
"",
f"**{plan.subject}**",
"",
f"- Commit: `{plan.sha}`",
f"- Date: {plan.date}",
f"- Author: {plan.author}",
"",
"## Areas",
"",
]
for a in plan.areas or ["(generic)"]:
lines.append(f"- {a}")
lines += ["", "## Files", ""]
for f in plan.files:
lines.append(f"- `{f}`")
lines += ["", "## Test plan", "", "| # | Steps | Expected result |", "|---|--------|-----------------|"]
for i, (s, e) in enumerate(plan.tests, 1):
lines.append(f"| {i} | {s.replace('|', '/')} | {e.replace('|', '/')} |")
lines += ["", "## Rollback", "", f"- Revert `{plan.short}` / previous build", ""]
out_path.write_text("\n".join(lines), encoding="utf-8")


def write_index(out_dir: Path, plans: list[CommitPlan], files: list[str]) -> None:
lines = [
"# Commit test plans (auto-generated)",
"",
f"Generated: {datetime.now(timezone.utc).strftime('%Y-%m-%d %H:%M UTC')}",
"",
"| Date | SHA | Subject | Word |",
"|------|-----|---------|------|",
]
for plan, name in zip(plans, files):
subj = plan.subject.replace("|", "/")
lines.append(f"| {plan.date} | `{plan.short}` | {subj} | [{name}]({name}) |")
lines.append("")
(out_dir / "_index.md").write_text("\n".join(lines), encoding="utf-8")


def main() -> None:
ap = argparse.ArgumentParser(description="Generate Word test plans per commit")
ap.add_argument(
"range",
help="Git revision range, e.g. HEAD~10..HEAD (use A..B exclusive-start semantics)",
)
ap.add_argument(
"--out-dir",
type=Path,
default=DEFAULT_OUT,
help=f"Output folder (default: {DEFAULT_OUT.relative_to(ROOT)})",
)
ap.add_argument("--also-md", action="store_true", help="Also write .md next to each .docx")
ap.add_argument(
"--limit",
type=int,
default=0,
help="Max commits to process (0 = all)",
)
args = ap.parse_args()

out_dir = args.out_dir if args.out_dir.is_absolute() else ROOT / args.out_dir
out_dir.mkdir(parents=True, exist_ok=True)

shas = list_commits(args.range)
if args.limit and args.limit > 0:
shas = shas[-args.limit :]
if not shas:
print("No commits in range.", file=sys.stderr)
sys.exit(1)

plans: list[CommitPlan] = []
names: list[str] = []
for sha in shas:
plan = load_commit(sha)
plans.append(plan)
# Stable name per commit (re-run overwrites same SHA)
fname = f"{plan.date}_{plan.short}_{slugify(plan.subject)}.docx"
target = out_dir / fname
write_docx(plan, target)
names.append(target.name)
print(f"Wrote {target.relative_to(ROOT)}")
if args.also_md:
md_path = target.with_suffix(".md")
write_md(plan, md_path)
print(f"Wrote {md_path.relative_to(ROOT)}")

write_index(out_dir, plans, names)
print(f"Wrote { (out_dir / '_index.md').relative_to(ROOT) }")
print(f"Done: {len(plans)} plan(s) → {out_dir.relative_to(ROOT)}")


if __name__ == "__main__":
main()

+ 120
- 0
scripts/generate_deploy_test_plan.py Datei anzeigen

@@ -0,0 +1,120 @@
#!/usr/bin/env python3
# -*- coding: utf-8 -*-
"""
Scaffold a deploy / QA note from a git commit range.

Usage (from repo root):
python scripts/generate_deploy_test_plan.py HEAD~5..HEAD
python scripts/generate_deploy_test_plan.py abc1234..def5678 --out docs/deploy/20260801_example.md

The script lists commits and touched files. You (or the agent) still fill
test steps and expected results after reading the diffs.
"""

from __future__ import annotations

import argparse
import subprocess
import sys
from datetime import date
from pathlib import Path

ROOT = Path(__file__).resolve().parents[1]


def run(args: list[str]) -> str:
r = subprocess.run(
args,
cwd=ROOT,
capture_output=True,
text=True,
encoding="utf-8",
errors="replace",
)
if r.returncode != 0:
raise SystemExit(r.stderr or r.stdout or f"command failed: {args}")
return r.stdout.strip()


def main() -> None:
p = argparse.ArgumentParser(description="Scaffold deploy test plan from git range")
p.add_argument(
"range",
help="Git revision range, e.g. HEAD~5..HEAD or origin/production..HEAD",
)
p.add_argument(
"--out",
type=Path,
default=None,
help="Optional output path under docs/deploy/",
)
p.add_argument("--title", default="Deploy note (draft)", help="Document title")
args = p.parse_args()

log = run(["git", "log", "--oneline", args.range])
if not log:
print("No commits in range.", file=sys.stderr)
sys.exit(1)

stat = run(["git", "diff", "--stat", args.range])
name_status = run(["git", "diff", "--name-status", args.range])

commits = [ln for ln in log.splitlines() if ln.strip()]
commit_bullets = "\n".join(f"- `{c[:7]}` — {c[8:]}" for c in commits)

body = f"""# {args.title}
Date: {date.today().isoformat()}
Branch / build: (fill)
Range: `{args.range}`
Author: (fill)

> Auto-scaffolded from git. **Replace the Test plan with real steps** after reviewing the diff.

## Summary
- (TODO: 1–3 bullets — user-facing impact)

## Scope
- Backend: (see files below)
- Frontend: (check paired repo if UI)
- DB / Liquibase: (none / list changelog files)
- Config / ops: (none / list)

## Commits
{commit_bullets}

## Files touched
```
{name_status}
```

### Diffstat
```
{stat}
```

## Test plan
| # | Steps (who / where / data) | Expected result |
|---|----------------------------|-----------------|
| 1 | TODO — happy path | TODO |
| 2 | TODO — edge / failure case | TODO |
| 3 | TODO — regression on related screen | TODO |

## Out of scope / not tested
- (TODO)

## Rollback
- Revert range `{args.range}` / redeploy previous build
"""

if args.out:
out = args.out if args.out.is_absolute() else ROOT / args.out
out.parent.mkdir(parents=True, exist_ok=True)
out.write_text(body, encoding="utf-8")
print(f"Wrote {out.relative_to(ROOT)}")
else:
sys.stdout.reconfigure(encoding="utf-8")
print(body)


if __name__ == "__main__":
main()

+ 239
- 0
scripts/generate_m18_mapping_docs.py Datei anzeigen

@@ -0,0 +1,239 @@
#!/usr/bin/env python3
# -*- coding: utf-8 -*-
"""
Generate MTMS ↔ M18 mapping snippets from Kotlin source of truth.

Usage (from repo root):
python scripts/generate_m18_mapping_docs.py

Outputs:
docs/generated/m18-item-type-mapping.md
docs/generated/m18-stsearch-types.md

Re-run after changing:
- modules/master/web/models/NewItemRequest.kt (ItemType / M18ItemType)
- m18/model/M18MasterDataRequest.kt (StSearchType)
- m18/service/M18MasterDataService.kt (udfProducttype when-branches)
"""

from __future__ import annotations

import re
from datetime import datetime, timezone
from pathlib import Path

ROOT = Path(__file__).resolve().parents[1]
OUT_DIR = ROOT / "docs" / "generated"

NEW_ITEM_REQUEST = (
ROOT
/ "src/main/java/com/ffii/fpsms/modules/master/web/models/NewItemRequest.kt"
)
MASTER_DATA_REQUEST = (
ROOT / "src/main/java/com/ffii/fpsms/m18/model/M18MasterDataRequest.kt"
)
MASTER_DATA_SERVICE = (
ROOT / "src/main/java/com/ffii/fpsms/m18/service/M18MasterDataService.kt"
)
INVENTORY_I18N = (
ROOT.parent / "FPSMS-frontend" / "src" / "i18n" / "zh" / "inventory.json"
)

# UI labels when inventory.json is unavailable (fallback)
FALLBACK_UI = {
"mat": "原料",
"consumables": "消耗品",
"non-consumables": "非消耗品",
"fg": "成品",
"sfg": "半成品",
"item": "貨品",
"cmb": "消耗品",
"wip": "半成品",
"nm": "雜項及非消耗品",
}

# Known M18 udfProducttype values seen in the wild that are NOT in M18ItemType
# (documented as gaps so ops/dev notice).
KNOWN_UNMAPPED_M18_VALUES = [
("CMB", "Seen on M18 pro.udfProducttype (e.g. MG1852). Falls through to mat."),
]


def parse_kotlin_string_enum(text: str, enum_name: str) -> list[tuple[str, str]]:
"""Parse active (non-commented) `enum class Foo(...) { NAME("x"), ... }`."""
# Only match enum declarations that start a line (optional indent), not //enum
m = re.search(
rf"(?m)^[ \t]*enum class {re.escape(enum_name)}\([^)]*\)\s*\{{(.*?)^[ \t]*\}}",
text,
re.DOTALL,
)
if not m:
raise SystemExit(f"Could not find enum class {enum_name}")
body = m.group(1)
return re.findall(r"(\w+)\s*\(\s*\"([^\"]+)\"\s*\)", body)


def parse_producttype_when_branches(service_text: str) -> list[tuple[str, str]]:
"""
Extract first `when (pro.udfProducttype) { M18ItemType.X.type -> ItemType.Y.type ... }`
Returns list of (M18ItemTypeConst, ItemTypeConst).
"""
m = re.search(
r"when\s*\(\s*pro\.udfProducttype\s*\)\s*\{(.*?)else\s*->\s*ItemType\.(\w+)\.type",
service_text,
re.DOTALL,
)
if not m:
raise SystemExit("Could not find udfProducttype when-branch in M18MasterDataService")
body, else_item = m.group(1), m.group(2)
pairs = re.findall(
r"M18ItemType\.(\w+)\.type\s*->\s*ItemType\.(\w+)\.type",
body,
)
return pairs + [("__else__", else_item)]


def load_ui_labels() -> dict[str, str]:
labels = dict(FALLBACK_UI)
if not INVENTORY_I18N.is_file():
return labels
# Minimal JSON-ish extract of "key": "value" string pairs
text = INVENTORY_I18N.read_text(encoding="utf-8")
for k, v in re.findall(r'"([^"]+)"\s*:\s*"([^"]*)"', text):
labels[k] = v
return labels


def write_item_type_doc(
item_types: list[tuple[str, str]],
m18_types: list[tuple[str, str]],
when_pairs: list[tuple[str, str]],
ui: dict[str, str],
) -> None:
item_by_const = {c: v for c, v in item_types}
m18_by_const = {c: v for c, v in m18_types}
mapped_m18_consts = {a for a, b in when_pairs if a != "__else__"}

lines: list[str] = []
lines.append("<!-- AUTO-GENERATED by scripts/generate_m18_mapping_docs.py — do not edit by hand -->")
lines.append("")
lines.append("# M18 `udfProducttype` → MTMS `items.type`")
lines.append("")
lines.append(f"_Generated: {datetime.now(timezone.utc).strftime('%Y-%m-%d %H:%M UTC')}_")
lines.append("")
lines.append("**Source of truth**")
lines.append("")
lines.append("- Enums: `NewItemRequest.kt` → `ItemType`, `M18ItemType`")
lines.append("- Sync: `M18MasterDataService.saveProduct` / `saveProducts` (`when (pro.udfProducttype)`)")
lines.append("- UI labels (inventory): `FPSMS-frontend/src/i18n/zh/inventory.json`")
lines.append("")
lines.append("## Sync mapping")
lines.append("")
lines.append("| M18 `udfProducttype` (exact string) | `M18ItemType` | MTMS `items.type` | `ItemType` | Inventory UI (zh) |")
lines.append("|---|---|---|---|---|")

for m18_const, item_const in when_pairs:
if m18_const == "__else__":
mtms_val = item_by_const.get(item_const, "?")
lines.append(
f"| *(any other value / empty)* | — | `{mtms_val}` | `{item_const}` | {ui.get(mtms_val, '—')} |"
)
continue
m18_val = m18_by_const.get(m18_const, "?")
mtms_val = item_by_const.get(item_const, "?")
lines.append(
f"| `{m18_val}` | `{m18_const}` | `{mtms_val}` | `{item_const}` | {ui.get(mtms_val, '—')} |"
)

lines.append("")
lines.append("## Enum inventories")
lines.append("")
lines.append("### `M18ItemType`")
lines.append("")
lines.append("| Constant | String value | Used in sync `when`? |")
lines.append("|---|---|---|")
for c, v in m18_types:
used = "yes" if c in mapped_m18_consts else "**no**"
lines.append(f"| `{c}` | `{v}` | {used} |")

lines.append("")
lines.append("### `ItemType` (MTMS stored values)")
lines.append("")
lines.append("| Constant | `items.type` | Inventory UI (zh) |")
lines.append("|---|---|---|")
for c, v in item_types:
lines.append(f"| `{c}` | `{v}` | {ui.get(v, '—')} |")

lines.append("")
lines.append("## Known gaps (not auto-mapped)")
lines.append("")
lines.append("| M18 value seen | Effect | Notes |")
lines.append("|---|---|---|")
for val, note in KNOWN_UNMAPPED_M18_VALUES:
lines.append(f"| `{val}` | → `mat` (else) | {note} |")
lines.append("")
lines.append("Frontend Settings → Items edit also offers `cmb` / `wip` / `nm` as local types;")
lines.append("those are **not** written by the current M18 `udfProducttype` mapper.")
lines.append("")
lines.append("## Regenerate")
lines.append("")
lines.append("```bash")
lines.append("python scripts/generate_m18_mapping_docs.py")
lines.append("```")
lines.append("")

OUT_DIR.mkdir(parents=True, exist_ok=True)
path = OUT_DIR / "m18-item-type-mapping.md"
path.write_text("\n".join(lines), encoding="utf-8")
print(f"Wrote {path.relative_to(ROOT)}")


def write_stsearch_doc(st_types: list[tuple[str, str]]) -> None:
lines: list[str] = []
lines.append("<!-- AUTO-GENERATED by scripts/generate_m18_mapping_docs.py — do not edit by hand -->")
lines.append("")
lines.append("# M18 `StSearchType` (master list APIs)")
lines.append("")
lines.append(f"_Generated: {datetime.now(timezone.utc).strftime('%Y-%m-%d %H:%M UTC')}_")
lines.append("")
lines.append("**Source:** `m18/model/M18MasterDataRequest.kt`")
lines.append("")
lines.append("| Constant | `stSearch` value | Typical MTMS sync target |")
lines.append("|---|---|---|")
hints = {
"PRODUCT": "items (+ item_uom via prices)",
"VENDOR": "shop (`type=supplier`)",
"CUSTOMER": "(enum present; sync usage varies)",
"UNIT": "uom_conversion (+ m18 cunit)",
"CURRENCY": "currency",
"BOM": "bom / bom_material (udfbomforshop)",
"BUSINESS_UNIT": "shop (`type=shop`)",
}
for c, v in st_types:
lines.append(f"| `{c}` | `{v}` | {hints.get(c, '—')} |")
lines.append("")

path = OUT_DIR / "m18-stsearch-types.md"
path.write_text("\n".join(lines), encoding="utf-8")
print(f"Wrote {path.relative_to(ROOT)}")


def main() -> None:
new_item = NEW_ITEM_REQUEST.read_text(encoding="utf-8")
master_req = MASTER_DATA_REQUEST.read_text(encoding="utf-8")
service = MASTER_DATA_SERVICE.read_text(encoding="utf-8")

item_types = parse_kotlin_string_enum(new_item, "ItemType")
m18_types = parse_kotlin_string_enum(new_item, "M18ItemType")
st_types = parse_kotlin_string_enum(master_req, "StSearchType")
# StSearchType uses `value` not always matching parse — enum uses (val value: String)
# Our regex still works for NAME("x")
when_pairs = parse_producttype_when_branches(service)
ui = load_ui_labels()

write_item_type_doc(item_types, m18_types, when_pairs, ui)
write_stsearch_doc(st_types)


if __name__ == "__main__":
main()

+ 16
- 1
src/main/java/com/ffii/fpsms/config/security/SecurityConfig.java Datei anzeigen

@@ -74,6 +74,11 @@ public class SecurityConfig {
return new BCryptPasswordEncoder();
}

/**
* FP-MTMS Version Checklist | Functions Ref. No. 51 | v1.0.0 | 2026-08-05
* FP-MTMS Version Checklist | Functions Ref. No. 3 | v1.0.5 | 2026-08-05
* (stockAdjustment/submit → INVENTORY_ADJUST only)
*/
@Bean
@Order(1)
public SecurityFilterChain filterChain(HttpSecurity http) throws Exception {
@@ -112,7 +117,17 @@ public class SecurityConfig {
.requestMatchers(HttpMethod.GET, "/label-printer-monitor/odometer-stats")
.hasAnyAuthority("TESTING", "ADMIN")
.requestMatchers(HttpMethod.POST, "/stockAdjustment/submit")
.hasAnyAuthority("ADMIN", "INVENTORY_ADJUST")
.hasAuthority("INVENTORY_ADJUST")
.requestMatchers(HttpMethod.GET, "/inventoryLotLine/trace")
.hasAuthority("ITEM_TRACING")
.requestMatchers(HttpMethod.GET, "/inventoryLotLine/trace/location/**")
.hasAuthority("ITEM_TRACING")
/* 工單 生產流程 完成工單 */
.requestMatchers(HttpMethod.POST, "/product-process/Demo/ProcessLine/complete/**")
.hasAuthority("PRODUCT_PROCESS")
/* 工序「已完成」(Just Pass):僅 ADMIN */
.requestMatchers(HttpMethod.POST, "/product-process/Demo/ProcessLine/pass/**")
.hasAuthority("ADMIN")
.anyRequest().authenticated())
.httpBasic(httpBasic -> httpBasic.authenticationEntryPoint(
(request, response, authException) -> sendUnauthorizedJson(response, "Unauthorized", "UNAUTHORIZED")))


+ 22
- 0
src/main/java/com/ffii/fpsms/modules/deliveryOrder/service/DoFloorSupplierSettingsService.kt Datei anzeigen

@@ -69,6 +69,28 @@ open class DoFloorSupplierSettingsService(
return null
}

/**
* FP-MTMS Version Checklist | Functions Ref. No. 37 | v1.0.1 | 2026-08-03
* Warehouse store scope for workbench lot suggestions (`2/F` / `4/F`).
* Uses ticket [dbStoreId] when set; when null (Truck X), derives floor from linked DO
* supplier codes (4F settings list wins; else 2F). Does not write DOPO.storeId.
*/
open fun resolveSuggestionStoreId(dbStoreId: String?, supplierCodes: Collection<String?>): String {
val raw = dbStoreId?.trim()?.takeIf { it.isNotEmpty() }
if (raw != null) {
return when (raw.replace("/", "").uppercase(Locale.ROOT)) {
"2F" -> "2/F"
"4F" -> "4/F"
else -> raw
}
}
val (s2, s4) = loadDoFloorSupplierLists()
val any4F = supplierCodes.any { code ->
preferredStoreFloorForSupplier(code, s2, s4) == "4F"
}
return if (any4F) "4/F" else "2/F"
}

data class SqlPreferredFloorCases(
/** 例如 `CASE WHEN s.code IN (...) THEN '4F' ... END`(單行,可嵌入原生 SQL) */
val floorStringCase: String,


+ 34
- 3
src/main/java/com/ffii/fpsms/modules/deliveryOrder/service/DoReleaseCoordinatorService.kt Datei anzeigen

@@ -706,8 +706,11 @@ class DoReleaseCoordinatorService(
val existing = candidates.firstOrNull { c ->
c.truckId == first.truckId &&
c.truckDepartureTime == first.truckDepartureTime &&
c.truckLanceCode == first.truckLanceCode
} ?: candidates.minByOrNull { it.id ?: Long.MAX_VALUE }
c.truckLanceCode == first.truckLanceCode &&
(storeId != null || doPickOrderMatchesTruckXPreferredFloor(c, first.preferredFloor))
} ?: candidates
.filter { storeId != null || doPickOrderMatchesTruckXPreferredFloor(it, first.preferredFloor) }
.minByOrNull { it.id ?: Long.MAX_VALUE }
if (existing != null) {
println(
" DEBUG: Merging batch into existing DoPickOrder id=${existing.id}, ticket=${existing.ticketNo} " +
@@ -754,6 +757,33 @@ class DoReleaseCoordinatorService(

println(" DEBUG: Created ${results.size} DoPickOrderLine records")
}

/**
* Truck X (`storeId` null): only merge when every linked DO supplier preferred floor
* matches [preferredFloor] (4F list wins; else 2F). Empty tickets are allowed.
*/
private fun doPickOrderMatchesTruckXPreferredFloor(
dpo: DoPickOrder,
preferredFloor: String?,
): Boolean {
val target = when (preferredFloor?.trim()?.uppercase(java.util.Locale.ROOT)?.replace("/", "")) {
"4F" -> "4F"
else -> "2F"
}
val id = dpo.id ?: return true
val lines = doPickOrderLineRepository.findByDoPickOrderIdAndDeletedFalse(id)
if (lines.isEmpty()) return true
val (_, s4f) = doFloorSupplierSettingsService.loadDoFloorSupplierLists()
fun floorOf(supplierCode: String?): String =
if (!supplierCode.isNullOrBlank() && s4f.contains(supplierCode.trim())) "4F" else "2F"
for (line in lines) {
val doId = line.doOrderId ?: continue
val dorder = deliveryOrderRepository.findById(doId).orElse(null) ?: continue
if (floorOf(dorder.supplier?.code) != target) return false
}
return true
}

private fun checkPickOrderHasStockIssues(doPickOrderId: Long): Boolean {
try {
// 1. 获取 do_pick_order 的所有 pick orders 数量
@@ -858,7 +888,8 @@ class DoReleaseCoordinatorService(
.firstOrNull {
it.requiredDeliveryDate == result.estimatedArrivalDate &&
it.truckDepartureTime == result.truckDepartureTime &&
it.truckLanceCode == result.truckLanceCode
it.truckLanceCode == result.truckLanceCode &&
(storeId != null || doPickOrderMatchesTruckXPreferredFloor(it, result.preferredFloor))
}
if (existingDoPickOrder != null) {


+ 42
- 7
src/main/java/com/ffii/fpsms/modules/deliveryOrder/service/DoWorkbenchDopoAssignmentService.kt Datei anzeigen

@@ -23,6 +23,7 @@ open class DoWorkbenchDopoAssignmentService(
private val pickOrderRepository: PickOrderRepository,
private val suggestedPickLotWorkbenchService: SuggestedPickLotWorkbenchService,
private val stockOutLineWorkbenchService: StockOutLineWorkbenchService,
private val doFloorSupplierSettingsService: DoFloorSupplierSettingsService,
) {

@Transactional
@@ -95,6 +96,7 @@ open class DoWorkbenchDopoAssignmentService(
}

/**
* FP-MTMS Version Checklist | Functions Ref. No. 37 | v1.0.1 | 2026-08-03
* Same UX as [DoPickOrderAssignmentService.assignByLane] but candidates come from
* [delivery_order_pick_order] (+ unassigned [pick_order]), not [do_pick_order].
*/
@@ -145,7 +147,7 @@ open class DoWorkbenchDopoAssignmentService(
params["loadingSequence"] = request.loadingSequence
}
if (isisExtraReleaseType(request.releaseType)) {
sql.append(WorkbenchReleaseTypeSupport.legacyIsExtraSql())
sql.append(WorkbenchReleaseTypeSupport.etraFamilySql())
} else {
sql.append(WorkbenchReleaseTypeSupport.assignFilterSql(request.releaseType))
}
@@ -206,7 +208,10 @@ open class DoWorkbenchDopoAssignmentService(
)
}

/** Legacy lane assign (V1): old FG-style (no atomic conflict guard). */
/**
* FP-MTMS Version Checklist | Functions Ref. No. 37 | v1.0.1 | 2026-08-03
* Legacy lane assign (V1): old FG-style (no atomic conflict guard).
*/
@Transactional
open fun assignByLaneForWorkbenchV1(request: AssignByLaneRequest): MessageResponse {
val user = userRepository.findById(request.userId).orElse(null)
@@ -253,7 +258,7 @@ open class DoWorkbenchDopoAssignmentService(
params["loadingSequence"] = request.loadingSequence
}
if (isisExtraReleaseType(request.releaseType)) {
sql.append(WorkbenchReleaseTypeSupport.legacyIsExtraSql())
sql.append(WorkbenchReleaseTypeSupport.etraFamilySql())
} else {
sql.append(WorkbenchReleaseTypeSupport.assignFilterSql(request.releaseType))
}
@@ -395,18 +400,19 @@ open class DoWorkbenchDopoAssignmentService(
)
}
// 下方逻辑先保持不变(你后续再做第二阶段批次化)
// Suggestion store scope: use DOPO.storeId when set; Truck X (null) → 2/F or 4/F from DO suppliers.
val storeIdRow = jdbcDao.queryForList(
"SELECT storeId FROM fpsmsdb.delivery_order_pick_order WHERE id = :id AND deleted = 0",
mapOf("id" to dopoId),
).firstOrNull()
val storeIdKey = storeIdRow?.keys?.find { it.equals("storeId", true) }
val storeId = storeIdKey?.let { storeIdRow[it]?.toString() }?.trim()?.takeIf { it.isNotEmpty() }
val dbStoreId = storeIdKey?.let { storeIdRow[it]?.toString() }?.trim()?.takeIf { it.isNotEmpty() }
val suggestionStoreId = resolveSuggestionStoreIdForDopo(dopoId, dbStoreId)

for (poId in poIds) {
suggestedPickLotWorkbenchService.primeNextSingleLotSuggestionsForPickOrder(
pickOrderId = poId,
storeId = storeId,
storeId = suggestionStoreId,
excludeWarehouseCodes = null,
)
stockOutLineWorkbenchService.ensureStockOutLinesForPickOrderNoHold(poId, userId)
@@ -434,4 +440,33 @@ open class DoWorkbenchDopoAssignmentService(
)
)
}

/**
* FP-MTMS Version Checklist | Functions Ref. No. 37 | v1.0.1 | 2026-08-03
* Truck X: derive 2/F|4/F from linked DO suppliers when DOPO.storeId is null.
*/
private fun resolveSuggestionStoreIdForDopo(dopoId: Long, dbStoreId: String?): String {
if (!dbStoreId.isNullOrBlank()) {
return doFloorSupplierSettingsService.resolveSuggestionStoreId(dbStoreId, emptyList())
}
val rows = try {
jdbcDao.queryForList(
"""
SELECT DISTINCT s.code AS supplierCode
FROM fpsmsdb.pick_order po
INNER JOIN fpsmsdb.delivery_order d ON d.id = po.doId AND d.deleted = 0
LEFT JOIN fpsmsdb.shop s ON s.id = d.supplierId AND s.deleted = 0
WHERE po.deliveryOrderPickOrderId = :dopoId AND po.deleted = 0
""".trimIndent(),
mapOf("dopoId" to dopoId),
)
} catch (_: Exception) {
emptyList()
}
val codes = rows.mapNotNull { row ->
val k = row.keys.find { it.equals("supplierCode", true) } ?: return@mapNotNull null
row[k]?.toString()
}
return doFloorSupplierSettingsService.resolveSuggestionStoreId(null, codes)
}
}

+ 319
- 39
src/main/java/com/ffii/fpsms/modules/deliveryOrder/service/DoWorkbenchMainService.kt Datei anzeigen

@@ -45,6 +45,7 @@ import com.ffii.fpsms.modules.stock.entity.enum.InventoryLotLineStatus
import com.ffii.fpsms.modules.stock.entity.projection.StockOutLineInfo
import com.ffii.fpsms.modules.stock.service.StockOutLineWorkbenchService
import com.ffii.fpsms.modules.stock.service.SuggestedPickLotWorkbenchService
import com.ffii.fpsms.modules.stock.service.StockLedgerLotSnapshot
import com.ffii.fpsms.modules.stock.service.WorkbenchStockOutLinePickProgress
import com.ffii.fpsms.modules.stock.web.model.StockOutLineStatus
import org.springframework.stereotype.Service
@@ -134,6 +135,7 @@ open class DoWorkbenchMainService(
private val itemsRepository: ItemsRepository,
private val transactionManager: PlatformTransactionManager,
private val doReplenishmentService: DoReplenishmentService,
private val doFloorSupplierSettingsService: DoFloorSupplierSettingsService,
) {
@PersistenceContext
private lateinit var entityManager: EntityManager
@@ -265,6 +267,7 @@ open class DoWorkbenchMainService(
}

/**
* FP-MTMS Version Checklist | Functions Ref. No. 38 | v1.0.0 | 2026-08-03
* Workbench scan-pick (DO FG):
* 1) Post outbound on scanned inventory lot line first; on failure return a clear message.
* 2) If the lot runs out before this stock-out line’s chunk is filled and the user did not pass a short [qty],
@@ -323,6 +326,7 @@ open class DoWorkbenchMainService(
sol.handledBy = request.userId
if (sol.startTime == null) sol.startTime = LocalDateTime.now()
sol.endTime = LocalDateTime.now()
maybeMarkIssueJustComplete(sol, request.justComplete == true)
stockOutLIneRepository.saveAndFlush(sol)
val polZero = sol.pickOrderLine
updateJoPickOrderHandledByIfJobOrder(
@@ -617,6 +621,7 @@ if (sol.startTime == null) sol.startTime = LocalDateTime.now()
if (solEndStatus.equals(StockOutLineStatus.COMPLETE.status, ignoreCase = true)) {
sol.endTime = LocalDateTime.now()
}
maybeMarkIssueJustComplete(sol, request.justComplete == true)
stockOutLIneRepository.save(sol)
stockOutLIneRepository.flush()
sol.id?.let { suggestedPickLotWorkbenchService.linkSplToStockOutLineAfterWorkbenchPick(it) }
@@ -850,6 +855,9 @@ return MessageResponse(
)
}

// Truck X keeps storeId null; split count by supplier preferred floor (same as DO Search tabs).
val truckXFloorKey = actualStoreId.replace("/", "").trim().uppercase(java.util.Locale.ROOT)
val truckXFloorClause = truckXPreferredFloorExistsSql(truckXFloorKey)
val defaultCountSql = """
SELECT COUNT(DISTINCT dop.id) AS c
FROM fpsmsdb.delivery_order_pick_order dop
@@ -862,6 +870,7 @@ return MessageResponse(
SELECT 1 FROM fpsmsdb.pick_order po
WHERE po.deliveryOrderPickOrderId = dop.id AND po.deleted = 0
)
$truckXFloorClause
$releaseFilterClause
""".trimIndent()

@@ -882,43 +891,114 @@ return MessageResponse(
}

/**
* Workbench Etra view: all `delivery_order_pick_order` with `releaseType` = isExtra (case-insensitive),
* for one [requiredDeliveryDate], grouped by shop then by truck / time / loading sequence.
* FP-MTMS Version Checklist | Functions Ref. No. 37 | v1.0.1 | 2026-08-03
* Workbench Etra view: `isExtra` / `isExtrabatch` / `isExtrasingle` tickets for one day,
* grouped by shop then truck / time / loading sequence.
* Truck X (`車線-X`, DB `storeId` null) is included and split by supplier preferred floor
* into display `storeId` `2/F`/`4/F` (response only; not written to DB).
*/
open fun getWorkbenchEtraLaneSummary(requiredDate: LocalDate?): List<WorkbenchEtraShopLaneGroup> {
val targetDate = requiredDate ?: LocalDate.now()
val defaultTruck = truckRepository.findById(5577L).orElse(null)
val defaultTruckLaneCode = defaultTruck?.truckLanceCode ?: ""
val defaultTruckLaneCode = (defaultTruck?.truckLanceCode ?: "車線-X").trim()
val (_, s4f) = doFloorSupplierSettingsService.loadDoFloorSupplierLists()
val in4 = s4f.joinToString(", ") { "'" + it.replace("'", "''") + "'" }
.ifEmpty { "'__NO_4F_SUPPLIER__'" }
val floorExpr = "CASE WHEN s.code IN ($in4) THEN '4F' ELSE '2F' END"
val etraReleaseFilter = WorkbenchReleaseTypeSupport.etraFamilySql("dop.releaseType")

val sql = """
SELECT
dop.shopCode AS shopCode,
dop.shopName AS shopName,
dop.storeId AS storeId,
dop.truckDepartureTime AS truckDepartureTime,
dop.truckLanceCode AS truckLanceCode,
dop.loadingSequence AS loadingSequence,
COUNT(DISTINCT dop.id) AS total_cnt,
SUM(CASE WHEN dop.handledBy IS NULL THEN 1 ELSE 0 END) AS unassigned_cnt,
base.shopCode AS shopCode,
base.shopName AS shopName,
base.displayStoreId AS storeId,
base.truckDepartureTime AS truckDepartureTime,
base.truckLanceCode AS truckLanceCode,
base.loadingSequence AS loadingSequence,
COUNT(DISTINCT base.id) AS total_cnt,
SUM(CASE WHEN base.handledBy IS NULL THEN 1 ELSE 0 END) AS unassigned_cnt,
GROUP_CONCAT(
DISTINCT NULLIF(TRIM(dop.handlerName), '')
ORDER BY dop.handlerName
DISTINCT NULLIF(TRIM(base.handlerName), '')
ORDER BY base.handlerName
SEPARATOR ', '
) AS handler_names
FROM fpsmsdb.delivery_order_pick_order dop
WHERE dop.deleted = 0
AND LOWER(COALESCE(dop.releaseType, '')) = 'isExtra'
AND dop.requiredDeliveryDate = :requiredDate
AND dop.ticketStatus IN ('pending', 'released')
AND EXISTS (
SELECT 1 FROM fpsmsdb.pick_order po
WHERE po.deliveryOrderPickOrderId = dop.id AND po.deleted = 0
)
GROUP BY dop.shopCode, dop.shopName, dop.storeId, dop.truckDepartureTime, dop.truckLanceCode, dop.loadingSequence
FROM (
SELECT
dop.id AS id,
dop.shopCode AS shopCode,
dop.shopName AS shopName,
dop.handledBy AS handledBy,
dop.handlerName AS handlerName,
dop.truckDepartureTime AS truckDepartureTime,
dop.truckLanceCode AS truckLanceCode,
dop.loadingSequence AS loadingSequence,
CASE
WHEN (dop.storeId IS NULL OR TRIM(dop.storeId) = '')
AND TRIM(dop.truckLanceCode) = :defaultTruckLane
THEN CASE
WHEN EXISTS (
SELECT 1
FROM fpsmsdb.pick_order po
INNER JOIN fpsmsdb.delivery_order d ON d.id = po.doId AND d.deleted = 0
LEFT JOIN fpsmsdb.shop s ON s.id = d.supplierId AND s.deleted = 0
WHERE po.deliveryOrderPickOrderId = dop.id AND po.deleted = 0
AND ($floorExpr) = '4F'
)
AND NOT EXISTS (
SELECT 1
FROM fpsmsdb.pick_order po2
INNER JOIN fpsmsdb.delivery_order d2 ON d2.id = po2.doId AND d2.deleted = 0
LEFT JOIN fpsmsdb.shop s2 ON s2.id = d2.supplierId AND s2.deleted = 0
WHERE po2.deliveryOrderPickOrderId = dop.id AND po2.deleted = 0
AND (CASE WHEN s2.code IN ($in4) THEN '4F' ELSE '2F' END) <> '4F'
)
THEN '4/F'
WHEN EXISTS (
SELECT 1
FROM fpsmsdb.pick_order po
INNER JOIN fpsmsdb.delivery_order d ON d.id = po.doId AND d.deleted = 0
LEFT JOIN fpsmsdb.shop s ON s.id = d.supplierId AND s.deleted = 0
WHERE po.deliveryOrderPickOrderId = dop.id AND po.deleted = 0
AND ($floorExpr) = '2F'
)
AND NOT EXISTS (
SELECT 1
FROM fpsmsdb.pick_order po2
INNER JOIN fpsmsdb.delivery_order d2 ON d2.id = po2.doId AND d2.deleted = 0
LEFT JOIN fpsmsdb.shop s2 ON s2.id = d2.supplierId AND s2.deleted = 0
WHERE po2.deliveryOrderPickOrderId = dop.id AND po2.deleted = 0
AND (CASE WHEN s2.code IN ($in4) THEN '4F' ELSE '2F' END) <> '2F'
)
THEN '2/F'
ELSE NULL
END
ELSE dop.storeId
END AS displayStoreId
FROM fpsmsdb.delivery_order_pick_order dop
WHERE dop.deleted = 0
$etraReleaseFilter
AND dop.requiredDeliveryDate = :requiredDate
AND dop.ticketStatus IN ('pending', 'released')
AND EXISTS (
SELECT 1 FROM fpsmsdb.pick_order po
WHERE po.deliveryOrderPickOrderId = dop.id AND po.deleted = 0
)
) base
WHERE base.displayStoreId IS NOT NULL
OR TRIM(COALESCE(base.truckLanceCode, '')) <> :defaultTruckLane
GROUP BY
base.shopCode, base.shopName, base.displayStoreId,
base.truckDepartureTime, base.truckLanceCode, base.loadingSequence
""".trimIndent()

val rawRows: List<Map<String, Any?>> = try {
jdbcDao.queryForList(sql, mapOf("requiredDate" to targetDate))
jdbcDao.queryForList(
sql,
mapOf(
"requiredDate" to targetDate,
"defaultTruckLane" to defaultTruckLaneCode,
),
)
} catch (e: Exception) {
println("❌ getWorkbenchEtraLaneSummary: ${e.message}")
emptyList()
@@ -961,8 +1041,11 @@ return MessageResponse(

val aggs = rawRows.mapNotNull { row ->
val lance = cellStr(row, "truckLanceCode") ?: return@mapNotNull null
if (lance == defaultTruckLaneCode) return@mapNotNull null
val storeIdCol = cellStr(row, "storeId")
// Truck X must have derived display floor (2/F or 4/F); drop mixed/unknown.
if (lance == defaultTruckLaneCode && storeIdCol.isNullOrBlank()) {
return@mapNotNull null
}
val ttKey = row.keys.find { it.equals("truckDepartureTime", true) }
val ttVal = ttKey?.let { row[it] }
val sortTime = when (ttVal) {
@@ -996,13 +1079,14 @@ return MessageResponse(
}

return byShop.entries
.map { (key, group) ->
.map { (_, group) ->
val head = group.first()
val lanes = group
.sortedWith(
compareBy<EtraAgg> { it.sortTime }
.thenBy { it.lance }
.thenBy { it.loadingSequence ?: 999 }
.thenBy { it.storeId ?: "" }
)
.map {
val is4F = it.storeId?.replace("/", "")?.trim()?.equals("4F", ignoreCase = true) == true
@@ -1033,13 +1117,22 @@ return MessageResponse(
storeId: String?,
truck: String?,
releaseTypeFilter: String? = null,
floor: String? = null,
): List<ReleasedDoPickOrderListItem> =
queryWorkbenchReleasedDopoList(shopName, storeId, truck, beforeToday = true, releaseTypeFilter = releaseTypeFilter)
queryWorkbenchReleasedDopoList(
shopName,
storeId,
truck,
beforeToday = true,
releaseTypeFilter = releaseTypeFilter,
floor = floor,
)

/**
* @param requiredDeliveryDate when null, uses [LocalDate.now] (calendar today).
* When set, filters `dop.requiredDeliveryDate = :targetDate` (workbench date picker / select day).
* @param releaseTypeFilter when `isExtra` (case-insensitive), only `delivery_order_pick_order.releaseType = isExtra` rows.
* @param releaseTypeFilter when `isExtra`/`etra`, filters Etra family (`isExtra` / `isExtrabatch` / `isExtrasingle`).
* @param floor optional `2F`/`4F`: for Truck X (null storeId) filter by DO supplier preferred floor.
*/
open fun findWorkbenchReleasedDeliveryOrderPickOrdersForSelectionToday(
shopName: String?,
@@ -1047,6 +1140,7 @@ return MessageResponse(
truck: String?,
requiredDeliveryDate: LocalDate? = null,
releaseTypeFilter: String? = null,
floor: String? = null,
): List<ReleasedDoPickOrderListItem> =
queryWorkbenchReleasedDopoList(
shopName,
@@ -1055,6 +1149,7 @@ return MessageResponse(
beforeToday = false,
equalsDeliveryDate = requiredDeliveryDate,
releaseTypeFilter = releaseTypeFilter,
floor = floor,
)

/**
@@ -1648,6 +1743,134 @@ return MessageResponse(
)
}

/**
* FP-MTMS Version Checklist | Functions Ref. No. 37 | v1.0.1 | 2026-08-03
* Store scope for workbench lot (re)suggestions.
* Prefer [requestStoreId]; else DOPO.storeId; Truck X (both null) → 2/F|4/F from DO suppliers.
*/
private fun resolveWorkbenchSuggestionStoreId(pickOrderId: Long, requestStoreId: String?): String {
if (!requestStoreId.isNullOrBlank()) {
return doFloorSupplierSettingsService.resolveSuggestionStoreId(requestStoreId, emptyList())
}
val header = try {
jdbcDao.queryForList(
"""
SELECT dop.storeId AS storeId
FROM fpsmsdb.pick_order po
INNER JOIN fpsmsdb.delivery_order_pick_order dop
ON dop.id = po.deliveryOrderPickOrderId AND dop.deleted = 0
WHERE po.id = :poId AND po.deleted = 0
LIMIT 1
""".trimIndent(),
mapOf("poId" to pickOrderId),
).firstOrNull()
} catch (_: Exception) {
null
}
val dbStoreId = header?.let { row ->
val k = row.keys.find { it.equals("storeId", true) } ?: return@let null
row[k]?.toString()?.trim()?.takeIf { it.isNotEmpty() }
}
if (!dbStoreId.isNullOrBlank()) {
return doFloorSupplierSettingsService.resolveSuggestionStoreId(dbStoreId, emptyList())
}
val supplierRows = try {
jdbcDao.queryForList(
"""
SELECT DISTINCT s.code AS supplierCode
FROM fpsmsdb.pick_order po
INNER JOIN fpsmsdb.delivery_order d ON d.id = po.doId AND d.deleted = 0
LEFT JOIN fpsmsdb.shop s ON s.id = d.supplierId AND s.deleted = 0
WHERE po.id = :poId AND po.deleted = 0
""".trimIndent(),
mapOf("poId" to pickOrderId),
)
} catch (_: Exception) {
emptyList()
}
val codes = supplierRows.mapNotNull { row ->
val k = row.keys.find { it.equals("supplierCode", true) } ?: return@mapNotNull null
row[k]?.toString()
}
return doFloorSupplierSettingsService.resolveSuggestionStoreId(null, codes)
}

/**
* FP-MTMS Version Checklist | Functions Ref. No. 37 | v1.0.1 | 2026-08-03
* UI / lot-print / scan-pick store scope for a DOPO.
* Truck X keeps DB storeId null; response may expose derived 2/F|4/F (not written to DB).
*/
private fun resolveDisplayStoreIdForDopo(dopoId: Long, dbStoreId: String?): String {
if (!dbStoreId.isNullOrBlank()) {
return doFloorSupplierSettingsService.resolveSuggestionStoreId(dbStoreId, emptyList())
}
val supplierRows = try {
jdbcDao.queryForList(
"""
SELECT DISTINCT s.code AS supplierCode
FROM fpsmsdb.pick_order po
INNER JOIN fpsmsdb.delivery_order d ON d.id = po.doId AND d.deleted = 0
LEFT JOIN fpsmsdb.shop s ON s.id = d.supplierId AND s.deleted = 0
WHERE po.deliveryOrderPickOrderId = :dopoId AND po.deleted = 0
""".trimIndent(),
mapOf("dopoId" to dopoId),
)
} catch (_: Exception) {
emptyList()
}
val codes = supplierRows.mapNotNull { row ->
val k = row.keys.find { it.equals("supplierCode", true) } ?: return@mapNotNull null
row[k]?.toString()
}
return doFloorSupplierSettingsService.resolveSuggestionStoreId(null, codes)
}

/**
* FP-MTMS Version Checklist | Functions Ref. No. 37 | v1.0.1 | 2026-08-03
* Copy DOPO header map with Truck X display storeId filled for hierarchical fgInfo.
*/
private fun enrichDopoInfoStoreIdForUi(doPickOrderInfo: Map<String, Any?>, dopoId: Long): Map<String, Any?> {
val storeKey = doPickOrderInfo.keys.find { it.equals("store_id", true) } ?: "store_id"
val dbStoreId = doPickOrderInfo[storeKey]?.toString()?.trim()?.takeIf { it.isNotEmpty() }
if (!dbStoreId.isNullOrBlank()) return doPickOrderInfo
val resolved = resolveDisplayStoreIdForDopo(dopoId, null)
return doPickOrderInfo.toMutableMap().apply { this[storeKey] = resolved }
}

/**
* EXISTS clause: Truck X ticket has at least one DO whose supplier preferred floor matches [floorKey]
* (`2F` / `4F`), and **no** linked DO on the other floor.
* Aligns with [DoFloorSupplierSettingsService.preferredStoreFloorForSupplier]
* (4F list wins; otherwise 2F). Empty string = no extra filter.
*/
private fun truckXPreferredFloorExistsSql(floorKey: String?): String {
val key = floorKey?.trim()?.uppercase(java.util.Locale.ROOT)?.replace("/", "").orEmpty()
if (key != "2F" && key != "4F") return ""
val (_, s4f) = doFloorSupplierSettingsService.loadDoFloorSupplierLists()
val in4 = s4f.joinToString(", ") { "'" + it.replace("'", "''") + "'" }
.ifEmpty { "'__NO_4F_SUPPLIER__'" }
val floorExpr = "CASE WHEN s.code IN ($in4) THEN '4F' ELSE '2F' END"
return """
AND EXISTS (
SELECT 1
FROM fpsmsdb.pick_order po
INNER JOIN fpsmsdb.delivery_order d ON d.id = po.doId AND d.deleted = 0
LEFT JOIN fpsmsdb.shop s ON s.id = d.supplierId AND s.deleted = 0
WHERE po.deliveryOrderPickOrderId = dop.id AND po.deleted = 0
AND ($floorExpr) = '$key'
)
AND NOT EXISTS (
SELECT 1
FROM fpsmsdb.pick_order po2
INNER JOIN fpsmsdb.delivery_order d2 ON d2.id = po2.doId AND d2.deleted = 0
LEFT JOIN fpsmsdb.shop s2 ON s2.id = d2.supplierId AND s2.deleted = 0
WHERE po2.deliveryOrderPickOrderId = dop.id AND po2.deleted = 0
AND (CASE WHEN s2.code IN ($in4) THEN '4F' ELSE '2F' END) <> '$key'
)
""".trimIndent()
}

/** FP-MTMS Version Checklist | Functions Ref. No. 37 | v1.0.1 | 2026-08-03 */
private fun queryWorkbenchReleasedDopoList(
shopName: String?,
storeId: String?,
@@ -1655,6 +1878,7 @@ return MessageResponse(
beforeToday: Boolean,
equalsDeliveryDate: LocalDate? = null,
releaseTypeFilter: String? = null,
floor: String? = null,
): List<ReleasedDoPickOrderListItem> {
val today = LocalDate.now()
val params = mutableMapOf<String, Any>()
@@ -1706,8 +1930,14 @@ return MessageResponse(
params["shopPat"] = "%${shopName.trim()}%"
}
val rtNorm = releaseTypeFilter?.trim()?.lowercase().orEmpty()
if (rtNorm == "isExtra") {
sqlBuilder.append(" AND LOWER(COALESCE(dop.releaseType, '')) = 'isExtra' ")
if (rtNorm == "isextra" || rtNorm == "etra" || rtNorm == "etrafamily") {
// Etra modal: standalone isExtra + merged isExtrabatch / isExtrasingle
sqlBuilder.append(WorkbenchReleaseTypeSupport.etraFamilySql("dop.releaseType"))
}
// Optional floor: used for Truck X (null storeId) display split; does not write storeId.
val floorClause = truckXPreferredFloorExistsSql(floor)
if (floorClause.isNotBlank()) {
sqlBuilder.append(" ").append(floorClause).append(" ")
}
sqlBuilder.append(" ORDER BY dop.requiredDeliveryDate, dop.truckDepartureTime, dop.truckLanceCode, dop.id ")
val rows: List<Map<String, Any?>> = try {
@@ -1889,7 +2119,7 @@ return MessageResponse(
payload = assembleHierarchicalFgPayload(
jdbcDao = jdbcDao,
doPickOrderId = doPickOrderId,
doPickOrderInfo = doPickOrderInfoSafe,
doPickOrderInfo = enrichDopoInfoStoreIdForUi(doPickOrderInfoSafe, doPickOrderId),
pickOrdersInfo = pickOrdersInfo,
availableQtyInOutOnly = true,
timingSink = ::mark,
@@ -1954,13 +2184,12 @@ return MessageResponse(
return mapOf("fgInfo" to null, "pickOrders" to emptyList<Any>())
}

// Completed-record detail should keep each PO as an independent group
// (ticket can link multiple POs, e.g. 20 + 25), not merged into a single 45-line bucket.
val doPickOrderInfoForUi = enrichDopoInfoStoreIdForUi(doPickOrderInfo, deliveryOrderPickOrderId)
val perPoPayloads = pickOrdersInfo.map { poInfo ->
assembleHierarchicalFgPayload(
jdbcDao = jdbcDao,
doPickOrderId = deliveryOrderPickOrderId,
doPickOrderInfo = doPickOrderInfo,
doPickOrderInfo = doPickOrderInfoForUi,
pickOrdersInfo = listOf(poInfo),
availableQtyInOutOnly = true,
)
@@ -2044,6 +2273,13 @@ return MessageResponse(
} else {
emptyList()
}
val dopoId = (row["doPickOrderId"] as? Number)?.toLong() ?: 0L
val dbStoreId = row["storeId"]?.toString()?.trim()?.takeIf { it.isNotEmpty() }
val displayStoreId = if (dopoId > 0L) {
resolveDisplayStoreIdForDopo(dopoId, dbStoreId)
} else {
dbStoreId.orEmpty()
}

mapOf(
"doPickOrderId" to (row["doPickOrderId"] ?: 0L),
@@ -2068,7 +2304,7 @@ return MessageResponse(
"truckLanceCode" to (row["truckLanceCode"] ?: ""),
"DepartureTime" to (row["DepartureTime"]?.toString() ?: ""),
"ticketNo" to (row["ticketNo"] ?: ""),
"storeId" to (row["storeId"] ?: ""),
"storeId" to displayStoreId,
"qrCodeData" to (row["doPickOrderId"] ?: 0L),
)
}
@@ -2150,6 +2386,7 @@ return MessageResponse(
var postMs = 0L
try {
if (pickOrderId != null) {
val suggestionStoreId = resolveWorkbenchSuggestionStoreId(pickOrderId, requestStoreId)
val resuggestExcludeWarehouseCodes = workbenchResuggestExcludeWarehouseCodes(
pickOrderId = pickOrderId,
poType = null,
@@ -2162,7 +2399,7 @@ return MessageResponse(
suggestedPickLotWorkbenchService.setNoHoldSuggestionsForPickOrderLineNextSingleLot(
pickOrderLineId = polId,
targetQty = explicitRemainder,
storeId = requestStoreId,
storeId = suggestionStoreId,
excludeInventoryLotLineId = scannedIllId,
excludeWarehouseCodes = resuggestExcludeWarehouseCodes,
)
@@ -2196,7 +2433,7 @@ return MessageResponse(
rebuildMs = measureTimeMillis {
suggestedPickLotWorkbenchService.rebuildNoHoldSuggestionsForPickOrderLine(
pickOrderLineId = polId,
storeId = requestStoreId,
storeId = suggestionStoreId,
excludeWarehouseCodes = resuggestExcludeWarehouseCodes,
)
}
@@ -2353,6 +2590,9 @@ return MessageResponse(
val onHandAfter = (inventory.onHandQty ?: BigDecimal.ZERO).toDouble()
val previousBalance = onHandAfter + deltaQty.toDouble()
val newBalance = previousBalance - deltaQty.toDouble()
val ill = sol.inventoryLotLine?.id?.let { inventoryLotLineRepository.findById(it).orElse(sol.inventoryLotLine) }
?: sol.inventoryLotLine
val lotAfter = ill?.let { StockLedgerLotSnapshot.availableQty(it) }
val ledger = StockLedger().apply {
this.stockOutLine = sol
this.inventory = inventory
@@ -2362,14 +2602,54 @@ return MessageResponse(
this.type = "NOR"
this.itemId = solItem.id
this.itemCode = solItem.code
this.uomId = itemUomRespository.findByItemIdAndStockUnitIsTrueAndDeletedIsFalse(solItem.id!!)?.uom?.id
?: inventory.uom?.id
this.uomId = StockLedgerLotSnapshot.resolveLedgerUomId(
ill,
itemUomRespository.findByItemIdAndStockUnitIsTrueAndDeletedIsFalse(solItem.id!!)?.uom?.id,
inventory.uom?.id,
)
this.date = LocalDate.now()
}
StockLedgerLotSnapshot.apply(
ledger = ledger,
inventoryLotLine = ill,
lotAvailableAfterMove = lotAfter,
signedDelta = deltaQty.negate(),
)
// Do not flush per pick; transaction flush is sufficient and avoids slow roundtrips/locks.
stockLedgerRepository.save(ledger)
}

/**
* FP-MTMS Version Checklist | Functions Ref. No. 38 | v1.0.0 | 2026-08-03
* DO user-audit: Just Complete is reportable only when the item still has pickable stock
* at complete time (AVAILABLE, not expired, in−out > 0).
*
* Legitimate (do not flag): suggested lot exhausted → system splits remainder SOL → JC qty=0
* with no other available lots.
* Issue (flag): between split and JC a new/other available lot exists but user still JC.
*/
private fun maybeMarkIssueJustComplete(sol: StockOutLine, justComplete: Boolean) {
if (!justComplete) return
val pol = sol.pickOrderLine ?: return
val po = pol.pickOrder
val isDo = po?.type == PickOrderType.DELIVERY_ORDER
if (!isDo) return

val itemId = pol.item?.id ?: sol.item?.id ?: return
val today = LocalDate.now()
val excludeIllId = sol.inventoryLotLine?.id
val hasPickableLot = inventoryLotLineRepository.findAllByItemIdIn(listOf(itemId))
.asSequence()
.filter { !it.deleted }
.filter { excludeIllId == null || it.id != excludeIllId }
.filter { it.status == InventoryLotLineStatus.AVAILABLE }
.filter { it.inventoryLot?.expiryDate?.isBefore(today) != true }
.any { StockLedgerLotSnapshot.availableQty(it) > BigDecimal.ZERO }
if (hasPickableLot) {
sol.isIssueJustComplete = true
}
}

private fun resolveWorkbenchPreviousBalance(
itemId: Long,
inventory: Inventory,


+ 56
- 0
src/main/java/com/ffii/fpsms/modules/deliveryOrder/service/DoWorkbenchReleaseService.kt Datei anzeigen

@@ -94,6 +94,7 @@ open class DoWorkbenchReleaseService(
private val jdbcDao: JdbcDao,
private val suggestedPickLotWorkbenchService: SuggestedPickLotWorkbenchService,
private val stockOutLineWorkbenchService: StockOutLineWorkbenchService,
private val doFloorSupplierSettingsService: DoFloorSupplierSettingsService,
) {
private val poolSize = Runtime.getRuntime().availableProcessors()
private val executor = Executors.newFixedThreadPool(kotlin.math.min(poolSize, 4))
@@ -523,7 +524,9 @@ open class DoWorkbenchReleaseService(
params: MutableMap<String, Any>,
) {
if (storeId.isNullOrBlank()) {
// Truck X: storeId stays null, but do not merge across supplier preferred floors (2F vs 4F).
sql.append(" AND (storeId IS NULL OR TRIM(COALESCE(storeId, '')) = '') ")
appendTruckXPreferredFloorMatchClauses(sql, first.preferredFloor)
} else {
sql.append(" AND storeId = :storeId ")
params["storeId"] = storeId
@@ -552,6 +555,59 @@ open class DoWorkbenchReleaseService(
}
}

/**
* For Truck X headers (`storeId` null): only merge into a ticket whose linked DO suppliers
* all resolve to the same preferred floor as [preferredFloor] (4F list wins; else 2F),
* matching normal lane separation by floor / [resolveStoreId].
*/
private fun appendTruckXPreferredFloorMatchClauses(
sql: StringBuilder,
preferredFloor: String?,
) {
val floor = when (preferredFloor?.trim()?.uppercase(java.util.Locale.ROOT)?.replace("/", "")) {
"4F" -> "4F"
else -> "2F"
}
val (_, s4f) = doFloorSupplierSettingsService.loadDoFloorSupplierLists()
val in4 = s4f.joinToString(", ") { "'" + it.replace("'", "''") + "'" }
.ifEmpty { "'__NO_4F_SUPPLIER__'" }
val floorExpr = "CASE WHEN s.code IN ($in4) THEN '4F' ELSE '2F' END"
// Reject tickets that already have any DO on a different floor.
sql.append(
"""
AND NOT EXISTS (
SELECT 1
FROM fpsmsdb.pick_order po
INNER JOIN fpsmsdb.delivery_order d ON d.id = po.doId AND d.deleted = 0
LEFT JOIN fpsmsdb.shop s ON s.id = d.supplierId AND s.deleted = 0
WHERE po.deliveryOrderPickOrderId = delivery_order_pick_order.id
AND po.deleted = 0
AND ($floorExpr) <> '$floor'
)
""".trimIndent()
)
// Prefer an existing same-floor ticket; allow empty header (no pick_order yet).
sql.append(
"""
AND (
NOT EXISTS (
SELECT 1 FROM fpsmsdb.pick_order po0
WHERE po0.deliveryOrderPickOrderId = delivery_order_pick_order.id AND po0.deleted = 0
)
OR EXISTS (
SELECT 1
FROM fpsmsdb.pick_order po
INNER JOIN fpsmsdb.delivery_order d ON d.id = po.doId AND d.deleted = 0
LEFT JOIN fpsmsdb.shop s ON s.id = d.supplierId AND s.deleted = 0
WHERE po.deliveryOrderPickOrderId = delivery_order_pick_order.id
AND po.deleted = 0
AND ($floorExpr) = '$floor'
)
)
""".trimIndent()
)
}

private fun mapRowToMergeableTicket(row: Map<String, Any?>): MergeableWorkbenchTicket? {
fun cell(name: String): String? {
val key = row.keys.find { it.equals(name, true) } ?: return null


+ 13
- 3
src/main/java/com/ffii/fpsms/modules/deliveryOrder/service/WorkbenchReleaseTypeSupport.kt Datei anzeigen

@@ -2,7 +2,7 @@ package com.ffii.fpsms.modules.deliveryOrder.service

/**
* Workbench [delivery_order_pick_order.releaseType] values and SQL filters.
* Legacy `isExtra` tickets are excluded from merge; only used for legacy Etra views.
* Etra UI uses [etraFamilySql] (standalone `isExtra` + merged `isExtrabatch` / `isExtrasingle`).
*/
object WorkbenchReleaseTypeSupport {
const val BATCH = "batch"
@@ -15,20 +15,23 @@ object WorkbenchReleaseTypeSupport {

fun singleFamilyTypes(): List<String> = listOf(SINGLE, IS_EXTRA_SINGLE)

/** FP-MTMS Version Checklist | Functions Ref. No. 37 | v1.0.1 | 2026-08-03 */
fun summaryFilterSql(releaseType: String, column: String = "dop.releaseType"): String =
when (releaseType.trim().lowercase()) {
"batch" -> batchFamilySql(column)
"single" -> singleFamilySql(column)
"isextra" -> legacyIsExtraSql(column)
// Etra UI / list: standalone + merged extra family
"isextra", "etra", "etrafamily" -> etraFamilySql(column)
else -> ""
}

/** FP-MTMS Version Checklist | Functions Ref. No. 37 | v1.0.1 | 2026-08-03 */
fun assignFilterSql(releaseType: String?, column: String = "dop.releaseType"): String {
val n = releaseType?.trim()?.lowercase().orEmpty()
return when (n) {
"batch" -> batchFamilySql(column)
"single" -> singleFamilySql(column)
"isextra" -> legacyIsExtraSql(column)
"isextra", "etra", "etrafamily" -> etraFamilySql(column)
else -> ""
}
}
@@ -42,6 +45,13 @@ object WorkbenchReleaseTypeSupport {
fun legacyIsExtraSql(column: String = "dop.releaseType"): String =
" AND LOWER(COALESCE($column, '')) = 'isextra' "

/**
* FP-MTMS Version Checklist | Functions Ref. No. 37 | v1.0.1 | 2026-08-03
* Standalone `isExtra` plus merged `isExtrabatch` / `isExtrasingle` (Etra workbench).
*/
fun etraFamilySql(column: String = "dop.releaseType"): String =
" AND LOWER(COALESCE($column, '')) IN ('isextra', 'isextrabatch', 'isextrasingle') "

fun newHeaderReleaseType(isExtraRelease: Boolean, isSingleRelease: Boolean): String = when {
isExtraRelease && isSingleRelease -> IS_EXTRA_SINGLE
isExtraRelease -> IS_EXTRA_BATCH


+ 6
- 0
src/main/java/com/ffii/fpsms/modules/deliveryOrder/web/DoWorkbenchController.kt Datei anzeigen

@@ -115,12 +115,15 @@ class DoWorkbenchController(
@RequestParam(required = false) storeId: String?,
@RequestParam(required = false) truck: String?,
@RequestParam(required = false) releaseType: String?,
/** Optional `2F`/`4F`: Truck X list split by DO supplier preferred floor (storeId stays null). */
@RequestParam(required = false) floor: String?,
): List<ReleasedDoPickOrderListItem> {
return doWorkbenchMainService.findWorkbenchReleasedDeliveryOrderPickOrdersForSelection(
shopName,
storeId,
truck,
releaseTypeFilter = releaseType,
floor = floor,
)
}

@@ -131,6 +134,8 @@ class DoWorkbenchController(
@RequestParam(required = false) truck: String?,
@RequestParam(required = false) @DateTimeFormat(iso = DateTimeFormat.ISO.DATE) requiredDate: LocalDate?,
@RequestParam(required = false) releaseType: String?,
/** Optional `2F`/`4F`: Truck X list split by DO supplier preferred floor (storeId stays null). */
@RequestParam(required = false) floor: String?,
): List<ReleasedDoPickOrderListItem> {
return doWorkbenchMainService.findWorkbenchReleasedDeliveryOrderPickOrdersForSelectionToday(
shopName,
@@ -138,6 +143,7 @@ class DoWorkbenchController(
truck,
requiredDeliveryDate = requiredDate,
releaseTypeFilter = releaseType,
floor = floor,
)
}



+ 1
- 1
src/main/java/com/ffii/fpsms/modules/deliveryOrder/web/models/DoDetailResponse.kt Datei anzeigen

@@ -68,7 +68,7 @@ data class LaneBtn(
val truckDepartureTime: String? = null,
)

/** All Etra (`releaseType=isExtra`) tickets for a day, grouped by shop then truck (no 2F/4F split in UI). */
/** Etra family tickets (`isExtra` / `isExtrabatch` / `isExtrasingle`) for a day, grouped by shop then truck. */
data class WorkbenchEtraShopLaneGroup(
val shopCode: String?,
val shopName: String?,


+ 6
- 0
src/main/java/com/ffii/fpsms/modules/deliveryOrder/web/models/WorkbenchScanPickRequest.kt Datei anzeigen

@@ -3,6 +3,7 @@ package com.ffii.fpsms.modules.deliveryOrder.web.models
import java.math.BigDecimal

/**
* FP-MTMS Version Checklist | Functions Ref. No. 38 | v1.0.0 | 2026-08-03
* Workbench v1: scan lot and post pick immediately (no separate submit step).
* [qty] optional: when null, posts up to remaining quantity for this stock-out line chunk; when set, may exceed that
* chunk and is capped only by available quantity on the scanned inventory lot line (overscan / UI edit).
@@ -25,6 +26,11 @@ data class WorkbenchScanPickRequest(
*/
val inventoryLotLineId: Long? = null,
val qty: BigDecimal? = null,
/**
* When true, UI used Just Complete (no QR). Backend may set [StockOutLine.isIssueJustComplete]
* for DO user-audit reporting.
*/
val justComplete: Boolean? = false,
val userId: Long = 1L,
)


+ 49
- 38
src/main/java/com/ffii/fpsms/modules/jobOrder/service/JoPickOrderService.kt Datei anzeigen

@@ -231,6 +231,7 @@ open class JoPickOrderService(
}
return joPickOrderRecordRepository.saveAll(joPickOrderRecords)
}
/** FP-MTMS Version Checklist | Functions Ref. No. 44 | v1.0.0 | 2026-08-03 */
open fun getAllJobOrderLotsWithDetailsHierarchical(userId: Long): JobOrderLotsHierarchicalResponse {
println("=== Debug: getAllJobOrderLotsWithDetailsHierarchical ===")
println("today: ${LocalDate.now()}")
@@ -557,6 +558,7 @@ open class JoPickOrderService(
}
// Get completed job order pick orders (for second tab)
// Fix the getCompletedJobOrderLotsHierarchical method
/** FP-MTMS Version Checklist | Functions Ref. No. 44 | v1.0.0 | 2026-08-03 */
open fun getCompletedJobOrderLotsHierarchical(userId: Long): Map<String, Any?> {
println("=== Debug: getCompletedJobOrderLotsHierarchical (repo version) ===")
println("today: ${LocalDate.now()}")
@@ -625,13 +627,20 @@ open class JoPickOrderService(
.filter { it.deleted == false }
} else emptyList()
val inventoryLotLineIds = suggestedPickLots.mapNotNull { it.suggestedLotLine?.id }
// Prefer actualPickLotLine for display (after scan/switch); fallback suggested
val inventoryLotLineIds = suggestedPickLots
.mapNotNull { it.actualPickLotLine?.id ?: it.suggestedLotLine?.id }
.distinct()
val inventoryLotLines = if (inventoryLotLineIds.isNotEmpty()) {
inventoryLotLineRepository
.findAllByIdIn(inventoryLotLineIds)
.filter { it.deleted == false }
} else emptyList()

val inventoryLotLineById = inventoryLotLines
.filter { it.id != null }
.associateBy { it.id!! }
val inventoryLotIds = inventoryLotLines.mapNotNull { it.inventoryLot?.id }.distinct()
val inventoryLots = if (inventoryLotIds.isNotEmpty()) {
@@ -664,16 +673,19 @@ open class JoPickOrderService(
}
val lots = lineSuggestedLots.mapNotNull { spl ->
val ill = spl.suggestedLotLine ?: return@mapNotNull null
if (ill.deleted == true) return@mapNotNull null
val displayIllId = (spl.actualPickLotLine?.takeIf { it.deleted != true }?.id)
?: (spl.suggestedLotLine?.takeIf { it.deleted != true }?.id)
?: return@mapNotNull null
val ill = inventoryLotLineById[displayIllId] ?: return@mapNotNull null
val il = ill.inventoryLot ?: return@mapNotNull null
if (il.deleted == true) return@mapNotNull null
val warehouse = ill.warehouse
val sol = stockOutLines.firstOrNull {
it.pickOrderLine?.id == pol.id && it.inventoryLotLine?.id == ill.id
}
val sol = spl.stockOutLine?.takeIf { it.deleted != true && it.pickOrderLine?.id == pol.id }
?: stockOutLines.firstOrNull {
it.pickOrderLine?.id == pol.id && it.inventoryLotLine?.id == ill.id
}
val availableQty = if (sol?.status == "rejected") {
null
@@ -1140,7 +1152,7 @@ open fun recordSecondScanIssue(request: SecondScanIssueRequest): MessageResponse
il.lotNo as lotNo,
w.name as storeLocation
FROM fpsmsdb.suggested_pick_lot spl
JOIN fpsmsdb.inventory_lot_line ill ON spl.suggestedLotLineId = ill.id
JOIN fpsmsdb.inventory_lot_line ill ON ill.id = COALESCE(spl.actualPickLotLineId, spl.suggestedLotLineId)
JOIN fpsmsdb.inventory_lot il ON ill.inventoryLotId = il.id
LEFT JOIN fpsmsdb.warehouse w ON w.id = ill.warehouseId
WHERE spl.pickOrderLineId = :pickOrderLineId
@@ -1506,7 +1518,7 @@ open fun getCompletedJobOrderPickOrderLotDetails(pickOrderId: Long): List<Map<St
JOIN fpsmsdb.items i ON i.id = pol.itemId
LEFT JOIN fpsmsdb.uom_conversion uc ON uc.id = pol.uomId
LEFT JOIN fpsmsdb.suggested_pick_lot spl ON pol.id = spl.pickOrderLineId
LEFT JOIN fpsmsdb.inventory_lot_line ill ON spl.suggestedLotLineId = ill.id
LEFT JOIN fpsmsdb.inventory_lot_line ill ON ill.id = COALESCE(spl.actualPickLotLineId, spl.suggestedLotLineId)

LEFT JOIN fpsmsdb.inventory_lot il ON il.id = ill.inventoryLotId
LEFT JOIN fpsmsdb.warehouse w ON w.id = ill.warehouseId
@@ -1932,7 +1944,7 @@ open fun getCompletedJobOrderPickOrders(completedDate: LocalDate?): List<Map<Str
JOIN fpsmsdb.items i ON i.id = pol.itemId
LEFT JOIN fpsmsdb.uom_conversion uc ON uc.id = pol.uomId
LEFT JOIN fpsmsdb.suggested_pick_lot spl ON pol.id = spl.pickOrderLineId
LEFT JOIN fpsmsdb.inventory_lot_line ill ON spl.suggestedLotLineId = ill.id
LEFT JOIN fpsmsdb.inventory_lot_line ill ON ill.id = COALESCE(spl.actualPickLotLineId, spl.suggestedLotLineId)
LEFT JOIN fpsmsdb.inventory_lot il ON il.id = ill.inventoryLotId
LEFT JOIN fpsmsdb.warehouse w ON w.id = ill.warehouseId
-- no-lot:ill.id 為 null 時,匹配 sol.inventoryLotLineId IS NULL
@@ -2088,7 +2100,7 @@ open fun getCompletedJobOrderPickOrders(completedDate: LocalDate?): List<Map<Str
JOIN fpsmsdb.items i ON i.id = pol.itemId
LEFT JOIN fpsmsdb.uom_conversion uc ON uc.id = pol.uomId
LEFT JOIN fpsmsdb.suggested_pick_lot spl ON pol.id = spl.pickOrderLineId
LEFT JOIN fpsmsdb.inventory_lot_line ill ON spl.suggestedLotLineId = ill.id
LEFT JOIN fpsmsdb.inventory_lot_line ill ON ill.id = COALESCE(spl.actualPickLotLineId, spl.suggestedLotLineId)

LEFT JOIN fpsmsdb.inventory_lot il ON il.id = ill.inventoryLotId
LEFT JOIN fpsmsdb.warehouse w ON w.id = ill.warehouseId
@@ -2187,7 +2199,7 @@ open fun getCompletedJobOrderPickOrders(completedDate: LocalDate?): List<Map<Str
FROM fpsmsdb.pick_order po
JOIN fpsmsdb.pick_order_line pol ON pol.poId = po.id AND pol.deleted = false
LEFT JOIN fpsmsdb.suggested_pick_lot spl ON pol.id = spl.pickOrderLineId AND spl.deleted = false
LEFT JOIN fpsmsdb.inventory_lot_line ill ON spl.suggestedLotLineId = ill.id AND ill.deleted = false
LEFT JOIN fpsmsdb.inventory_lot_line ill ON ill.id = COALESCE(spl.actualPickLotLineId, spl.suggestedLotLineId) AND ill.deleted = false
LEFT JOIN fpsmsdb.warehouse w ON w.id = ill.warehouseId
LEFT JOIN fpsmsdb.stock_out_line sol ON sol.pickOrderLineId = pol.id AND sol.deleted = false
WHERE po.deleted = false
@@ -2382,7 +2394,9 @@ open fun getCompletedJobOrderPickOrders(completedDate: LocalDate?): List<Map<Str
jobOrderType = jobOrderType?.name,
itemId = item.id ?: 0L,
itemName = item.name ?: "",
itemCode = item.code,
bomDescription = bom?.bomKind,
bomType = bom?.type,
reqQty = jobOrder.reqQty ?: BigDecimal.ZERO,
//uomId = bom.outputQtyUom?.id : 0L,
uomId = 0,
@@ -2438,6 +2452,7 @@ open fun getCompletedJobOrderPickOrders(completedDate: LocalDate?): List<Map<Str
}
}

/** FP-MTMS Version Checklist | Functions Ref. No. 44 | v1.0.0 | 2026-08-03 */
open fun getJobOrderLotsHierarchicalByPickOrderId(pickOrderId: Long): JobOrderLotsHierarchicalResponse {
println("=== getJobOrderLotsHierarchicalByPickOrderId ===")
println("pickOrderId: $pickOrderId")
@@ -2518,7 +2533,9 @@ open fun getCompletedJobOrderPickOrders(completedDate: LocalDate?): List<Map<Str
}

// 获取所有 inventory lot line IDs
val inventoryLotLineIds = suggestedPickLots.mapNotNull { it.suggestedLotLine?.id }
val inventoryLotLineIds = suggestedPickLots
.mapNotNull { it.actualPickLotLine?.id ?: it.suggestedLotLine?.id }
.distinct()

// 获取 inventory lot lines
val inventoryLotLines = if (inventoryLotLineIds.isNotEmpty()) {
@@ -2571,18 +2588,13 @@ open fun getCompletedJobOrderPickOrders(completedDate: LocalDate?): List<Map<Str
.associateBy { it.id!! }

// 获取 stock in lines 通过 inventoryLotLineId(用于填充 stockInLineId)
val stockInLinesByInventoryLotLineId = if (inventoryLotLineIds.isNotEmpty()) {
// ✅ 修复:直接使用已加载的 inventoryLotLines 实体获取 stockInLineId
inventoryLotLines.associateBy { it.id!! }
.mapNotNull { (illId, ill) ->
// 通过关系链:InventoryLotLine -> InventoryLot -> StockInLine -> id
ill.inventoryLot?.stockInLine?.id?.let { stockInLineId ->
illId to stockInLineId
}
val stockInLinesByInventoryLotLineId = buildMap {
(inventoryLotLines + stockOutInventoryLotLines).forEach { ill ->
val id = ill.id ?: return@forEach
if (!containsKey(id)) {
ill.inventoryLot?.stockInLine?.id?.let { put(id, it) }
}
.toMap()
} else {
emptyMap()
}
}

// 获取 jo_pick_order 记录
@@ -2617,22 +2629,22 @@ open fun getCompletedJobOrderPickOrders(completedDate: LocalDate?): List<Map<Str
userService.find(uid).orElse(null)?.name
}
// 构建 lots 数据
// COALESCE(actualPickLotLine, suggestedLotLine) for display after switch
val lots = lineSuggestedLots.mapNotNull { spl ->
val ill = spl.suggestedLotLine
if (ill == null || ill.deleted == true) return@mapNotNull null
val displayIllId = (spl.actualPickLotLine?.takeIf { it.deleted != true }?.id)
?: (spl.suggestedLotLine?.takeIf { it.deleted != true }?.id)
?: return@mapNotNull null
val ill = inventoryLotLineById[displayIllId] ?: return@mapNotNull null

val il = ill.inventoryLot
if (il == null || il.deleted == true) return@mapNotNull null

val warehouse = ill.warehouse
val sol = stockOutLines.firstOrNull {
it.pickOrderLine?.id == pol.id && it.inventoryLotLine?.id == ill.id
}
val jpo = joPickOrders.firstOrNull { it.itemId == item?.id }
val handlerName = jpo?.handledBy?.let { uid ->
userService.find(uid).orElse(null)?.name
}
println("handlerName: $handlerName")
val sol = spl.stockOutLine?.takeIf { it.deleted != true && it.pickOrderLine?.id == pol.id }
?: stockOutLines.firstOrNull {
it.pickOrderLine?.id == pol.id && it.inventoryLotLine?.id == ill.id
}
val jpoInner = joPickOrders.firstOrNull { it.itemId == item?.id }
val availableQty = if (sol?.status == "rejected") {
null
} else {
@@ -2655,7 +2667,6 @@ open fun getCompletedJobOrderPickOrders(completedDate: LocalDate?): List<Map<Str
else -> "pending"
}

// 获取 stockInLineId
val stockInLineId = ill.id?.let { illId ->
stockInLinesByInventoryLotLineId[illId]
}
@@ -2685,11 +2696,11 @@ open fun getCompletedJobOrderPickOrders(completedDate: LocalDate?): List<Map<Str
routerArea = warehouse?.code,
routerRoute = warehouse?.code,
uomShortDesc = uom?.udfShortDesc,
matchStatus = jpo?.matchStatus?.value,
matchBy = jpo?.matchBy,
matchQty = jpo?.matchQty?.toDouble()
matchStatus = jpoInner?.matchStatus?.value,
matchBy = jpoInner?.matchBy,
matchQty = jpoInner?.matchQty?.toDouble()
)
}
}.distinctBy { it.lotId }

// 构建 stockouts 数据:用于无 suggested lot / noLot 场景也能显示并闭环(submit 0)
val stockouts = (stockOutLinesByPickOrderLine[lineId] ?: emptyList()).map { sol ->


+ 40
- 23
src/main/java/com/ffii/fpsms/modules/jobOrder/service/JoWorkbenchMainService.kt Datei anzeigen

@@ -32,7 +32,8 @@ import java.util.Locale

/**
* Job Order Workbench–only flows (assign prime, hierarchical payload for scan-pick UI).
* [getJobOrderLotsHierarchicalByPickOrderIdWorkbench] uses **in − out** for available qty (matches workbench scan-pick).
* [getJobOrderLotsHierarchicalByPickOrderIdWorkbench] uses **in − out** for available qty (matches workbench scan-pick)
* and displays COALESCE(actualPickLotLine, suggestedLotLine).
* Non-workbench hierarchical stays in [JoPickOrderService.getJobOrderLotsHierarchicalByPickOrderId] (in − out − hold).
*/
@Service
@@ -190,7 +191,9 @@ open class JoWorkbenchMainService(
}

/**
* Hierarchical pick UI for JO Workbench: available qty **in − out**; stockouts include **suggestedPickQty** when SPL matches SOL lot line.
* FP-MTMS Version Checklist | Functions Ref. No. 44 | v1.0.0 | 2026-08-03
* Hierarchical pick UI for JO Workbench: available qty **in − out**;
* lot display uses COALESCE(actualPickLotLine, suggestedLotLine) (same as DO workbench).
*/
open fun getJobOrderLotsHierarchicalByPickOrderIdWorkbench(pickOrderId: Long): JobOrderLotsHierarchicalWorkbenchResponse {
//println("=== JoWorkbenchMainService.getJobOrderLotsHierarchicalByPickOrderIdWorkbench ===")
@@ -239,7 +242,10 @@ open class JoWorkbenchMainService(
emptyList()
}

val inventoryLotLineIds = suggestedPickLots.mapNotNull { it.suggestedLotLine?.id }
// Prefer actualPickLotLine for display (after scan/switch); fallback suggested (old / not yet picked).
val inventoryLotLineIds = suggestedPickLots
.mapNotNull { displayIllForWorkbench(it)?.id }
.distinct()

val inventoryLotLines = if (inventoryLotLineIds.isNotEmpty()) {
inventoryLotLineRepository.findAllByIdIn(inventoryLotLineIds)
@@ -253,16 +259,6 @@ open class JoWorkbenchMainService(
inventoryLotRepository.findAllByIdIn(inventoryLotIds).filter { it.deleted == false }
}

val stockOutLines = if (pickOrderLineIds.isNotEmpty() && inventoryLotLineIds.isNotEmpty()) {
pickOrderLineIds.flatMap { polId ->
inventoryLotLineIds.flatMap { illId ->
stockOutLineRepository.findByPickOrderLineIdAndInventoryLotLineIdAndDeletedFalse(polId, illId)
}
}
} else {
emptyList()
}

val stockOutLinesByPickOrderLine = pickOrderLineIds.associateWith { polId ->
stockOutLineRepository.findAllByPickOrderLineIdAndDeletedFalse(polId)
}
@@ -322,27 +318,33 @@ open class JoWorkbenchMainService(
val uom = pol.uom
val lineId = pol.id!!
val lineSuggestedLots = suggestedPickLots.filter { it.pickOrderLine?.id == pol.id }
val lineSols = stockOutLinesByPickOrderLine[lineId] ?: emptyList()
val jpo = joPickOrders.firstOrNull { it.itemId == item?.id }
val handlerName = jpo?.handledBy?.let { uid ->
userService.find(uid).orElse(null)?.name
}

val lots = lineSuggestedLots.mapNotNull { spl ->
val ill = spl.suggestedLotLine
if (ill == null || ill.deleted == true) return@mapNotNull null
val displayIllId = displayIllForWorkbench(spl)?.id ?: return@mapNotNull null
// Prefer freshly loaded ILL (actual or suggested) so lotNo/location match display id.
val ill = inventoryLotLineById[displayIllId]
?: displayIllForWorkbench(spl)
?: return@mapNotNull null
if (ill.deleted == true) return@mapNotNull null

val il = ill.inventoryLot
if (il == null || il.deleted == true) return@mapNotNull null

val warehouse = ill.warehouse
val sol = stockOutLines.firstOrNull {
it.pickOrderLine?.id == pol.id && it.inventoryLotLine?.id == ill.id
val linkedSolId = spl.stockOutLine?.takeIf { it.deleted != true }?.id
val sol = when {
linkedSolId != null -> lineSols.firstOrNull { it.id == linkedSolId }
else -> lineSols.firstOrNull { it.inventoryLotLineId == ill.id }
}
val jpoInner = joPickOrders.firstOrNull { it.itemId == item?.id }
val handlerNameInner = jpoInner?.handledBy?.let { uid ->
userService.find(uid).orElse(null)?.name
}
//println("handlerName: $handlerNameInner")
val availableQty = if (sol?.status == "rejected") {
null
} else {
@@ -377,7 +379,7 @@ open class JoWorkbenchMainService(
location = warehouse?.code,
availableQty = availableQty?.toDouble(),
requiredQty = spl.qty?.toDouble() ?: 0.0,
actualPickQty = sol?.qty ?: 0.0,
actualPickQty = sol?.qty?.toDouble() ?: 0.0,
processingStatus = processingStatus,
lotAvailability = lotAvailability,
pickOrderId = pickOrder.id,
@@ -387,7 +389,7 @@ open class JoWorkbenchMainService(
stockOutLineId = sol?.id,
stockInLineId = stockInLineId,
suggestedPickLotId = spl.id,
stockOutLineQty = sol?.qty ?: 0.0,
stockOutLineQty = sol?.qty?.toDouble() ?: 0.0,
stockOutLineStatus = sol?.status,
routerIndex = warehouse?.order?.toString(),
routerArea = warehouse?.code,
@@ -397,9 +399,9 @@ open class JoWorkbenchMainService(
matchBy = jpoInner?.matchBy,
matchQty = jpoInner?.matchQty?.toDouble()
)
}
}.distinctBy { it.lotId }

val stockouts = (stockOutLinesByPickOrderLine[lineId] ?: emptyList()).map { sol ->
val stockouts = lineSols.map { sol ->
val illId = sol.inventoryLotLineId
val ill = if (illId != null) inventoryLotLineById[illId] else null
val lot = ill?.inventoryLot
@@ -412,7 +414,10 @@ open class JoWorkbenchMainService(
illAvailableQtyWorkbench(ill)
}
val splForSol = lineSuggestedLots.firstOrNull { sp ->
sp.suggestedLotLine?.id != null && sp.suggestedLotLine?.id == illId
sp.stockOutLine?.id != null && sp.stockOutLine?.id == sol.id
} ?: lineSuggestedLots.firstOrNull { sp ->
val displayId = displayIllForWorkbench(sp)?.id
displayId != null && displayId == illId
}

StockOutLineDetailResponse(
@@ -456,6 +461,18 @@ open class JoWorkbenchMainService(
}
}

/**
* FP-MTMS Version Checklist | Functions Ref. No. 44 | v1.0.0 | 2026-08-03
* Workbench UI display lot: prefer actual pick (after scan/switch), fallback suggested (pending / old data).
*/
private fun displayIllForWorkbench(spl: com.ffii.fpsms.modules.stock.entity.SuggestedPickLot): InventoryLotLine? {
val actual = spl.actualPickLotLine
if (actual != null && actual.deleted != true) return actual
val suggested = spl.suggestedLotLine
if (suggested != null && suggested.deleted != true) return suggested
return null
}

private fun emptyHierarchical(message: String): JobOrderLotsHierarchicalWorkbenchResponse {
println("❌ $message")
return JobOrderLotsHierarchicalWorkbenchResponse(


+ 6
- 2
src/main/java/com/ffii/fpsms/modules/jobOrder/service/JobOrderPlanStartAutoService.kt Datei anzeigen

@@ -21,7 +21,8 @@ import java.util.concurrent.atomic.AtomicBoolean
* [JobOrder.planStart] fell on the previous calendar day.
*
* - Branch A: no pick submitted, product process pending → hide job order.
* - Branch B: pick submitted, product process still pending → reschedule to today 00:00:00 and renumber.
* - Branch B (disabled): pick submitted, process pending — previously rescheduled + renumbered;
* now SKIP so unfinished JOs stay visible via Production Process list lookback instead.
*/
@Service
open class JobOrderPlanStartAutoService(
@@ -47,6 +48,7 @@ open class JobOrderPlanStartAutoService(
val errors: Int = 0,
)

/** FP-MTMS Version Checklist | Functions Ref. No. 42 | v1.0.0 | 2026-08-03 */
open fun runAutoProcess(runAt: LocalDateTime = LocalDateTime.now()): JobOrderPlanStartAutoReport {
if (!inFlight.compareAndSet(false, true)) {
logger.warn("Job order plan-start auto process skipped: previous run still in flight")
@@ -147,6 +149,7 @@ open class JobOrderPlanStartAutoService(
SKIP,
}

/** FP-MTMS Version Checklist | Functions Ref. No. 42 | v1.0.0 | 2026-08-03 */
private fun classify(
jo: JobOrder,
pickOrders: List<PickOrder>,
@@ -158,7 +161,8 @@ open class JobOrderPlanStartAutoService(
val maxSubmittedLines = pickOrders.maxOfOrNull { it.submittedLines ?: 0 } ?: 0
return when {
maxSubmittedLines == 0 -> Branch.HIDE
maxSubmittedLines > 0 -> Branch.RESCHEDULE
// Renumber / planStart roll-forward disabled — Production Process list carry-over covers visibility.
maxSubmittedLines > 0 -> Branch.SKIP
else -> Branch.SKIP
}
}


+ 3
- 0
src/main/java/com/ffii/fpsms/modules/jobOrder/web/model/CreateJobOrderRequest.kt Datei anzeigen

@@ -49,7 +49,10 @@ data class AllJoPickOrderResponse(
val jobOrderType: String?,
val itemId: Long,
val itemName: String,
val itemCode: String? = null,
val bomDescription: String?,
/** BOM.type (e.g. drink / Powder_Mixture / other). */
val bomType: String? = null,
val reqQty: BigDecimal,
val uomId: Long,
val uomName: String,


+ 125
- 37
src/main/java/com/ffii/fpsms/modules/master/service/ItemsService.kt Datei anzeigen

@@ -266,7 +266,7 @@ open class ItemsService(
return jdbcDao.queryForList(sql.toString(), args);
}

open fun listBagItemsCombo(): List<Map<String, String>> {
open fun listBagItemsCombo(): List<Map<String, String>> {
return itemsRepository.findAllByIsBagTrueAndDeletedFalse()
.mapNotNull { item ->
val code = item.code?.trim().orEmpty()
@@ -319,6 +319,7 @@ open class ItemsService(
}
}

/** FP-MTMS Version Checklist | Functions Ref. No. 35 | v1.0.1 | 2026-07-22 */
open fun getPickOrderItemsByPage(args: Map<String, Any>): List<Map<String, Any>> {
try {
println("=== Debug: getPickOrderItemsByPage in ItemsService ===")
@@ -327,10 +328,12 @@ open class ItemsService(
val sql = StringBuilder(
"""
SELECT
CONCAT(po.id, '-', pol.itemId) as id,
CONCAT(po.id, '-', pol.id) as id,
po.id as pickOrderId,
po.code as pickOrderCode,
pol.id as pickOrderLineId,
pol.itemId,
pol.uomId,
i.code as itemCode,
i.name as itemName,
pol.qty as requiredQty,
@@ -349,15 +352,16 @@ open class ItemsService(
LEFT JOIN (
SELECT
il.itemId,
iu.uomId as uomId,
SUM(COALESCE(ill.inQty, 0) - COALESCE(ill.outQty, 0) - COALESCE(ill.holdQty, 0)) as availableQty
FROM inventory_lot_line ill
JOIN inventory_lot il ON il.id = ill.inventoryLotId
LEFT JOIN item_uom iu_ratio ON iu_ratio.itemId = il.itemId AND iu_ratio.deleted = false AND iu_ratio.salesUnit = true
JOIN item_uom iu ON iu.id = ill.stockItemUomId AND iu.deleted = false
WHERE ill.status = 'available'
AND (COALESCE(ill.inQty, 0) - COALESCE(ill.outQty, 0) - COALESCE(ill.holdQty, 0)) > 0
AND (il.expiryDate IS NULL OR il.expiryDate >= CURDATE())
GROUP BY il.itemId
) inv ON inv.itemId = pol.itemId
GROUP BY il.itemId, iu.uomId
) inv ON inv.itemId = pol.itemId AND inv.uomId = pol.uomId
WHERE po.deleted = false
AND po.consoCode IS NULL
"""
@@ -805,56 +809,66 @@ open class ItemsService(
)
}

/**
* FP-MTMS Version Checklist | Functions Ref. No. 35 | v1.0.1 | 2026-07-22
* Pick-order create search: one row per item + available lot UOM
* (available qty summed per UOM). Items with no available stock are omitted.
*/
open fun getItemsWithDetailsByPage(args: Map<String, Any>): List<Map<String, Any>> {
val sql = StringBuilder(
"select"
+ " i.id, "
+ " i.code, "
+ " i.name, "
+ " i.type, "
+ " i.description, "
+ " uc.id as uomId, "
+ " uc.code as uom, "
+ " uc.udfudesc as uomDesc, "
+ " COALESCE(inv.availableQty, 0) as currentStockBalance "
+ " from items i "
+ " left join item_uom iu on iu.itemId = i.id and iu.deleted = false and iu.salesUnit = true "
+ " left join uom_conversion uc on uc.id = iu.uomId "
+ " left join ("
+ " SELECT "
+ " il.itemId, "
+ " SUM(COALESCE(ill.inQty, 0) - COALESCE(ill.outQty, 0) - COALESCE(ill.holdQty, 0)) as availableQty "
+ " FROM inventory_lot_line ill "
+ " JOIN inventory_lot il ON il.id = ill.inventoryLotId "
+ " LEFT JOIN item_uom iu_ratio ON iu_ratio.itemId = il.itemId AND iu_ratio.deleted = false AND iu_ratio.salesUnit = true "
+ " WHERE ill.status = 'available' "
+ " AND (COALESCE(ill.inQty, 0) - COALESCE(ill.outQty, 0) - COALESCE(ill.holdQty, 0)) > 0 "
+ " AND (il.expiryDate IS NULL OR il.expiryDate >= CURDATE()) "
+ " GROUP BY il.itemId "
+ " ) inv ON inv.itemId = i.id "
+ " where i.deleted = false "
"""
SELECT
i.id,
i.code,
i.name,
i.type,
i.description,
uc.id AS uomId,
uc.code AS uom,
uc.udfudesc AS uomDesc,
SUM(
COALESCE(ill.inQty, 0) - COALESCE(ill.outQty, 0) - COALESCE(ill.holdQty, 0)
) AS currentStockBalance
FROM items i
JOIN inventory_lot il
ON il.itemId = i.id
AND il.deleted = false
JOIN inventory_lot_line ill
ON ill.inventoryLotId = il.id
AND ill.deleted = false
JOIN item_uom iu
ON iu.id = ill.stockItemUomId
AND iu.deleted = false
JOIN uom_conversion uc
ON uc.id = iu.uomId
WHERE i.deleted = false
AND ill.status = 'available'
AND (COALESCE(ill.inQty, 0) - COALESCE(ill.outQty, 0) - COALESCE(ill.holdQty, 0)) > 0
AND (il.expiryDate IS NULL OR il.expiryDate >= CURDATE())
""".trimIndent()
)

// Add search conditions
if (args.containsKey("name")) {
sql.append(" AND i.name LIKE :name ")
}

if (args.containsKey("code")) {
sql.append(" AND i.code LIKE :code ")
}

if (args.containsKey("description")) {
sql.append(" AND i.description LIKE :description ")
}

if (args.containsKey("type")) {
sql.append(" AND i.type = :type ")
}

sql.append(" ORDER BY i.name ASC ")
sql.append(
"""
GROUP BY i.id, i.code, i.name, i.type, i.description, uc.id, uc.code, uc.udfudesc
ORDER BY i.name ASC, uc.udfudesc ASC
""".trimIndent()
)

return jdbcDao.queryForList(sql.toString(), args);
return jdbcDao.queryForList(sql.toString(), args)
}

open fun getItemOrderOrderedItems(search: String?, type: String? = null): List<Map<String, Any>> {
@@ -1024,4 +1038,78 @@ open class ItemsService(
}?.takeIf { it.isNotBlank() }
}

/**
* FP-MTMS Version Checklist | Functions Ref. No. 35 | v1.0.1 | 2026-07-22
* Pick-order create: stock UOM (default) + distinct UOMs from currently available lots.
* Lot UOM = inventory_lot_line.stockItemUom → item_uom.uom (uom_conversion id).
*/
open fun getAvailablePickUoms(itemId: Long): Map<String, Any?> {
val args = mapOf("itemId" to itemId)

val stockRows = jdbcDao.queryForList(
"""
SELECT
uc.id AS uomId,
uc.code AS uomCode,
uc.udfudesc AS uomDesc
FROM item_uom iu
JOIN uom_conversion uc ON uc.id = iu.uomId
WHERE iu.itemId = :itemId
AND iu.deleted = false
AND iu.stockUnit = true
LIMIT 1
""".trimIndent(),
args,
)
val stockUom = stockRows.firstOrNull()?.let { row ->
mapOf(
"uomId" to row["uomId"],
"uomCode" to row["uomCode"],
"uomDesc" to row["uomDesc"],
)
}

val lotRows = jdbcDao.queryForList(
"""
SELECT DISTINCT
uc.id AS uomId,
uc.code AS uomCode,
uc.udfudesc AS uomDesc
FROM inventory_lot_line ill
JOIN inventory_lot il ON il.id = ill.inventoryLotId
JOIN item_uom iu ON iu.id = ill.stockItemUomId AND iu.deleted = false
JOIN uom_conversion uc ON uc.id = iu.uomId
WHERE il.itemId = :itemId
AND ill.deleted = false
AND ill.status = 'available'
AND (COALESCE(ill.inQty, 0) - COALESCE(ill.outQty, 0)) > 0
AND (il.expiryDate IS NULL OR il.expiryDate >= CURDATE())
ORDER BY uc.udfudesc ASC
""".trimIndent(),
args,
)

val byId = linkedMapOf<Long, Map<String, Any?>>()
fun putUom(row: Map<String, Any?>?) {
if (row == null) return
val id = (row["uomId"] as? Number)?.toLong() ?: return
if (id <= 0L) return
if (!byId.containsKey(id)) {
byId[id] = mapOf(
"uomId" to id,
"uomCode" to row["uomCode"],
"uomDesc" to row["uomDesc"],
)
}
}
putUom(stockUom)
lotRows.forEach { putUom(it) }

return mapOf(
"itemId" to itemId,
"stockUom" to stockUom,
"options" to byId.values.toList(),
)
}

}

+ 9
- 0
src/main/java/com/ffii/fpsms/modules/master/web/ItemsController.kt Datei anzeigen

@@ -60,6 +60,15 @@ class ItemsController(
fun itemsExistsByCodes(@RequestBody codes: List<String>): List<Map<String, Any?>> {
return itemsService.itemsExistsByCodes(codes)
}

/**
* FP-MTMS Version Checklist | Functions Ref. No. 35 | v1.0.1 | 2026-07-22
* Stock UOM + distinct available-lot UOMs for pick-order create dropdown.
*/
@GetMapping("/{itemId}/available-pick-uoms")
fun getAvailablePickUoms(@PathVariable itemId: Long): Map<String, Any?> {
return itemsService.getAvailablePickUoms(itemId)
}
// @GetMapping("/getRecordByPage")
// fun getAllItemsByPage(@RequestBody filterRequest: HttpServletRequest): RecordsRes<Map<String, Any>> {
// val pageSize = filterRequest.getParameter("pageSize").toString().toInt(); // Default to 10 if not provided


+ 51
- 0
src/main/java/com/ffii/fpsms/modules/pickOrder/entity/PickOrderRepository.kt Datei anzeigen

@@ -100,6 +100,7 @@ fun findAllByJobOrder_Id(jobOrderId: Long): List<PickOrder>
fun findAllByJobOrder_IdInAndDeletedFalseOrderByJobOrder_IdAscCreatedDesc(jobOrderIds: Collection<Long>): List<PickOrder>
fun findTopByJobOrder_IdOrderByCreatedDesc(jobOrderId: Long): PickOrder?
fun findAllByStatusIn(statuses: List<PickOrderStatus>): List<PickOrder>
fun findByCodeAndDeletedFalse(@Param("code") code: String): PickOrder?

@Query(
"""
@@ -253,4 +254,54 @@ fun findAllReleasedJoWorkbenchPickOrders(
nativeQuery = true,
)
fun incrementSubmittedLines(@Param("pickOrderId") pickOrderId: Long): Int

/**
* FP-MTMS Version Checklist | Functions Ref. No. 41 | v1.0.0 | 2026-08-03
* JO ids that have at least one stock_out_line counted as picked
* (checked / partially_completed / completed / picking, or rejected with qty > 0).
*/
@Query(
value = """
SELECT DISTINCT po.joId
FROM pick_order po
INNER JOIN pick_order_line pol ON pol.poId = po.id AND pol.deleted = 0
INNER JOIN stock_out_line sol ON sol.pickOrderLineId = pol.id AND sol.deleted = 0
WHERE po.deleted = 0
AND po.joId IN (:joIds)
AND (
LOWER(COALESCE(sol.status, '')) IN ('checked', 'partially_completed', 'completed', 'picking')
OR (LOWER(COALESCE(sol.status, '')) = 'rejected' AND COALESCE(sol.qty, 0) > 0)
)
""",
nativeQuery = true,
)
fun findPickedJobOrderIds(@Param("joIds") joIds: Collection<Long>): List<Long>

/** Unlimited: picked + product process still pending / not started. */
@Query(
value = """
SELECT DISTINCT po.joId
FROM pick_order po
INNER JOIN job_order jo
ON jo.id = po.joId
AND IFNULL(jo.deleted, 0) = 0
AND IFNULL(jo.isHidden, 0) = 0
AND LOWER(IFNULL(jo.status, '')) NOT IN ('completed', 'planning')
INNER JOIN productprocess pp
ON pp.jobOrderId = jo.id
AND IFNULL(pp.deleted, 0) = 0
AND LOWER(pp.status) = 'pending'
AND pp.startTime IS NULL
INNER JOIN pick_order_line pol ON pol.poId = po.id AND pol.deleted = 0
INNER JOIN stock_out_line sol ON sol.pickOrderLineId = pol.id AND sol.deleted = 0
WHERE po.deleted = 0
AND po.joId IS NOT NULL
AND (
LOWER(COALESCE(sol.status, '')) IN ('checked', 'partially_completed', 'completed', 'picking')
OR (LOWER(COALESCE(sol.status, '')) = 'rejected' AND COALESCE(sol.qty, 0) > 0)
)
""",
nativeQuery = true,
)
fun findPickedNotStartedJobOrderIds(): List<Long>
}

+ 10
- 4
src/main/java/com/ffii/fpsms/modules/pickOrder/service/HierarchicalFgPayloadAssembler.kt Datei anzeigen

@@ -5,6 +5,7 @@ import java.util.Locale
import kotlin.system.measureTimeMillis

/**
* FP-MTMS Version Checklist | Functions Ref. No. 45 | v1.0.0 | 2026-08-03
* Shared hierarchical FG payload (fgInfo + merged pickOrders with lines/lots) for both
* legacy [do_pick_order_line] and workbench [delivery_order_pick_order] flows.
*/
@@ -60,7 +61,8 @@ fun assembleHierarchicalFgPayload(
AND pol.deleted = false
),
ll AS (
SELECT spl.pickOrderLineId, spl.suggestedLotLineId AS lotLineId
SELECT spl.pickOrderLineId,
COALESCE(spl.actualPickLotLineId, spl.suggestedLotLineId) AS lotLineId
FROM fpsmsdb.suggested_pick_lot spl
JOIN target_pol tp ON tp.id = spl.pickOrderLineId
WHERE spl.deleted = false
@@ -73,11 +75,13 @@ ll AS (
WHERE sol.deleted = false
),
sm AS (
SELECT s.pickOrderLineId, s.suggestedLotLineId, MAX(s.id) AS maxId
SELECT s.pickOrderLineId,
COALESCE(s.actualPickLotLineId, s.suggestedLotLineId) AS displayLotLineId,
MAX(s.id) AS maxId
FROM fpsmsdb.suggested_pick_lot s
JOIN target_pol tp ON tp.id = s.pickOrderLineId
WHERE s.deleted = false
GROUP BY s.pickOrderLineId, s.suggestedLotLineId
GROUP BY s.pickOrderLineId, COALESCE(s.actualPickLotLineId, s.suggestedLotLineId)
)
SELECT
po.id as pickOrderId,
@@ -96,6 +100,7 @@ SELECT
i.code as itemCode,
i.name as itemName,
i.item_Order as itemOrder,
pol.uomId as uomId,
uc.code as uomCode,
uc.udfudesc as uomDesc,
uc.udfShortDesc as uomShortDesc,
@@ -149,7 +154,7 @@ LEFT JOIN ll ON ll.pickOrderLineId = pol.id

LEFT JOIN sm
ON sm.pickOrderLineId = pol.id
AND sm.suggestedLotLineId <=> ll.lotLineId
AND sm.displayLotLineId <=> ll.lotLineId
LEFT JOIN fpsmsdb.suggested_pick_lot spl
ON spl.id = sm.maxId
AND spl.deleted = false
@@ -261,6 +266,7 @@ ORDER BY
"id" to firstLineRow["itemId"],
"code" to firstLineRow["itemCode"],
"name" to firstLineRow["itemName"],
"uomId" to firstLineRow["uomId"],
"uomCode" to firstLineRow["uomCode"],
"uomDesc" to firstLineRow["uomDesc"],
"uomShortDesc" to firstLineRow["uomShortDesc"],


+ 31
- 6
src/main/java/com/ffii/fpsms/modules/pickOrder/service/PickExecutionIssueService.kt Datei anzeigen

@@ -45,6 +45,7 @@ import com.ffii.fpsms.modules.common.CodeGenerator
import com.ffii.fpsms.modules.stock.web.model.BatchStockOutResult
import com.ffii.fpsms.modules.stock.entity.StockLedger
import com.ffii.fpsms.modules.stock.entity.StockLedgerRepository
import com.ffii.fpsms.modules.stock.service.StockLedgerLotSnapshot
import com.ffii.fpsms.modules.deliveryOrder.entity.DeliveryOrderRepository
import com.ffii.fpsms.modules.deliveryOrder.enums.DeliveryOrderStatus
import com.ffii.fpsms.modules.stock.entity.SuggestPickLotRepository
@@ -2371,6 +2372,7 @@ private fun updateInventoryAfterLotLineChange(lotLine: InventoryLotLine) {
e.printStackTrace()
}
}
/** FP-MTMS Version Checklist | Functions Ref. No. 46 | v1.0.1 | 2026-08-05 */
private fun createStockLedgerForStockOut(
stockOutLine: StockOutLine,
ledgerType: String? = null,
@@ -2396,6 +2398,9 @@ private fun createStockLedgerForStockOut(
val inventory = itemUomService.findInventoryForItemBaseUom(item.id!!) ?: return
val ill = stockOutLine.inventoryLotLine
val outQtyBd = BigDecimal.valueOf(outQty)
val lotAfter = ill?.let { StockLedgerLotSnapshot.availableQty(it) }
val stockLedger = StockLedger().apply {
this.stockOutLine = stockOutLine
this.inventory = inventory
@@ -2405,10 +2410,19 @@ private fun createStockLedgerForStockOut(
this.type = ledgerTypeToUse
this.itemId = item.id
this.itemCode = item.code
this.uomId = inventory.uom?.id
?: itemUomRespository.findByItemIdAndStockUnitIsTrueAndDeletedIsFalse(item.id!!)?.uom?.id
this.uomId = StockLedgerLotSnapshot.resolveLedgerUomId(
ill,
inventory.uom?.id,
itemUomRespository.findByItemIdAndStockUnitIsTrueAndDeletedIsFalse(item.id!!)?.uom?.id,
)
this.date = LocalDate.now()
}
StockLedgerLotSnapshot.apply(
ledger = stockLedger,
inventoryLotLine = ill,
lotAvailableAfterMove = lotAfter,
signedDelta = outQtyBd.negate(),
)
stockLedgerRepository.saveAndFlush(stockLedger)
@@ -2419,8 +2433,6 @@ private fun createStockLedgerForStockOut(
println("===========================")
}



@Transactional(propagation = Propagation.REQUIRES_NEW, rollbackFor = [Exception::class])
private fun handleBadItemPackageProblem(request: PickExecutionIssueRequest, badItemQty: BigDecimal) {
println("=== HANDLING BAD ITEM PACKAGE PROBLEM (DON'T REJECT LOT) ===")
@@ -2865,6 +2877,7 @@ open fun submitIssueWithQty(request: SubmitIssueWithQtyRequest): MessageResponse
}

// New: Create stock ledger with explicit balance (to avoid double subtraction)
/** FP-MTMS Version Checklist | Functions Ref. No. 46 | v1.0.1 | 2026-08-05 */
private fun createStockLedgerForStockOutWithBalance(
stockOutLine: StockOutLine,
balance: Double,
@@ -2882,6 +2895,9 @@ private fun createStockLedgerForStockOutWithBalance(
val inventory = itemUomService.findInventoryForItemBaseUom(item.id!!) ?: return
val ill = stockOutLine.inventoryLotLine
val outQtyBd = BigDecimal.valueOf(outQty)
val lotAfter = ill?.let { StockLedgerLotSnapshot.availableQty(it) }
val stockLedger = StockLedger().apply {
this.stockOutLine = stockOutLine
this.inventory = inventory
@@ -2891,10 +2907,19 @@ private fun createStockLedgerForStockOutWithBalance(
this.type = ledgerTypeToUse
this.itemId = item.id
this.itemCode = item.code
this.uomId = inventory.uom?.id
?: itemUomRespository.findByItemIdAndStockUnitIsTrueAndDeletedIsFalse(item.id!!)?.uom?.id
this.uomId = StockLedgerLotSnapshot.resolveLedgerUomId(
ill,
inventory.uom?.id,
itemUomRespository.findByItemIdAndStockUnitIsTrueAndDeletedIsFalse(item.id!!)?.uom?.id,
)
this.date = LocalDate.now()
}
StockLedgerLotSnapshot.apply(
ledger = stockLedger,
inventoryLotLine = ill,
lotAvailableAfterMove = lotAfter,
signedDelta = outQtyBd.negate(),
)
stockLedgerRepository.saveAndFlush(stockLedger)


+ 284
- 0
src/main/java/com/ffii/fpsms/modules/pickOrder/service/PickOrderLifecycleService.kt Datei anzeigen

@@ -0,0 +1,284 @@
package com.ffii.fpsms.modules.pickOrder.service

import com.ffii.fpsms.modules.deliveryOrder.entity.DoPickOrderLineRepository
import com.ffii.fpsms.modules.deliveryOrder.entity.DoPickOrderRecordRepository
import com.ffii.fpsms.modules.deliveryOrder.entity.DoPickOrderRepository
import com.ffii.fpsms.modules.deliveryOrder.enums.DoPickOrderStatus
import com.ffii.fpsms.modules.jobOrder.entity.JoPickOrderRecordRepository
import com.ffii.fpsms.modules.jobOrder.entity.JoPickOrderRepository
import com.ffii.fpsms.modules.jobOrder.enums.JoPickOrderStatus
import com.ffii.fpsms.modules.pickOrder.entity.PickOrderGroupRepository
import com.ffii.fpsms.modules.pickOrder.entity.PickOrderRepository
import com.ffii.fpsms.modules.pickOrder.enums.PickOrderStatus
import com.ffii.fpsms.modules.pickOrder.enums.PickOrderType
import com.ffii.fpsms.modules.pickOrder.web.models.*
import org.springframework.stereotype.Service
import org.springframework.transaction.annotation.Transactional
import java.time.format.DateTimeFormatter

@Service
@Transactional(readOnly = true)
open class PickOrderLifecycleService(
private val pickOrderRepository: PickOrderRepository,
private val pickOrderGroupRepository: PickOrderGroupRepository,
private val doPickOrderRepository: DoPickOrderRepository,
private val doPickOrderRecordRepository: DoPickOrderRecordRepository,
private val doPickOrderLineRepository: DoPickOrderLineRepository,
private val joPickOrderRepository: JoPickOrderRepository,
private val joPickOrderRecordRepository: JoPickOrderRecordRepository,
) {
private val dtf = DateTimeFormatter.ofPattern("yyyy-MM-dd HH:mm:ss")

/** FP-MTMS Version Checklist | Functions Ref. No. 11 | v1.0.0 | 2026-07-17 */
open fun getLifecycle(pickOrderId: Long): PickOrderLifecycleResponse? {
val po = pickOrderRepository.findById(pickOrderId).orElse(null) ?: return null

val groups = po.id?.let { pickOrderGroupRepository.findByPickOrderIdAndDeletedIsFalse(it) } ?: emptyList()
val groupName = groups.firstOrNull()?.name

val stages = buildPickOrderStages(po)
val doLifecycle = if (po.type == PickOrderType.DELIVERY_ORDER) buildDoLifecycle(pickOrderId) else null
val joLifecycle = if (po.type == PickOrderType.JOB_ORDER) buildJoLifecycle(pickOrderId) else null

return PickOrderLifecycleResponse(
pickOrderId = po.id!!,
pickOrderCode = po.code ?: "",
pickOrderType = po.type?.value ?: "",
currentStatus = po.status?.value ?: "",
targetDate = po.targetDate?.format(dtf),
completeDate = po.completeDate?.format(dtf),
consoCode = po.consoCode,
groupName = groupName,
totalLines = po.totalLines,
submittedLines = po.submittedLines,
stages = stages,
doLifecycle = doLifecycle,
joLifecycle = joLifecycle,
)
}

/** FP-MTMS Version Checklist | Functions Ref. No. 11 | v1.0.0 | 2026-07-17 */
open fun getLifecycleByCode(code: String): PickOrderLifecycleResponse? {
val po = pickOrderRepository.findByCodeAndDeletedFalse(code) ?: return null
return po.id?.let { getLifecycle(it) }
}

private fun buildPickOrderStages(po: com.ffii.fpsms.modules.pickOrder.entity.PickOrder): List<LifecycleStage> {
val currentStatus = po.status ?: PickOrderStatus.PENDING
val statusOrder = listOf(
PickOrderStatus.PENDING to "待處理",
PickOrderStatus.CONSOLIDATED to "已合併",
PickOrderStatus.ASSIGNED to "已指派",
PickOrderStatus.RELEASED to "已發放",
PickOrderStatus.PICKING to "提料中",
PickOrderStatus.COMPLETED to "已完成",
)

val currentIdx = statusOrder.indexOfFirst { it.first == currentStatus }

return statusOrder.mapIndexed { idx, (status, label) ->
val reached = idx <= currentIdx
val isCurrent = status == currentStatus
LifecycleStage(
status = status.value,
label = label,
reached = reached,
timestamp = when (status) {
PickOrderStatus.RELEASED -> po.releasedDate?.format(dtf)
PickOrderStatus.COMPLETED -> po.completeDate?.format(dtf)
else -> null
},
actor = when (status) {
PickOrderStatus.RELEASED -> po.releasedBy?.name
PickOrderStatus.ASSIGNED -> po.assignTo?.name
else -> null
},
actorId = when (status) {
PickOrderStatus.RELEASED -> po.releasedBy?.id
PickOrderStatus.ASSIGNED -> po.assignTo?.id
else -> null
},
isCurrent = isCurrent,
details = buildStageDetails(po, status),
)
}
}

private fun buildStageDetails(
po: com.ffii.fpsms.modules.pickOrder.entity.PickOrder,
status: PickOrderStatus
): Map<String, String?> {
val map = mutableMapOf<String, String?>()
when (status) {
PickOrderStatus.CONSOLIDATED -> {
po.consoCode?.let { map["consoCode"] = it }
}
PickOrderStatus.RELEASED -> {
po.consoCode?.let { map["consoCode"] = it }
map["plasticBoxCartonQty2f"] = po.plasticBoxCartonQty2f?.toString()
map["plasticBoxCartonQty3f"] = po.plasticBoxCartonQty3f?.toString()
map["plasticBoxCartonQty4f"] = po.plasticBoxCartonQty4f?.toString()
}
PickOrderStatus.COMPLETED -> {
map["totalLines"] = po.totalLines?.toString()
map["submittedLines"] = po.submittedLines?.toString()
}
else -> {}
}
return map
}

private fun buildDoLifecycle(pickOrderId: Long): DoLifecycleInfo? {
// Check active do_pick_order first
val doPickOrderLines = doPickOrderLineRepository.findByPickOrderIdAndDeletedFalse(pickOrderId)
val doPickOrderId = doPickOrderLines.firstOrNull()?.doPickOrderId

var dpo = doPickOrderId?.let { doPickOrderRepository.findById(it).orElse(null) }
var deliveryNoteCode: String? = null

// If not in active table, check record table
if (dpo == null) {
val records = doPickOrderRecordRepository.findByPickOrderId(pickOrderId)
val record = records.maxByOrNull { it.id ?: 0L }
if (record != null) {
deliveryNoteCode = record.deliveryNoteCode
}
}

if (dpo == null && deliveryNoteCode == null) {
// Try do_pick_order_record by looking up via delivery_order
val po = pickOrderRepository.findById(pickOrderId).orElse(null)
val doId = po?.deliveryOrder?.id
if (doId != null) {
val doRecords = doPickOrderRecordRepository.findByDoOrderIdAndDeletedFalse(doId)
val doRecord = doRecords.firstOrNull()
if (doRecord != null) {
deliveryNoteCode = doRecord.deliveryNoteCode
}
}
}

if (dpo == null && deliveryNoteCode == null) return null

val stages = buildDoStages(dpo)
return DoLifecycleInfo(
doPickOrderId = dpo?.id,
ticketNo = dpo?.ticketNo,
ticketStatus = dpo?.ticketStatus?.value ?: DoPickOrderStatus.completed.value,
storeId = dpo?.storeId,
shopName = dpo?.shopName,
truckLanceCode = dpo?.truckLanceCode,
deliveryNoteCode = deliveryNoteCode,
deliveryOrderCode = dpo?.deliveryOrderCode,
loadingSequence = dpo?.loadingSequence,
stages = stages,
)
}

private fun buildDoStages(dpo: com.ffii.fpsms.modules.deliveryOrder.entity.DoPickOrder?): List<LifecycleStage> {
val currentStatus = dpo?.ticketStatus ?: DoPickOrderStatus.completed
val statusOrder = listOf(
DoPickOrderStatus.pending to "DO待處理",
DoPickOrderStatus.released to "DO已發放",
DoPickOrderStatus.completed to "DO已完成(DN產生)",
)

val currentIdx = statusOrder.indexOfFirst { it.first == currentStatus }
if (currentIdx < 0 && dpo == null) {
// Already completed and archived
return statusOrder.mapIndexed { idx, (status, label) ->
LifecycleStage(
status = status.value,
label = label,
reached = true,
timestamp = null,
actor = null,
actorId = null,
isCurrent = false,
)
}
}

return statusOrder.mapIndexed { idx, (status, label) ->
val reached = idx <= maxOf(currentIdx, 0)
val isCurrent = status == currentStatus
LifecycleStage(
status = status.value,
label = label,
reached = reached,
timestamp = when (status) {
DoPickOrderStatus.released -> dpo?.ticketReleaseTime?.format(dtf)
DoPickOrderStatus.completed -> dpo?.ticketCompleteDateTime?.format(dtf)
else -> null
},
actor = when (status) {
DoPickOrderStatus.released -> dpo?.handlerName
DoPickOrderStatus.completed -> dpo?.handlerName
else -> null
},
actorId = dpo?.handledBy,
isCurrent = isCurrent,
)
}
}

private fun buildJoLifecycle(pickOrderId: Long): JoLifecycleInfo? {
val jpos = joPickOrderRepository.findByPickOrderId(pickOrderId)
val jpo = jpos.firstOrNull()

// Check record table if not in active
val jpoRecord = if (jpo == null) {
joPickOrderRecordRepository.findByPickOrderId(pickOrderId).firstOrNull()
} else null

if (jpo == null && jpoRecord == null) return null

val stages = buildJoStages(jpo)
return JoLifecycleInfo(
joPickOrderId = jpo?.id ?: jpoRecord?.id,
jobOrderCode = jpo?.jobOrderCode ?: jpoRecord?.jobOrderCode,
matchStatus = jpo?.matchStatus?.value ?: jpoRecord?.matchStatus?.value,
matchQty = jpo?.matchQty ?: jpoRecord?.matchQty,
matchBy = jpo?.matchingBy?.toString() ?: jpoRecord?.matchBy?.toString(),
stages = stages,
)
}

private fun buildJoStages(jpo: com.ffii.fpsms.modules.jobOrder.entity.JoPickOrder?): List<LifecycleStage> {
val currentStatus = jpo?.matchStatus ?: JoPickOrderStatus.completed
val statusOrder = listOf(
JoPickOrderStatus.pending to "等待二次掃描",
JoPickOrderStatus.scanned to "已掃描",
JoPickOrderStatus.completed to "配對完成",
)

val currentIdx = statusOrder.indexOfFirst { it.first == currentStatus }
if (currentIdx < 0 && jpo == null) {
return statusOrder.mapIndexed { idx, (status, label) ->
LifecycleStage(
status = status.value,
label = label,
reached = true,
timestamp = null,
actor = null,
actorId = null,
isCurrent = false,
)
}
}

return statusOrder.mapIndexed { idx, (status, label) ->
val reached = idx <= maxOf(currentIdx, 0)
val isCurrent = status == currentStatus
LifecycleStage(
status = status.value,
label = label,
reached = reached,
timestamp = null,
actor = null,
actorId = jpo?.matchingBy,
isCurrent = isCurrent,
)
}
}
}

+ 13
- 4
src/main/java/com/ffii/fpsms/modules/pickOrder/service/PickOrderService.kt Datei anzeigen

@@ -3804,8 +3804,10 @@ open fun getAllPickOrderLotsWithDetailsHierarchical(userId: Long): Map<String, A
LEFT JOIN fpsmsdb.uom_conversion uc ON uc.id = pol.uomId

LEFT JOIN (
SELECT spl.pickOrderLineId, spl.suggestedLotLineId AS lotLineId
SELECT spl.pickOrderLineId,
COALESCE(spl.actualPickLotLineId, spl.suggestedLotLineId) AS lotLineId
FROM fpsmsdb.suggested_pick_lot spl
WHERE spl.deleted = false
UNION
SELECT sol.pickOrderLineId, sol.inventoryLotLineId
FROM fpsmsdb.stock_out_line sol
@@ -3813,7 +3815,9 @@ open fun getAllPickOrderLotsWithDetailsHierarchical(userId: Long): Map<String, A
) ll ON ll.pickOrderLineId = pol.id

LEFT JOIN fpsmsdb.suggested_pick_lot spl
ON spl.pickOrderLineId = pol.id AND spl.suggestedLotLineId = ll.lotLineId
ON spl.pickOrderLineId = pol.id
AND spl.deleted = false
AND COALESCE(spl.actualPickLotLineId, spl.suggestedLotLineId) <=> ll.lotLineId

-- lots 用(保留原逻辑)
LEFT JOIN fpsmsdb.stock_out_line sol
@@ -4018,6 +4022,7 @@ println("DEBUG sol polIds in linesResults: " + linesResults.mapNotNull { it["sto
)
}
*/
/** FP-MTMS Version Checklist | Functions Ref. No. 45 | v1.0.0 | 2026-08-03 */
open fun getAllPickOrderLotsWithDetailsHierarchical(userId: Long): Map<String, Any?> {
val user = userService.find(userId).orElse(null)
if (user == null) {
@@ -4162,15 +4167,19 @@ open fun getAllPickOrderLotsWithDetailsHierarchical(userId: Long): Map<String, A
JOIN fpsmsdb.items i ON i.id = pol.itemId
LEFT JOIN fpsmsdb.uom_conversion uc ON uc.id = pol.uomId
LEFT JOIN (
SELECT spl.pickOrderLineId, spl.suggestedLotLineId AS lotLineId
SELECT spl.pickOrderLineId,
COALESCE(spl.actualPickLotLineId, spl.suggestedLotLineId) AS lotLineId
FROM fpsmsdb.suggested_pick_lot spl
WHERE spl.deleted = false
UNION
SELECT sol.pickOrderLineId, sol.inventoryLotLineId
FROM fpsmsdb.stock_out_line sol
WHERE sol.deleted = false
) ll ON ll.pickOrderLineId = pol.id
LEFT JOIN fpsmsdb.suggested_pick_lot spl
ON spl.pickOrderLineId = pol.id AND spl.suggestedLotLineId = ll.lotLineId
ON spl.pickOrderLineId = pol.id
AND spl.deleted = false
AND COALESCE(spl.actualPickLotLineId, spl.suggestedLotLineId) <=> ll.lotLineId
LEFT JOIN fpsmsdb.stock_out_line sol
ON sol.pickOrderLineId = pol.id
AND ( (sol.inventoryLotLineId = ll.lotLineId)


+ 8
- 5
src/main/java/com/ffii/fpsms/modules/pickOrder/service/PickOrderWorkbenchService.kt Datei anzeigen

@@ -145,6 +145,7 @@ open class PickOrderWorkbenchService(
}

/**
* FP-MTMS Version Checklist | Functions Ref. No. 45 | v1.0.0 | 2026-08-03
* Consumable workbench hierarchical payload for Tab3.
* Uses no-hold available qty semantics (inQty - outQty).
*/
@@ -251,6 +252,7 @@ open class PickOrderWorkbenchService(
"id" to pol.item?.id,
"code" to pol.item?.code,
"name" to pol.item?.name,
"uomId" to pol.uom?.id,
"uomCode" to pol.uom?.code,
"uomDesc" to pol.uom?.udfudesc,
),
@@ -274,6 +276,7 @@ open class PickOrderWorkbenchService(

/**
* Workbench line detail V2 (consumable):
* FP-MTMS Version Checklist | Functions Ref. No. 45 | v1.0.0 | 2026-08-03
* - Returns only lot rows already linked by suggestion/stock_out_line for this pick order line.
* - Uses no-hold availability semantics: available = inQty - outQty.
* - Includes no-lot stock_out_line rows.
@@ -305,14 +308,14 @@ open class PickOrderWorkbenchService(
LEFT JOIN fpsmsdb.warehouse w ON w.id = ill.warehouseId
LEFT JOIN (
SELECT
s.suggestedLotLineId AS lotLineId,
COALESCE(s.actualPickLotLineId, s.suggestedLotLineId) AS lotLineId,
MAX(s.id) AS suggestedPickLotId,
SUM(COALESCE(s.qty,0)) AS requiredQty
FROM fpsmsdb.suggested_pick_lot s
WHERE s.pickOrderLineId = :pickOrderLineId
AND s.deleted = false
AND s.suggestedLotLineId IS NOT NULL
GROUP BY s.suggestedLotLineId
AND COALESCE(s.actualPickLotLineId, s.suggestedLotLineId) IS NOT NULL
GROUP BY COALESCE(s.actualPickLotLineId, s.suggestedLotLineId)
) spl ON spl.lotLineId = ill.id
LEFT JOIN (
SELECT
@@ -327,11 +330,11 @@ open class PickOrderWorkbenchService(
GROUP BY so.inventoryLotLineId
) sol ON sol.lotLineId = ill.id
WHERE ill.id IN (
SELECT DISTINCT s.suggestedLotLineId
SELECT DISTINCT COALESCE(s.actualPickLotLineId, s.suggestedLotLineId)
FROM fpsmsdb.suggested_pick_lot s
WHERE s.pickOrderLineId = :pickOrderLineId
AND s.deleted = false
AND s.suggestedLotLineId IS NOT NULL
AND COALESCE(s.actualPickLotLineId, s.suggestedLotLineId) IS NOT NULL
UNION
SELECT DISTINCT so.inventoryLotLineId
FROM fpsmsdb.stock_out_line so


+ 28
- 0
src/main/java/com/ffii/fpsms/modules/pickOrder/web/PickOrderLifecycleController.kt Datei anzeigen

@@ -0,0 +1,28 @@
package com.ffii.fpsms.modules.pickOrder.web

import com.ffii.fpsms.modules.pickOrder.service.PickOrderLifecycleService
import com.ffii.fpsms.modules.pickOrder.web.models.PickOrderLifecycleResponse
import org.springframework.http.ResponseEntity
import org.springframework.web.bind.annotation.*

@RestController
@RequestMapping("/pickOrder")
class PickOrderLifecycleController(
private val lifecycleService: PickOrderLifecycleService,
) {
/** FP-MTMS Version Checklist | Functions Ref. No. 11 | v1.0.0 | 2026-07-17 */
@GetMapping("/{id}/lifecycle")
fun getLifecycle(@PathVariable id: Long): ResponseEntity<PickOrderLifecycleResponse> {
val result = lifecycleService.getLifecycle(id)
return if (result != null) ResponseEntity.ok(result)
else ResponseEntity.notFound().build()
}

/** FP-MTMS Version Checklist | Functions Ref. No. 11 | v1.0.0 | 2026-07-17 */
@GetMapping("/lifecycle/by-code")
fun getLifecycleByCode(@RequestParam code: String): ResponseEntity<PickOrderLifecycleResponse> {
val result = lifecycleService.getLifecycleByCode(code)
return if (result != null) ResponseEntity.ok(result)
else ResponseEntity.notFound().build()
}
}

+ 50
- 0
src/main/java/com/ffii/fpsms/modules/pickOrder/web/models/PickOrderLifecycleResponse.kt Datei anzeigen

@@ -0,0 +1,50 @@
package com.ffii.fpsms.modules.pickOrder.web.models

data class LifecycleStage(
val status: String,
val label: String,
val reached: Boolean,
val timestamp: String?,
val actor: String?,
val actorId: Long?,
val isCurrent: Boolean,
val details: Map<String, String?> = emptyMap(),
)

data class DoLifecycleInfo(
val doPickOrderId: Long?,
val ticketNo: String?,
val ticketStatus: String?,
val storeId: String?,
val shopName: String?,
val truckLanceCode: String?,
val deliveryNoteCode: String?,
val deliveryOrderCode: String?,
val loadingSequence: Int?,
val stages: List<LifecycleStage>,
)

data class JoLifecycleInfo(
val joPickOrderId: Long?,
val jobOrderCode: String?,
val matchStatus: String?,
val matchQty: Int?,
val matchBy: String?,
val stages: List<LifecycleStage>,
)

data class PickOrderLifecycleResponse(
val pickOrderId: Long,
val pickOrderCode: String,
val pickOrderType: String,
val currentStatus: String,
val targetDate: String?,
val completeDate: String?,
val consoCode: String?,
val groupName: String?,
val totalLines: Int?,
val submittedLines: Int?,
val stages: List<LifecycleStage>,
val doLifecycle: DoLifecycleInfo?,
val joLifecycle: JoLifecycleInfo?,
)

+ 68
- 0
src/main/java/com/ffii/fpsms/modules/productProcess/entity/ProductProcessRepository.kt Datei anzeigen

@@ -55,4 +55,72 @@ interface ProductProcessRepository : JpaRepository<ProductProcess, Long>, JpaSpe
@Param("useBomIds") useBomIds: Boolean,
@Param("bomIds") bomIds: Collection<Long>,
): List<JobOrderProcessAggregate>

/**
* FP-MTMS Version Checklist | Functions Ref. No. 41 | v1.0.0 | 2026-08-03
* Production list lookback: inclusive date range on ProductProcess.date.
*/
@Query(
"""
SELECT
p.jobOrder.id AS jobOrderId,
MAX(p.date) AS maxDate,
MIN(COALESCE(p.productionPriority, 2147483647)) AS minPriority
FROM ProductProcess p
WHERE p.deleted = false
AND p.jobOrder IS NOT NULL
AND COALESCE(p.jobOrder.isHidden, false) = false
AND p.jobOrder.status <> com.ffii.fpsms.modules.jobOrder.enums.JobOrderStatus.PLANNING
AND p.jobOrder.status <> com.ffii.fpsms.modules.jobOrder.enums.JobOrderStatus.COMPLETED
AND (:dateFrom IS NULL OR p.date >= :dateFrom)
AND (:dateTo IS NULL OR p.date <= :dateTo)
AND (:itemCode IS NULL OR LOWER(COALESCE(p.item.code, '')) LIKE LOWER(CONCAT('%', :itemCode, '%')))
AND (:jobOrderCode IS NULL OR LOWER(COALESCE(p.jobOrder.code, '')) LIKE LOWER(CONCAT('%', :jobOrderCode, '%')))
AND (:bomType IS NULL OR LOWER(COALESCE(p.bom.type, '')) = LOWER(:bomType))
AND (:useBomIds = false OR p.bom.id IN :bomIds)
AND (:excludeCompletedProcess = false OR p.status <> com.ffii.fpsms.modules.productProcess.enums.ProductProcessStatus.COMPLETED)
GROUP BY p.jobOrder.id
ORDER BY MAX(p.date) DESC, MIN(COALESCE(p.productionPriority, 2147483647)) ASC
"""
)
fun findCandidateJobOrderAggregatesByDateRange(
@Param("dateFrom") dateFrom: LocalDate?,
@Param("dateTo") dateTo: LocalDate?,
@Param("itemCode") itemCode: String?,
@Param("jobOrderCode") jobOrderCode: String?,
@Param("bomType") bomType: String?,
@Param("useBomIds") useBomIds: Boolean,
@Param("bomIds") bomIds: Collection<Long>,
@Param("excludeCompletedProcess") excludeCompletedProcess: Boolean,
): List<JobOrderProcessAggregate>

@Query(
"""
SELECT
p.jobOrder.id AS jobOrderId,
MAX(p.date) AS maxDate,
MIN(COALESCE(p.productionPriority, 2147483647)) AS minPriority
FROM ProductProcess p
WHERE p.deleted = false
AND p.jobOrder IS NOT NULL
AND p.jobOrder.id IN :jobOrderIds
AND COALESCE(p.jobOrder.isHidden, false) = false
AND p.jobOrder.status <> com.ffii.fpsms.modules.jobOrder.enums.JobOrderStatus.PLANNING
AND p.jobOrder.status <> com.ffii.fpsms.modules.jobOrder.enums.JobOrderStatus.COMPLETED
AND (:itemCode IS NULL OR LOWER(COALESCE(p.item.code, '')) LIKE LOWER(CONCAT('%', :itemCode, '%')))
AND (:jobOrderCode IS NULL OR LOWER(COALESCE(p.jobOrder.code, '')) LIKE LOWER(CONCAT('%', :jobOrderCode, '%')))
AND (:bomType IS NULL OR LOWER(COALESCE(p.bom.type, '')) = LOWER(:bomType))
AND (:useBomIds = false OR p.bom.id IN :bomIds)
GROUP BY p.jobOrder.id
"""
)
/** FP-MTMS Version Checklist | Functions Ref. No. 41 | v1.0.0 | 2026-08-03 */
fun findCandidateJobOrderAggregatesByJobOrderIds(
@Param("jobOrderIds") jobOrderIds: Collection<Long>,
@Param("itemCode") itemCode: String?,
@Param("jobOrderCode") jobOrderCode: String?,
@Param("bomType") bomType: String?,
@Param("useBomIds") useBomIds: Boolean,
@Param("bomIds") bomIds: Collection<Long>,
): List<JobOrderProcessAggregate>
}

+ 352
- 21
src/main/java/com/ffii/fpsms/modules/productProcess/service/ProductProcessService.kt Datei anzeigen

@@ -1516,7 +1516,8 @@ open class ProductProcessService(
assignedTo = pickOrder?.assignTo?.id,
itemName = productProcesses.item?.name,
itemCode = productProcesses.item?.code,
bomDescription = productProcesses.bom?.bomKind,
bomDescription = productProcesses.bom?.bomKind,
bomType = productProcesses.bom?.type,
pickOrderId = pickOrder?.id,
pickOrderStatus = pickOrder?.status?.value,
jobOrderId = productProcesses.jobOrder?.id,
@@ -1554,6 +1555,7 @@ open class ProductProcessService(
}

/**
* FP-MTMS Version Checklist | Functions Ref. No. 41 | v1.0.0 | 2026-08-03
* QC 列表:按 jobOrder 粒度分页,qcReady 由该 jobOrder 下所有 productProcessLine 是否全部 Completed/Pass 决定。
*
* 注意:date/itemCode/jobOrderCode/bomIds 用来筛选“候选 jobOrder”,但 qcReady 判断会用该 jobOrder 下全部 productProcessLine。
@@ -1568,7 +1570,9 @@ open class ProductProcessService(
includePutaway: Boolean?,
putawayStatus: String?,
page: Int,
size: Int
size: Int,
lookbackDays: Int? = null,
bucket: String? = null,
): JobOrderProductProcessPageResponse {
val safeSize = if (size <= 0) 50 else size
val safePage = if (page < 0) 0 else page
@@ -1579,16 +1583,82 @@ open class ProductProcessService(
val normalizedBomIds = bomIds?.distinct().orEmpty()
val useBomIds = normalizedBomIds.isNotEmpty()
val bomIdsForQuery = if (useBomIds) normalizedBomIds else listOf(-1L)
val normalizedBucket = bucket?.trim()?.lowercase()?.takeIf { it.isNotBlank() && it != "all" }
val useLookback = lookbackDays != null && lookbackDays >= 0 && date != null
/** Production list: unfinished process tabs + picked-not-started (capped at search date). */
val useProductionLookback = useLookback && qcReady != true
/** Waiting QC putaway: date lookback only (completed process OK), no pick buckets. */
val usePutawayLookback = useLookback && qcReady == true

// 1) DB 端先完成候選 jobOrder 篩選 + 分組排序,避免全表拉回 JVM
val candidateAggregates = productProcessRepository.findCandidateJobOrderAggregates(
date = date,
itemCode = trimmedItemCode,
jobOrderCode = trimmedJobOrderCode,
bomType = normalizedType,
useBomIds = useBomIds,
bomIds = bomIdsForQuery,
)
val candidateAggregates = if (useProductionLookback) {
val dateFrom = date!!.minusDays(lookbackDays!!.toLong())
val rangeAggs = productProcessRepository.findCandidateJobOrderAggregatesByDateRange(
dateFrom = dateFrom,
dateTo = date,
itemCode = trimmedItemCode,
jobOrderCode = trimmedJobOrderCode,
bomType = normalizedType,
useBomIds = useBomIds,
bomIds = bomIdsForQuery,
excludeCompletedProcess = true,
)
// Picked-not-started: still merge outside lookback window, but never after search date
// (avoids future plan dates appearing when searching a past day).
val unlimitedPickedNotStartedIds = pickOrderRepository.findPickedNotStartedJobOrderIds()
val extraAggs = if (unlimitedPickedNotStartedIds.isEmpty()) {
emptyList()
} else {
productProcessRepository.findCandidateJobOrderAggregatesByJobOrderIds(
jobOrderIds = unlimitedPickedNotStartedIds,
itemCode = trimmedItemCode,
jobOrderCode = trimmedJobOrderCode,
bomType = normalizedType,
useBomIds = useBomIds,
bomIds = bomIdsForQuery,
).filter { agg ->
val maxDate = agg.maxDate
maxDate == null || !maxDate.isAfter(date)
}
}
val byId = LinkedHashMap<Long, JobOrderProcessAggregate>()
(rangeAggs + extraAggs).forEach { agg ->
val existing = byId[agg.jobOrderId]
if (existing == null) {
byId[agg.jobOrderId] = agg
} else {
val maxDate = listOfNotNull(existing.maxDate, agg.maxDate).maxOrNull()
val minPriority = listOfNotNull(existing.minPriority, agg.minPriority).minOrNull()
byId[agg.jobOrderId] = object : JobOrderProcessAggregate {
override val jobOrderId = agg.jobOrderId
override val maxDate = maxDate
override val minPriority = minPriority
}
}
}
byId.values.toList()
} else if (usePutawayLookback) {
val dateFrom = date!!.minusDays(lookbackDays!!.toLong())
productProcessRepository.findCandidateJobOrderAggregatesByDateRange(
dateFrom = dateFrom,
dateTo = date,
itemCode = trimmedItemCode,
jobOrderCode = trimmedJobOrderCode,
bomType = normalizedType,
useBomIds = useBomIds,
bomIds = bomIdsForQuery,
excludeCompletedProcess = false,
)
} else {
productProcessRepository.findCandidateJobOrderAggregates(
date = date,
itemCode = trimmedItemCode,
jobOrderCode = trimmedJobOrderCode,
bomType = normalizedType,
useBomIds = useBomIds,
bomIds = bomIdsForQuery,
)
}
val candidateJobOrderIds = candidateAggregates.map { it.jobOrderId }

if (candidateJobOrderIds.isEmpty()) {
@@ -1596,7 +1666,10 @@ open class ProductProcessService(
content = emptyList(),
totalJobOrders = 0L,
page = safePage,
size = safeSize
size = safeSize,
bucketCounts = if (useProductionLookback) JobOrderProductProcessBucketCounts() else null,
searchDate = date?.toString(),
carriedOverCount = if (useLookback) 0L else null,
)
}

@@ -1682,19 +1755,104 @@ open class ProductProcessService(
}

val filteredQcKeys = qcKeys.filter { key -> qcReady == null || key.ready == qcReady }
val totalJobOrders = filteredQcKeys.size.toLong()

val sortedQcKeys = filteredQcKeys.sortedWith(
compareByDescending<JobOrderQcKey> { it.maxDate }.thenBy { it.minPriority }
val processByJobOrderIdForBucket = if (useProductionLookback) {
productProcessRepository
.findByJobOrder_IdInAndDeletedIsFalse(filteredQcKeys.map { it.jobOrderId })
.groupBy { it.jobOrder?.id }
.mapNotNull { (joId, list) ->
if (joId == null) null
else joId to (list.firstOrNull { it.status != ProductProcessStatus.COMPLETED } ?: list.first())
}
.toMap()
} else emptyMap()

val pickedJoIds = if (useProductionLookback && filteredQcKeys.isNotEmpty()) {
pickOrderRepository.findPickedJobOrderIds(filteredQcKeys.map { it.jobOrderId }).toSet()
} else emptySet()

fun classifyBucket(jobOrderId: Long): String? {
val pp = processByJobOrderIdForBucket[jobOrderId] ?: return null
val status = pp.status
if (status == ProductProcessStatus.COMPLETED) return null
val started =
status == ProductProcessStatus.IN_PROGRESS ||
status == ProductProcessStatus.STOPPED ||
pp.startTime != null
val notStarted = status == ProductProcessStatus.PENDING && pp.startTime == null
val picked = pickedJoIds.contains(jobOrderId)
return when {
notStarted && !picked -> "not_picked_not_started"
notStarted && picked -> "picked_not_started"
started && picked -> "picked_started"
started && !picked -> "not_picked_started"
else -> null
}
}

val keysWithBucket = if (useProductionLookback) {
filteredQcKeys.mapNotNull { key ->
val b = classifyBucket(key.jobOrderId) ?: return@mapNotNull null
key to b
}
} else {
filteredQcKeys.map { it to null as String? }
}

val bucketCounts = if (useProductionLookback) {
JobOrderProductProcessBucketCounts(
notPickedNotStarted = keysWithBucket.count { it.second == "not_picked_not_started" }.toLong(),
pickedNotStarted = keysWithBucket.count { it.second == "picked_not_started" }.toLong(),
pickedStarted = keysWithBucket.count { it.second == "picked_started" }.toLong(),
notPickedStarted = keysWithBucket.count { it.second == "not_picked_started" }.toLong(),
)
} else null

fun matchesMergedBucket(fine: String?, merged: String): Boolean {
return when (merged) {
"pending" ->
fine == "not_picked_not_started" || fine == "picked_not_started"
"processing" ->
fine == "picked_started" || fine == "not_picked_started"
else -> fine == merged
}
}

val afterBucketFilter = if (normalizedBucket != null && useProductionLookback) {
keysWithBucket.filter { matchesMergedBucket(it.second, normalizedBucket) }
} else {
keysWithBucket
}

val totalJobOrders = afterBucketFilter.size.toLong()
val carriedOverCount = if (useLookback && date != null) {
afterBucketFilter.count { (key, _) ->
val d = key.maxDate
d != null && d.isBefore(date)
}.toLong()
} else null

val sortedPairs = afterBucketFilter.sortedWith(
compareByDescending<Pair<JobOrderQcKey, String?>> { it.first.maxDate }.thenBy { it.first.minPriority }
)

val from = safePage * safeSize
if (from >= sortedQcKeys.size) {
return JobOrderProductProcessPageResponse(emptyList(), totalJobOrders, safePage, safeSize)
if (from >= sortedPairs.size) {
return JobOrderProductProcessPageResponse(
content = emptyList(),
totalJobOrders = totalJobOrders,
page = safePage,
size = safeSize,
bucketCounts = bucketCounts,
searchDate = date?.toString(),
carriedOverCount = carriedOverCount,
)
}

val to = minOf(from + safeSize, sortedQcKeys.size)
val pagedJobOrderIds = sortedQcKeys.subList(from, to).map { it.jobOrderId }
val to = minOf(from + safeSize, sortedPairs.size)
val pagedPairs = sortedPairs.subList(from, to)
val pagedJobOrderIds = pagedPairs.map { it.first.jobOrderId }
val bucketByJobOrderId = pagedPairs.associate { it.first.jobOrderId to it.second }
val pagedJobOrderIdSet = pagedJobOrderIds.toSet()

// 4) 只對當前頁資料做批量關聯查詢,並且排序/資料縮小盡量下推 DB
@@ -1812,7 +1970,8 @@ open class ProductProcessService(
assignedTo = pickOrder?.assignTo?.id,
itemName = productProcess.item?.name,
itemCode = productProcess.item?.code,
bomDescription = productProcess.bom?.bomKind,
bomDescription = productProcess.bom?.bomKind,
bomType = productProcess.bom?.type,
pickOrderId = pickOrder?.id,
pickOrderStatus = pickOrder?.status?.value,
jobOrderId = productProcess.jobOrder?.id,
@@ -1832,7 +1991,9 @@ open class ProductProcessService(
endTime = line.endTime,
status = line.status ?: ""
)
}
},
isPicked = if (useProductionLookback) pickedJoIds.contains(jobOrderId) else null,
pickProcessBucket = if (useProductionLookback) bucketByJobOrderId[jobOrderId] else null,
)
}

@@ -1840,7 +2001,10 @@ open class ProductProcessService(
content = content,
totalJobOrders = totalJobOrders,
page = safePage,
size = safeSize
size = safeSize,
bucketCounts = bucketCounts,
searchDate = date?.toString(),
carriedOverCount = carriedOverCount,
)
}

@@ -2521,6 +2685,173 @@ open class ProductProcessService(

}

// ===== Drink Production Qty Dashboard =====
/** FP-MTMS Version Checklist | Functions Ref. No. 27 | v1.0.0 | 2026-07-20 */
open fun getDrinkProductionQty(date: LocalDate?): List<DrinkProductionQtyResponse> {
val targetDate = date ?: LocalDate.now()
val startOfDay = targetDate.atStartOfDay()
val endOfDayExclusive = targetDate.plusDays(1).atStartOfDay()

// 依「生產日」彙總:與既有 dashboard 的用法一致,採半開區間 [startOfDay, endExclusive)
val jobOrders = jobOrderRepository.findByDeletedFalseAndPlanStartFromBeforeExclusiveOrderByIdAsc(
planStartFrom = startOfDay,
planStartToExclusive = endOfDayExclusive,
)

if (jobOrders.isEmpty()) {
return emptyList()
}

data class GroupKey(
val itemCode: String?,
val itemName: String?,
)

data class DrinkOrderRow(
val itemCode: String?,
val itemName: String?,
val uom: String?,
val reqQty: BigDecimal,
val productionQty: BigDecimal,
val jobOrderId: Long?,
val jobOrderCode: String?,
val productionDate: LocalDate?,
)

val drinkOrders = jobOrders
.filter { it.bom?.isDrink == true }
.filter { it.status != JobOrderStatus.PLANNING }

if (drinkOrders.isEmpty()) {
return emptyList()
}

val jobOrderIds = drinkOrders.mapNotNull { it.id }
val itemIds = drinkOrders.mapNotNull { it.bom?.item?.id }.distinct()

val processesByJobOrderId = productProcessRepository
.findByJobOrder_IdInAndDeletedIsFalse(jobOrderIds)
.groupBy { it.jobOrder?.id }
.mapValues { (_, processes) -> processes.firstOrNull() }

val processIds = processesByJobOrderId.values.mapNotNull { it?.id }.distinct()
val linesByProcessId = if (processIds.isNotEmpty()) {
productProcessLineRepository.findByProductProcess_IdIn(processIds)
.groupBy { it.productProcess?.id ?: 0L }
} else {
emptyMap()
}

val stockInLineByJobOrderId = if (jobOrderIds.isNotEmpty()) {
stockInLineRepository.findAllByJobOrder_IdInAndDeletedFalse(jobOrderIds)
.associateBy { it.jobOrder?.id }
} else {
emptyMap()
}

val stockItemUoms = if (itemIds.isNotEmpty()) {
itemUomRepository.findAllByItemIdInAndStockUnitIsTrueAndDeletedIsFalse(itemIds)
} else {
emptyList()
}
val itemUomByItemId = mutableMapOf<Long, com.ffii.fpsms.modules.master.entity.ItemUom>()
stockItemUoms.forEach { iu ->
val itemId = iu.item?.id ?: return@forEach
itemUomByItemId.putIfAbsent(itemId, iu)
}
val uomIds = itemUomByItemId.values.mapNotNull { it.uom?.id }.distinct()
val uomById = if (uomIds.isNotEmpty()) {
uomConversionRepository.findAllByIdInAndDeletedFalse(uomIds).associateBy { it.id }
} else {
emptyMap()
}
val uomDescByItemId = itemUomByItemId.mapValues { (_, iu) ->
uomById[iu.uom?.id]?.udfudesc
}

val drinkRows = drinkOrders.map { jo ->
val bom = jo.bom
val itemId = bom?.item?.id
val process = processesByJobOrderId[jo.id]
val lines = process?.id?.let { linesByProcessId[it].orEmpty() }.orEmpty()
val stockInLine = stockInLineByJobOrderId[jo.id]

DrinkOrderRow(
itemCode = bom?.item?.code,
itemName = bom?.item?.name,
uom = resolveDrinkProductionUom(bom, itemId, uomDescByItemId),
reqQty = jo.reqQty ?: BigDecimal.ZERO,
productionQty = resolveDrinkProductionQty(jo, lines, stockInLine),
jobOrderId = jo.id,
jobOrderCode = jo.code,
productionDate = jo.planStart?.toLocalDate(),
)
}

return drinkRows
.groupBy { GroupKey(it.itemCode, it.itemName) }
.map { (key, rows) ->
DrinkProductionQtyResponse(
itemCode = key.itemCode,
itemName = key.itemName,
uom = rows.firstNotNullOfOrNull { it.uom?.takeIf { u -> u.isNotBlank() } },
totalReqQty = rows.fold(BigDecimal.ZERO) { acc, row ->
acc + row.reqQty
},
totalQty = rows.fold(BigDecimal.ZERO) { acc, row ->
acc + row.productionQty
},
jobOrders = rows.map { row ->
DrinkProductionQtyJobOrderDetail(
jobOrderId = row.jobOrderId ?: 0L,
jobOrderCode = row.jobOrderCode,
productionDate = row.productionDate,
reqQty = row.reqQty,
productionQty = row.productionQty,
)
}.sortedBy { it.jobOrderCode ?: "" },
)
}
.sortedWith(
compareBy<DrinkProductionQtyResponse> { it.itemCode ?: "" }
.thenBy { it.itemName ?: "" }
)
}

private fun resolveDrinkProductionUom(
bom: Bom?,
itemId: Long?,
uomDescByItemId: Map<Long, String?>,
): String? {
bom?.outputQtyUom?.trim()?.takeIf { it.isNotEmpty() }?.let { return it }
bom?.excelUom?.trim()?.takeIf { it.isNotEmpty() }?.let { return it }
itemId?.let { uomDescByItemId[it] }?.trim()?.takeIf { it.isNotEmpty() }?.let { return it }
return null
}

/** 實際生產數量:優先工序產出 → QC 入庫接受量 → 工單 reqQty(當日計劃生產量) */
private fun resolveDrinkProductionQty(
jobOrder: com.ffii.fpsms.modules.jobOrder.entity.JobOrder,
lines: List<ProductProcessLine>,
stockInLine: com.ffii.fpsms.modules.stock.entity.StockInLine?,
): BigDecimal {
val lineOutputQty = lines
.sortedByDescending { it.seqNo ?: 0L }
.firstNotNullOfOrNull { line ->
line.outputFromProcessQty?.takeIf { it > 0 }?.toBigDecimal()
}
if (lineOutputQty != null) {
return lineOutputQty
}

val acceptedQty = stockInLine?.acceptedQty
if (acceptedQty != null && acceptedQty > BigDecimal.ZERO) {
return acceptedQty
}

return jobOrder.reqQty ?: BigDecimal.ZERO
}

// ===== Operator KPI Dashboard =====
open fun getOperatorKpi(date: LocalDate?): List<OperatorKpiResponse> {
val targetDate = date ?: LocalDate.now()


+ 21
- 1
src/main/java/com/ffii/fpsms/modules/productProcess/web/ProductProcessController.kt Datei anzeigen

@@ -200,6 +200,7 @@ class ProductProcessController(
return productProcessService.getAllJoborderProductProcessInfo(bomType)
}

/** FP-MTMS Version Checklist | Functions Ref. No. 41 | v1.0.0 | 2026-08-03 */
@GetMapping("/Demo/Process/search")
fun demoprocesssearch(
@RequestParam(required = false) date: String?,
@@ -211,6 +212,14 @@ class ProductProcessController(
/** all | completed | notCompleted */
@RequestParam(required = false) putawayStatus: String?,
@RequestParam(name = "type", required = false) bomType: String?,
/** Production list: unfinished from (date - lookbackDays) .. date; picked_not_started merged but capped at date. */
@RequestParam(required = false) lookbackDays: Int?,
/**
* all | pending | processing
* (pending = not started; processing = started; also accepts fine-grained pick buckets)
* Only applied when lookbackDays is set.
*/
@RequestParam(required = false) bucket: String?,
@RequestParam(defaultValue = "0") page: Int,
@RequestParam(defaultValue = "50") size: Int
): JobOrderProductProcessPageResponse {
@@ -233,7 +242,9 @@ class ProductProcessController(
putawayStatus = putawayStatus,
bomType = bomType,
page = page,
size = size
size = size,
lookbackDays = lookbackDays,
bucket = bucket,
)
}

@@ -306,4 +317,13 @@ class ProductProcessController(
fun getEquipmentStatus(): List<EquipmentStatusByTypeResponse> {
return productProcessService.getEquipmentStatusByType()
}

/** FP-MTMS Version Checklist | Functions Ref. No. 27 | v1.0.0 | 2026-07-20 */
@GetMapping("/Demo/DrinkProductionQty")
fun getDrinkProductionQty(@RequestParam(required = false) date: String?): List<DrinkProductionQtyResponse> {
val parsedDate = date?.takeIf { it.isNotBlank() }?.let {
LocalDate.parse(it, DateTimeFormatter.ISO_DATE)
}
return productProcessService.getDrinkProductionQty(parsedDate)
}
}

+ 42
- 2
src/main/java/com/ffii/fpsms/modules/productProcess/web/model/SaveProductProcessRequest.kt Datei anzeigen

@@ -2,6 +2,7 @@ package com.ffii.fpsms.modules.productProcess.web.model

import java.time.LocalDate
import java.time.LocalDateTime
import java.math.BigDecimal
import com.fasterxml.jackson.annotation.JsonProperty
import com.fasterxml.jackson.annotation.JsonFormat
data class SaveProductProcessRequest(
@@ -169,6 +170,8 @@ data class AllJoborderProductProcessInfoResponse(
val itemName: String?,
val itemCode: String?,
val bomDescription: String?,
/** BOM.type (e.g. drink / Powder_Mixture / other). */
val bomType: String? = null,
val matchStatus: String?,
val RequiredQty: Int?,
val Uom: String?,
@@ -186,14 +189,32 @@ data class AllJoborderProductProcessInfoResponse(
val stockInLineStatus: String?,
val TimeNeedToComplete: Int?,
val isDrink: Boolean?,
val lines: List<ProductProcessInfoResponse>
val lines: List<ProductProcessInfoResponse>,
/** True when JO pick has at least one counted stock_out_line progress. */
val isPicked: Boolean? = null,
/**
* Fine-grained pick/process bucket:
* not_picked_not_started | picked_not_started | picked_started | not_picked_started
*/
val pickProcessBucket: String? = null,
)

data class JobOrderProductProcessBucketCounts(
val notPickedNotStarted: Long = 0,
val pickedNotStarted: Long = 0,
val pickedStarted: Long = 0,
val notPickedStarted: Long = 0,
)

data class JobOrderProductProcessPageResponse(
val content: List<AllJoborderProductProcessInfoResponse>,
val totalJobOrders: Long,
val page: Int,
val size: Int
val size: Int,
val bucketCounts: JobOrderProductProcessBucketCounts? = null,
/** Search date echoed for carry-over UI (YYYY-MM-dd). */
val searchDate: String? = null,
val carriedOverCount: Long? = null,
)

/**
@@ -335,4 +356,23 @@ data class EquipmentStatusByTypeResponse(
val equipmentTypeId: Long,
val equipmentTypeName: String?,
val details: List<EquipmentStatusPerDetail>,
)

// ===== Drink Production Qty Dashboard =====
data class DrinkProductionQtyJobOrderDetail(
val jobOrderId: Long,
val jobOrderCode: String?,
@JsonFormat(shape = JsonFormat.Shape.STRING, pattern = "yyyy-MM-dd")
val productionDate: LocalDate?,
val reqQty: BigDecimal,
val productionQty: BigDecimal,
)

data class DrinkProductionQtyResponse(
val itemCode: String?,
val itemName: String?,
val uom: String?,
val totalReqQty: BigDecimal,
val totalQty: BigDecimal,
val jobOrders: List<DrinkProductionQtyJobOrderDetail> = emptyList(),
)

+ 204
- 0
src/main/java/com/ffii/fpsms/modules/report/service/DoUserPickAuditReportService.kt Datei anzeigen

@@ -0,0 +1,204 @@
package com.ffii.fpsms.modules.report.service

import com.ffii.core.support.JdbcDao
import org.springframework.stereotype.Service

@Service
class DoUserPickAuditReportService(
private val jdbcDao: JdbcDao,
) {
/**
* FP-MTMS Version Checklist | Functions Ref. No. 39 | v1.0.2 | 2026-08-05
* DO workbench user-audit rows (Just issue only).
* issueTypes may combine: 跳過提料(使用「已完成」)+多提+少提+擅自換批/揀錯批.
* 少提/多提 use POL-level SUM(stock_out_line.qty) vs pol.qty (multi-lot split OK if total matches).
* Only completed tickets (`dop.ticketStatus = completed`) are included.
*
* Perf: filter day/completed SOL first (`day_sol`) by pickTime only (idx_sol_pickTime_deleted),
* then SUM qty only for those POL ids (avoids full-table GROUP BY / OR-date full scan).
*/
fun searchDoUserPickAudit(
dateStart: String?,
dateEnd: String?,
userId: Long?,
handler: String?,
ticketNo: String?,
itemCode: String?,
storeId: String?,
): List<Map<String, Any?>> {
val args = mutableMapOf<String, Any>()

// Date filter on pickTime only (uses idx_sol_pickTime_deleted).
// ~1% completed SOL with null pickTime are excluded by design (OR fallback caused full scan ~20s+/day).
val dateStartSql = if (!dateStart.isNullOrBlank()) {
args["dateStart"] = dateStart.replace("/", "-")
"AND sol.pickTime >= CONCAT(:dateStart, ' 00:00:00')"
} else ""

val dateEndSql = if (!dateEnd.isNullOrBlank()) {
args["dateEnd"] = dateEnd.replace("/", "-")
"AND sol.pickTime < DATE_ADD(CONCAT(:dateEnd, ' 00:00:00'), INTERVAL 1 DAY)"
} else ""

val userSql = if (userId != null && userId > 0) {
args["userId"] = userId
"AND sol.handled_by = :userId"
} else ""

val handlerSql = if (!handler.isNullOrBlank()) {
args["handler"] = handler.trim()
"AND TRIM(COALESCE(u.name, d.handlerName, '')) = :handler"
} else ""

val ticketSql = if (!ticketNo.isNullOrBlank()) {
args["ticketNo"] = ticketNo.trim()
"AND dop.ticketNo LIKE CONCAT('%', :ticketNo, '%')"
} else ""

val itemSql = if (!itemCode.isNullOrBlank()) {
args["itemCode"] = itemCode.trim()
"AND i.code LIKE CONCAT('%', :itemCode, '%')"
} else ""

val storeSql = if (!storeId.isNullOrBlank()) {
args["storeId"] = storeId.trim().uppercase().replace("/", "").replace(" ", "")
"""AND REPLACE(REPLACE(UPPER(IFNULL(dop.storeId,'')), '/', ''), ' ', '') = :storeId"""
} else ""

val sql = """
WITH day_sol AS (
SELECT
sol.id AS solId,
sol.pickOrderLineId AS pickOrderLineId,
sol.itemId AS itemId,
sol.qty AS actualQty,
sol.inventoryLotLineId AS inventoryLotLineId,
sol.handled_by AS handledBy,
sol.isIssueJustComplete AS isIssueJustComplete,
sol.pickTime AS pickTime,
sol.endTime AS endTime,
sol.created AS created,
pol.qty AS polQty,
dop.ticketNo AS ticketNo,
dop.handlerName AS handlerName
FROM stock_out_line sol
INNER JOIN pick_order_line pol
ON pol.id = sol.pickOrderLineId AND IFNULL(pol.deleted, 0) = 0
INNER JOIN pick_order po
ON po.id = pol.poId AND IFNULL(po.deleted, 0) = 0
AND po.deliveryOrderPickOrderId IS NOT NULL
INNER JOIN delivery_order_pick_order dop
ON dop.id = po.deliveryOrderPickOrderId AND IFNULL(dop.deleted, 0) = 0
WHERE IFNULL(sol.deleted, 0) = 0
AND dop.ticketStatus = 'completed'
$dateStartSql
$dateEndSql
$userSql
$ticketSql
$storeSql
),
pol_pick AS (
SELECT s2.pickOrderLineId AS pickOrderLineId,
COALESCE(SUM(s2.qty), 0) AS pickedTotal
FROM stock_out_line s2
WHERE IFNULL(s2.deleted, 0) = 0
AND s2.pickOrderLineId IN (SELECT DISTINCT pickOrderLineId FROM day_sol)
GROUP BY s2.pickOrderLineId
)
SELECT
DATE_FORMAT(COALESCE(d.pickTime, d.endTime, d.created), '%Y-%m-%d %H:%i:%s') AS datetime,
IFNULL(d.ticketNo, '') AS ticketNo,
IFNULL(i.code, '') AS itemCode,
IFNULL(i.name, '') AS itemName,
CASE WHEN IFNULL(i.isEgg, 0) = 1 THEN '是雞蛋類貨品' ELSE '不是雞蛋類貨品' END AS isEgg,
IFNULL(sug_lot.lotNo, '') AS suggestLot,
IFNULL(act_lot.lotNo, '') AS actualLot,
d.polQty AS qty,
d.actualQty AS actualQty,
sl.lotQtyBefore AS lotBeforeQty,
sl.lotQtyAfter AS lotAfterQty,
IFNULL(d.isIssueJustComplete, 0) AS issueJustComplete,
COALESCE(u.name, d.handlerName, '') AS user,
TRIM(BOTH '+' FROM CONCAT_WS('+',
CASE WHEN IFNULL(d.isIssueJustComplete, 0) = 1 THEN '跳過提料(使用「已完成」)' ELSE NULL END,
CASE WHEN d.polQty IS NOT NULL
AND COALESCE(pp.pickedTotal, 0) > d.polQty THEN '多提' ELSE NULL END,
CASE WHEN d.polQty IS NOT NULL
AND COALESCE(pp.pickedTotal, 0) < d.polQty
AND d.inventoryLotLineId IS NOT NULL THEN '少提' ELSE NULL END,
CASE WHEN sug_lot.lotNo IS NOT NULL AND act_lot.lotNo IS NOT NULL
AND sug_lot.lotNo <> act_lot.lotNo THEN '擅自換批/揀錯批' ELSE NULL END
)) AS issueTypes,
d.solId AS stockOutLineId
FROM day_sol d
INNER JOIN items i
ON i.id = d.itemId AND IFNULL(i.deleted, 0) = 0
LEFT JOIN pol_pick pp
ON pp.pickOrderLineId = d.pickOrderLineId
LEFT JOIN user u
ON u.id = d.handledBy AND IFNULL(u.deleted, 0) = 0
LEFT JOIN suggested_pick_lot spl
ON spl.id = (
SELECT MAX(s.id)
FROM suggested_pick_lot s
WHERE s.stockOutLineId = d.solId
AND IFNULL(s.deleted, 0) = 0
)
LEFT JOIN inventory_lot_line ill_sug
ON ill_sug.id = spl.suggestedLotLineId
AND IFNULL(ill_sug.deleted, 0) = 0
LEFT JOIN inventory_lot sug_lot
ON sug_lot.id = ill_sug.inventoryLotId AND IFNULL(sug_lot.deleted, 0) = 0
LEFT JOIN inventory_lot_line ill_act
ON ill_act.id = COALESCE(d.inventoryLotLineId, spl.actualPickLotLineId)
AND IFNULL(ill_act.deleted, 0) = 0
LEFT JOIN inventory_lot act_lot
ON act_lot.id = ill_act.inventoryLotId AND IFNULL(act_lot.deleted, 0) = 0
LEFT JOIN stock_ledger sl
ON sl.id = (
SELECT MAX(sl2.id)
FROM stock_ledger sl2
WHERE sl2.stockOutLineId = d.solId
AND IFNULL(sl2.deleted, 0) = 0
AND sl2.outQty IS NOT NULL
AND sl2.outQty > 0
)
WHERE (
IFNULL(d.isIssueJustComplete, 0) = 1
OR (d.polQty IS NOT NULL AND COALESCE(pp.pickedTotal, 0) > d.polQty)
OR (d.polQty IS NOT NULL AND COALESCE(pp.pickedTotal, 0) < d.polQty
AND d.inventoryLotLineId IS NOT NULL)
OR (
sug_lot.lotNo IS NOT NULL AND act_lot.lotNo IS NOT NULL
AND sug_lot.lotNo <> act_lot.lotNo
)
)
$handlerSql
$itemSql
ORDER BY COALESCE(d.pickTime, d.endTime, d.created) DESC, d.solId DESC
LIMIT 20000
""".trimIndent()

@Suppress("UNCHECKED_CAST")
return jdbcDao.queryForList(sql, args) as List<Map<String, Any?>>
}

/** FP-MTMS Version Checklist | Functions Ref. No. 39 | v1.0.2 | 2026-08-05 */
fun getDistinctHandlers(): List<String> {
val sql = """
SELECT DISTINCT TRIM(COALESCE(u.name, dop.handlerName, '')) AS handler
FROM stock_out_line sol
INNER JOIN pick_order_line pol ON pol.id = sol.pickOrderLineId AND IFNULL(pol.deleted, 0) = 0
INNER JOIN pick_order po ON po.id = pol.poId AND IFNULL(po.deleted, 0) = 0
AND po.deliveryOrderPickOrderId IS NOT NULL
INNER JOIN delivery_order_pick_order dop ON dop.id = po.deliveryOrderPickOrderId AND IFNULL(dop.deleted, 0) = 0
LEFT JOIN user u ON u.id = sol.handled_by AND IFNULL(u.deleted, 0) = 0
WHERE IFNULL(sol.deleted, 0) = 0
AND TRIM(COALESCE(u.name, dop.handlerName, '')) <> ''
ORDER BY handler
""".trimIndent()
return jdbcDao.queryForList(sql, emptyMap<String, Any>())
.map { (it["handler"]?.toString() ?: "").trim() }
.filter { it.isNotBlank() }
}
}

+ 281
- 0
src/main/java/com/ffii/fpsms/modules/report/service/ShopOrderReplenishmentReportService.kt Datei anzeigen

@@ -0,0 +1,281 @@
package com.ffii.fpsms.modules.report.service

import com.ffii.core.support.JdbcDao
import org.springframework.stereotype.Service
import java.math.BigDecimal

/**
* Shop Orders Replenishment Records (店鋪訂單補貨記錄).
*
* Only completed replenishments with a linked ticket are included.
* Qty columns are loaded in a second pass for the filtered row ids only
* (avoids full-table stock_out_line / pick_order GROUP BY).
*/
@Service
class ShopOrderReplenishmentReportService(
private val jdbcDao: JdbcDao,
) {
fun searchShopOrderReplenishmentReport(
reorderDateStart: String?,
reorderDateEnd: String?,
shopOrderDateStart: String?,
shopOrderDateEnd: String?,
deliveredDateStart: String?,
deliveredDateEnd: String?,
shopCode: String?,
): Map<String, Any> {
val args = mutableMapOf<String, Any>()

val reorderDateStartSql = dateStartClause(
raw = reorderDateStart,
argName = "reorderDateStart",
columnExpr = "dr.created",
args = args,
)
val reorderDateEndSql = dateEndClause(
raw = reorderDateEnd,
argName = "reorderDateEnd",
columnExpr = "dr.created",
args = args,
)

val shopOrderDateStartSql = dateStartClause(
raw = shopOrderDateStart,
argName = "shopOrderDateStart",
columnExpr = "sourceDo.estimatedArrivalDate",
args = args,
)
val shopOrderDateEndSql = dateEndClause(
raw = shopOrderDateEnd,
argName = "shopOrderDateEnd",
columnExpr = "sourceDo.estimatedArrivalDate",
args = args,
)

val deliveredDateStartSql = if (!deliveredDateStart.isNullOrBlank()) {
args["deliveredDateStart"] = normalizeDate(deliveredDateStart)
"AND dop.requiredDeliveryDate >= :deliveredDateStart"
} else {
""
}
val deliveredDateEndSql = if (!deliveredDateEnd.isNullOrBlank()) {
args["deliveredDateEnd"] = normalizeDate(deliveredDateEnd)
"AND dop.requiredDeliveryDate <= :deliveredDateEnd"
} else {
""
}

val shopCodeSql = if (!shopCode.isNullOrBlank()) {
args["shopCode"] = shopCode.trim()
"AND dr.shopCode LIKE CONCAT('%', :shopCode, '%')"
} else {
""
}

// Pass 1: filter do_replenishment only (no full-table SOL aggregation).
val baseSql = """
SELECT
dr.id AS replenishmentId,
dr.shopCode AS shopNo,
dr.shopName AS shopName,
DATE_FORMAT(sourceDo.estimatedArrivalDate, '%Y-%m-%d') AS shopOrderDate,
sourceDo.code AS shopOrderNo,
dr.itemNo AS itemNo,
dr.itemName AS itemName,
dol.qty AS firstOrderQty,
dr.replenishQty AS reorderQty,
DATE_FORMAT(dr.created, '%Y-%m-%d') AS reorderDate,
dr.reason AS reason,
DATE_FORMAT(dop.requiredDeliveryDate, '%Y-%m-%d') AS deliveredDate,
dr.sourceDoId AS sourceDoId,
dr.itemId AS itemId,
dr.pickOrderLineId AS pickOrderLineId
FROM do_replenishment dr
INNER JOIN delivery_order sourceDo
ON sourceDo.id = dr.sourceDoId
AND IFNULL(sourceDo.deleted, 0) = 0
LEFT JOIN delivery_order_line dol
ON dol.id = dr.sourceDoLineId
AND IFNULL(dol.deleted, 0) = 0
INNER JOIN delivery_order_pick_order dop
ON dop.id = dr.deliveryOrderPickOrderId
AND IFNULL(dop.deleted, 0) = 0
WHERE IFNULL(dr.deleted, 0) = 0
AND dr.status = 'completed'
AND dr.deliveryOrderPickOrderId IS NOT NULL
AND dr.pickOrderLineId IS NOT NULL
$reorderDateStartSql
$reorderDateEndSql
$shopOrderDateStartSql
$shopOrderDateEndSql
$deliveredDateStartSql
$deliveredDateEndSql
$shopCodeSql
ORDER BY
dr.shopCode ASC,
DATE(sourceDo.estimatedArrivalDate) ASC,
sourceDo.code ASC,
dr.itemNo ASC,
dr.id ASC
""".trimIndent()

@Suppress("UNCHECKED_CAST")
val baseRows = jdbcDao.queryForList(baseSql, args) as List<Map<String, Any?>>
if (baseRows.isEmpty()) {
return mapOf("rows" to emptyList<Map<String, Any?>>())
}

val pickOrderLineIds = baseRows.mapNotNull { longVal(it["pickOrderLineId"]) }.distinct()
val sourceDoIds = baseRows.mapNotNull { longVal(it["sourceDoId"]) }.distinct()
val itemIds = baseRows.mapNotNull { longVal(it["itemId"]) }.distinct()

val actualDeliveredByPolId = loadActualDeliveredQtyByPickOrderLineId(pickOrderLineIds)
val firstOrderPickBySourceKey = loadFirstOrderActualPickQty(sourceDoIds, itemIds, pickOrderLineIds)

val rows = baseRows.map { row ->
val polId = longVal(row["pickOrderLineId"])
val sourceDoId = longVal(row["sourceDoId"])
val itemId = longVal(row["itemId"])
val sourceKey =
if (sourceDoId != null && itemId != null) sourceDoId to itemId else null

linkedMapOf<String, Any?>(
"shopNo" to row["shopNo"],
"shopName" to row["shopName"],
"shopOrderDate" to row["shopOrderDate"],
"shopOrderNo" to row["shopOrderNo"],
"itemNo" to row["itemNo"],
"itemName" to row["itemName"],
"firstOrderQty" to row["firstOrderQty"],
"firstOrderActualPickQty" to (sourceKey?.let { firstOrderPickBySourceKey[it] } ?: BigDecimal.ZERO),
"reorderQty" to row["reorderQty"],
"reorderDate" to row["reorderDate"],
"reason" to row["reason"],
"actualDeliveredQty" to (polId?.let { actualDeliveredByPolId[it] } ?: BigDecimal.ZERO),
"deliveredDate" to row["deliveredDate"],
)
}

return mapOf("rows" to rows)
}

/** Sum stock_out_line.qty only for the given replenishment pickOrderLineIds. */
private fun loadActualDeliveredQtyByPickOrderLineId(
pickOrderLineIds: List<Long>,
): Map<Long, BigDecimal> {
if (pickOrderLineIds.isEmpty()) return emptyMap()

val args = mapOf("polIds" to pickOrderLineIds)
val sql = """
SELECT
sol.pickOrderLineId AS pickOrderLineId,
SUM(sol.qty) AS actualDeliveredQty
FROM stock_out_line sol
WHERE IFNULL(sol.deleted, 0) = 0
AND sol.pickOrderLineId IN (:polIds)
GROUP BY sol.pickOrderLineId
""".trimIndent()

@Suppress("UNCHECKED_CAST")
val rows = jdbcDao.queryForList(sql, args) as List<Map<String, Any?>>
return rows.mapNotNull { row ->
val polId = longVal(row["pickOrderLineId"]) ?: return@mapNotNull null
polId to decimalVal(row["actualDeliveredQty"])
}.toMap()
}

/**
* Sum stock_out_line.qty for source DO pick order lines of the same item,
* excluding any POL that is itself a replenishment line (incl. current report POLs).
*/
private fun loadFirstOrderActualPickQty(
sourceDoIds: List<Long>,
itemIds: List<Long>,
excludePickOrderLineIds: List<Long>,
): Map<Pair<Long, Long>, BigDecimal> {
if (sourceDoIds.isEmpty() || itemIds.isEmpty()) return emptyMap()

val args = mutableMapOf<String, Any>(
"sourceDoIds" to sourceDoIds,
"itemIds" to itemIds,
)
val excludeSql = if (excludePickOrderLineIds.isNotEmpty()) {
args["excludePolIds"] = excludePickOrderLineIds
"AND pol.id NOT IN (:excludePolIds)"
} else {
""
}

val sql = """
SELECT
po.doId AS sourceDoId,
pol.itemId AS itemId,
SUM(IFNULL(sol.qty, 0)) AS firstOrderActualPickQty
FROM pick_order po
INNER JOIN pick_order_line pol
ON pol.poId = po.id
AND IFNULL(pol.deleted, 0) = 0
LEFT JOIN stock_out_line sol
ON sol.pickOrderLineId = pol.id
AND IFNULL(sol.deleted, 0) = 0
WHERE IFNULL(po.deleted, 0) = 0
AND po.doId IN (:sourceDoIds)
AND pol.itemId IN (:itemIds)
$excludeSql
AND NOT EXISTS (
SELECT 1
FROM do_replenishment r2
WHERE r2.pickOrderLineId = pol.id
AND IFNULL(r2.deleted, 0) = 0
)
GROUP BY po.doId, pol.itemId
""".trimIndent()

@Suppress("UNCHECKED_CAST")
val rows = jdbcDao.queryForList(sql, args) as List<Map<String, Any?>>
return rows.mapNotNull { row ->
val sourceDoId = longVal(row["sourceDoId"]) ?: return@mapNotNull null
val itemId = longVal(row["itemId"]) ?: return@mapNotNull null
(sourceDoId to itemId) to decimalVal(row["firstOrderActualPickQty"])
}.toMap()
}

private fun normalizeDate(raw: String): String = raw.trim().replace("/", "-")

private fun dateStartClause(
raw: String?,
argName: String,
columnExpr: String,
args: MutableMap<String, Any>,
): String {
if (raw.isNullOrBlank()) return ""
args[argName] = normalizeDate(raw)
return "AND $columnExpr >= CONCAT(:$argName, ' 00:00:00')"
}

private fun dateEndClause(
raw: String?,
argName: String,
columnExpr: String,
args: MutableMap<String, Any>,
): String {
if (raw.isNullOrBlank()) return ""
args[argName] = normalizeDate(raw)
return "AND $columnExpr < DATE_ADD(CONCAT(:$argName, ' 00:00:00'), INTERVAL 1 DAY)"
}

private fun longVal(value: Any?): Long? =
when (value) {
null -> null
is Number -> value.toLong()
else -> value.toString().toLongOrNull()
}

private fun decimalVal(value: Any?): BigDecimal =
when (value) {
null -> BigDecimal.ZERO
is BigDecimal -> value
is Number -> BigDecimal(value.toString())
else -> value.toString().toBigDecimalOrNull() ?: BigDecimal.ZERO
}
}

+ 140
- 0
src/main/java/com/ffii/fpsms/modules/report/web/DoUserPickAuditReportController.kt Datei anzeigen

@@ -0,0 +1,140 @@
package com.ffii.fpsms.modules.report.web

import com.ffii.fpsms.modules.report.service.DoUserPickAuditReportService
import org.apache.poi.ss.usermodel.BorderStyle
import org.apache.poi.ss.usermodel.FillPatternType
import org.apache.poi.ss.usermodel.HorizontalAlignment
import org.apache.poi.ss.usermodel.IndexedColors
import org.apache.poi.ss.usermodel.VerticalAlignment
import org.apache.poi.ss.util.WorkbookUtil
import org.apache.poi.xssf.usermodel.XSSFWorkbook
import org.springframework.http.HttpHeaders
import org.springframework.http.HttpStatus
import org.springframework.http.MediaType
import org.springframework.http.ResponseEntity
import org.springframework.web.bind.annotation.GetMapping
import org.springframework.web.bind.annotation.RequestMapping
import org.springframework.web.bind.annotation.RequestParam
import org.springframework.web.bind.annotation.RestController
import java.io.ByteArrayOutputStream

@RestController
@RequestMapping("/report")
class DoUserPickAuditReportController(
private val doUserPickAuditReportService: DoUserPickAuditReportService,
) {
/** FP-MTMS Version Checklist | Functions Ref. No. 39 | v1.0.2 | 2026-08-05 */
@GetMapping("/do-user-pick-audit-handlers")
fun handlers(): List<String> = doUserPickAuditReportService.getDistinctHandlers()

/** FP-MTMS Version Checklist | Functions Ref. No. 39 | v1.0.2 | 2026-08-05 */
@GetMapping("/print-do-user-pick-audit-excel")
fun exportExcel(
@RequestParam(required = false) dateStart: String?,
@RequestParam(required = false) dateEnd: String?,
@RequestParam(required = false) userId: Long?,
@RequestParam(required = false) handler: String?,
@RequestParam(required = false) ticketNo: String?,
@RequestParam(required = false) itemCode: String?,
@RequestParam(required = false) storeId: String?,
): ResponseEntity<ByteArray> {
val rows = doUserPickAuditReportService.searchDoUserPickAudit(
dateStart = dateStart,
dateEnd = dateEnd,
userId = userId,
handler = handler,
ticketNo = ticketNo,
itemCode = itemCode,
storeId = storeId,
)
val bytes = buildExcel(rows)
val headers = HttpHeaders().apply {
contentType = MediaType.parseMediaType(
"application/vnd.openxmlformats-officedocument.spreadsheetml.sheet",
)
setContentDispositionFormData("attachment", "成品出倉揀貨合規報告.xlsx")
set("filename", "成品出倉揀貨合規報告.xlsx")
}
return ResponseEntity(bytes, headers, HttpStatus.OK)
}

private fun buildExcel(rows: List<Map<String, Any?>>): ByteArray {
val workbook = XSSFWorkbook()
val sheet = workbook.createSheet(WorkbookUtil.createSafeSheetName("DO揀貨人員稽核"))
val headerStyle = workbook.createCellStyle().apply {
alignment = HorizontalAlignment.CENTER
verticalAlignment = VerticalAlignment.CENTER
fillForegroundColor = IndexedColors.GREY_25_PERCENT.index
fillPattern = FillPatternType.SOLID_FOREGROUND
borderTop = BorderStyle.THIN
borderBottom = BorderStyle.THIN
borderLeft = BorderStyle.THIN
borderRight = BorderStyle.THIN
val font = workbook.createFont().apply { bold = true }
setFont(font)
}
val textStyle = workbook.createCellStyle().apply {
borderTop = BorderStyle.THIN
borderBottom = BorderStyle.THIN
borderLeft = BorderStyle.THIN
borderRight = BorderStyle.THIN
}

// key = SQL alias; label = Chinese Excel header
val columns = listOf(
"datetime" to "完成時間",
"ticketNo" to "提票號碼",
"itemCode" to "貨品編號",
"itemName" to "貨品名稱",
"isEgg" to "是否雞蛋類",
"suggestLot" to "建議批號",
"actualLot" to "實際批號",
"qty" to "所需數量",
"actualQty" to "實際提料數量",
"lotBeforeQty" to "提料前庫存",
"lotAfterQty" to "提料後庫存",
"issueJustComplete" to "跳過提料(使用「已完成」)",
"user" to "提料人",
"issueTypes" to "偏離類型",
)

var r = 0
val headerRow = sheet.createRow(r++)
columns.forEachIndexed { i, (_, label) ->
headerRow.createCell(i).apply {
setCellValue(label)
cellStyle = headerStyle
}
}

for (row in rows) {
val excelRow = sheet.createRow(r++)
columns.forEachIndexed { i, (key, _) ->
val v = row[key]
val cell = excelRow.createCell(i)
cell.cellStyle = textStyle
when (v) {
null -> cell.setCellValue("")
is Number -> {
if (key == "issueJustComplete") {
cell.setCellValue(if (v.toDouble() != 0.0) "是" else "否")
} else {
cell.setCellValue(v.toDouble())
}
}
is Boolean -> cell.setCellValue(if (v) "是" else "否")
else -> cell.setCellValue(v.toString())
}
}
}

// Fixed widths (avoid slow autoSize on large sheets)
val widths = intArrayOf(18, 14, 14, 28, 16, 16, 16, 12, 12, 12, 12, 14, 12, 20)
widths.forEachIndexed { i, w -> sheet.setColumnWidth(i, w * 256) }

val out = ByteArrayOutputStream()
workbook.write(out)
workbook.close()
return out.toByteArray()
}
}

+ 28
- 0
src/main/java/com/ffii/fpsms/modules/report/web/ReportController.kt Datei anzeigen

@@ -24,12 +24,14 @@ import java.time.format.DateTimeFormatter
import com.ffii.fpsms.modules.common.SecurityUtils
import com.ffii.fpsms.modules.report.service.M18BomShopSyncReportService
import com.ffii.fpsms.modules.report.service.ReportService
import com.ffii.fpsms.modules.report.service.ShopOrderReplenishmentReportService

@RestController
@RequestMapping("/report")
class ReportController(
private val reportService: ReportService,
private val m18BomShopSyncReportService: M18BomShopSyncReportService,
private val shopOrderReplenishmentReportService: ShopOrderReplenishmentReportService,
) {
private data class ExcelStyles(
val title: XSSFCellStyle,
@@ -1001,6 +1003,32 @@ class ReportController(
syncStatus = syncStatus,
)

/**
* Shop Orders Replenishment Records (店鋪訂單補貨記錄).
* JSON for Excel export: completed replenishments with linked ticket only.
*
* Example: `/report/shop-order-replenishment?reorderDateStart=2026-08-01&shopCode=S001`
*/
@GetMapping("/shop-order-replenishment")
fun getShopOrderReplenishmentReport(
@RequestParam(required = false) reorderDateStart: String?,
@RequestParam(required = false) reorderDateEnd: String?,
@RequestParam(required = false) shopOrderDateStart: String?,
@RequestParam(required = false) shopOrderDateEnd: String?,
@RequestParam(required = false) deliveredDateStart: String?,
@RequestParam(required = false) deliveredDateEnd: String?,
@RequestParam(required = false) shopCode: String?,
): Map<String, Any> =
shopOrderReplenishmentReportService.searchShopOrderReplenishmentReport(
reorderDateStart = reorderDateStart,
reorderDateEnd = reorderDateEnd,
shopOrderDateStart = shopOrderDateStart,
shopOrderDateEnd = shopOrderDateEnd,
deliveredDateStart = deliveredDateStart,
deliveredDateEnd = deliveredDateEnd,
shopCode = shopCode,
)

companion object {
/** GRN report fields only users with ADMIN authority may see */
private val GRN_FINANCIAL_KEYS = setOf("unitPrice", "lineAmount", "currencyCode")


+ 4
- 0
src/main/java/com/ffii/fpsms/modules/stock/entity/InventoryLotLineRepository.kt Datei anzeigen

@@ -196,19 +196,23 @@ WHERE ill.id = :id
fun findAllByWarehouseCodeAndDeletedIsFalse(@Param("warehouseCode") warehouseCode: String): List<InventoryLotLine>


/** FP-MTMS Version Checklist | Functions Ref. No. 53 | v1.0.0 | 2026-08-05 — 卡片「貨品種類數量」:僅 available,與明細 fallback / 預建來源一致 */
@Query("""
SELECT COUNT(DISTINCT ill.inventoryLot.item.id)
FROM InventoryLotLine ill
WHERE ill.warehouse.id IN :warehouseIds
AND ill.deleted = false
AND ill.status = 'available'
""")
fun countDistinctItemsByWarehouseIds(@Param("warehouseIds") warehouseIds: List<Long>): Long

/** FP-MTMS Version Checklist | Functions Ref. No. 53 | v1.0.0 | 2026-08-05 — 卡片 lot 筆數:僅 available */
@Query("""
SELECT COUNT(ill.id)
FROM InventoryLotLine ill
WHERE ill.warehouse.id IN :warehouseIds
AND ill.deleted = false
AND ill.status = 'available'
""")
fun countAllByWarehouseIds(@Param("warehouseIds") warehouseIds: List<Long>): Long



+ 4
- 1
src/main/java/com/ffii/fpsms/modules/stock/entity/InventoryLotRepository.kt Datei anzeigen

@@ -21,7 +21,10 @@ interface InventoryLotRepository: AbstractRepository<InventoryLot, Long> {
WHERE il.lotNo = :lotNo
AND il.item.id = :itemId
AND il.deleted = false
ORDER BY il.id ASC
""")
fun findByLotNoAndItemId(lotNo: String, itemId: Long): InventoryLot?
fun findAllByLotNoAndItemIdAndDeletedFalseOrderByIdAsc(lotNo: String, itemId: Long): List<InventoryLot>

fun findFirstByStockInLineIdAndDeletedFalseOrderByIdAsc(stockInLineId: Serializable): InventoryLot?
fun findByIdAndDeletedFalse(id: Serializable): InventoryLot?
}

+ 52
- 0
src/main/java/com/ffii/fpsms/modules/stock/entity/InventoryRepository.kt Datei anzeigen

@@ -46,6 +46,58 @@ interface InventoryRepository: AbstractRepository<Inventory, Long> {
pageable: Pageable
): Page<InventoryInfo>

/**
* FP-MTMS Version Checklist | Functions Ref. No. 19 | v1.0.0 | 2026-07-17
* Inventory search page only: no baseUnit/uomId match required;
* one row per item = latest inventory id (no larger id for same item).
*/
@Query(
"""
SELECT i FROM Inventory i
WHERE (:code IS NULL OR :code = '' OR LOWER(i.item.code) LIKE LOWER(CONCAT('%', :code, '%')))
AND (:name IS NULL OR :name = '' OR LOWER(i.item.name) LIKE LOWER(CONCAT('%', :name, '%')))
AND (:type IS NULL OR :type = '' OR i.item.type = :type)
AND i.deleted = false
AND NOT EXISTS (
SELECT 1 FROM Inventory i2
WHERE i2.item.id = i.item.id
AND i2.deleted = false
AND i2.id > i.id
)
"""
)
fun findLatestInventoryInfoByItemCodeContainsAndItemNameContainsAndItemTypeAndDeletedIsFalse(
code: String,
name: String,
type: String,
pageable: Pageable,
): Page<InventoryInfo>

/** FP-MTMS Version Checklist | Functions Ref. No. 19 | v1.0.0 | 2026-07-17 */
@Query(
"""
SELECT i FROM Inventory i
WHERE (:code IS NULL OR :code = '' OR LOWER(i.item.code) LIKE LOWER(CONCAT('%', :code, '%')))
AND (:name IS NULL OR :name = '' OR LOWER(i.item.name) LIKE LOWER(CONCAT('%', :name, '%')))
AND (:type IS NULL OR :type = '' OR i.item.type = :type)
AND i.item.id IN :itemIds
AND i.deleted = false
AND NOT EXISTS (
SELECT 1 FROM Inventory i2
WHERE i2.item.id = i.item.id
AND i2.deleted = false
AND i2.id > i.id
)
"""
)
fun findLatestInventoryInfoByItemCodeContainsAndItemNameContainsAndItemTypeAndItemIdInAndDeletedIsFalse(
code: String,
name: String,
type: String,
itemIds: List<Long>,
pageable: Pageable,
): Page<InventoryInfo>

fun findInventoryInfoByItemIdInAndDeletedIsFalse(itemIds: List<Serializable>): List<InventoryInfo>

fun findInventoryInfoByItemInAndDeletedIsFalse(items: List<Items>): List<InventoryInfo>


+ 36
- 0
src/main/java/com/ffii/fpsms/modules/stock/entity/ItemLotTraceLog.kt Datei anzeigen

@@ -0,0 +1,36 @@
package com.ffii.fpsms.modules.stock.entity

import com.ffii.core.entity.BaseEntity
import jakarta.persistence.Column
import jakarta.persistence.Entity
import jakarta.persistence.Table
import jakarta.validation.constraints.NotNull
import jakarta.validation.constraints.Size

@Entity
@Table(name = "item_lot_trace_log")
open class ItemLotTraceLog : BaseEntity<Long>() {

@NotNull
@Column(name = "userId", nullable = false)
open var userId: Long? = null

@Size(max = 64)
@NotNull
@Column(name = "username", nullable = false, length = 64)
open var username: String? = null

@Column(name = "inventoryLotId")
open var inventoryLotId: Long? = null

@Size(max = 64)
@Column(name = "lotNo", length = 64)
open var lotNo: String? = null

@Column(name = "itemId")
open var itemId: Long? = null

@Size(max = 64)
@Column(name = "itemCode", length = 64)
open var itemCode: String? = null
}

+ 7
- 0
src/main/java/com/ffii/fpsms/modules/stock/entity/ItemLotTraceLogRepository.kt Datei anzeigen

@@ -0,0 +1,7 @@
package com.ffii.fpsms.modules.stock.entity

import com.ffii.core.support.AbstractRepository
import org.springframework.stereotype.Repository

@Repository
interface ItemLotTraceLogRepository : AbstractRepository<ItemLotTraceLog, Long>

+ 35
- 1
src/main/java/com/ffii/fpsms/modules/stock/entity/StockLedger.kt Datei anzeigen

@@ -4,9 +4,11 @@ import com.ffii.core.entity.BaseEntity
import jakarta.persistence.Column
import jakarta.persistence.Entity
import jakarta.persistence.JoinColumn
import jakarta.persistence.ManyToOne
import jakarta.persistence.OneToOne
import jakarta.persistence.Table
import jakarta.validation.constraints.NotNull
import java.math.BigDecimal
import java.time.LocalDate
@Entity
@Table(name = "stock_ledger")
@@ -19,6 +21,24 @@ open class StockLedger: BaseEntity<Long>() {
@JoinColumn(name = "stockOutLineId")
open var stockOutLine: StockOutLine? = null

/**
* FP-MTMS Version Checklist | Functions Ref. No. 46 | v1.0.1 | 2026-08-05
*
* stock_ledger.inventoryLotLineId = the **actual** inventory_lot_line.id this
* in/out movement posted against (the lot whose qty changed).
*
* Not a suggested lot. For pick-order flows (FG / JO / PO), this must align with
* suggested_pick_lot.actualPickLotLineId (and stock_out_line.inventoryLotLineId),
* NOT suggested_pick_lot.suggestedLotLineId.
* If the picker switches lot (suggested A, scan B), ledger points to B.
*
* Prefer reading this column directly; do not infer lot only via stockInLine /
* stockOutLine when this is populated.
*/
@ManyToOne
@JoinColumn(name = "inventoryLotLineId")
open var inventoryLotLine: InventoryLotLine? = null

@NotNull
@OneToOne
@JoinColumn(name = "inventoryId")
@@ -33,7 +53,21 @@ open class StockLedger: BaseEntity<Long>() {
@Column(name = "balance")
open var balance: Double? = null

/** 物料庫存單位對應之 uom_conversion.id(與 item_uom / inventory 之 stock UOM 一致) */
/**
* FP-MTMS Version Checklist | Functions Ref. No. 46 | v1.0.1 | 2026-08-05
* Available qty of that same actual lot (in−out) **before** this movement; null for pre-V2 rows.
*/
@Column(name = "lotQtyBefore", precision = 14, scale = 2)
open var lotQtyBefore: BigDecimal? = null

/**
* FP-MTMS Version Checklist | Functions Ref. No. 46 | v1.0.1 | 2026-08-05
* Available qty of that same actual lot (in−out) **after** this movement; null for pre-V2 rows.
*/
@Column(name = "lotQtyAfter", precision = 14, scale = 2)
open var lotQtyAfter: BigDecimal? = null

/** Lot line stock UOM → uom.id(優先);無 lot 時才 fallback item/inventory stock UOM */
@Column(name = "uomId")
open var uomId: Long? = null



+ 7
- 0
src/main/java/com/ffii/fpsms/modules/stock/entity/StockOutLine.kt Datei anzeigen

@@ -41,6 +41,13 @@ open class StockOutLine: BaseEntity<Long>() {
@Column(name = "handled_by")
open var handledBy: Long? = null

/**
* DO user-audit: Just Complete that should be reported because pickable stock still existed
* for the item at complete time. Null/false = not an issue JC (e.g. lot exhausted remainder).
*/
@Column(name = "isIssueJustComplete")
open var isIssueJustComplete: Boolean? = null

@JsonBackReference
@ManyToOne
@JoinColumn(name = "pickOrderLineId")


+ 12
- 0
src/main/java/com/ffii/fpsms/modules/stock/entity/SuggestedPickLot.kt Datei anzeigen

@@ -29,6 +29,18 @@ open class SuggestedPickLot: BaseEntity<Long>() {
@JoinColumn(name = "suggestedLotLineId")
open var suggestedLotLine: InventoryLotLine? = null

/**
* FP-MTMS Version Checklist | Functions Ref. No. 33 | v1.0.2 | 2026-08-03
*
* Actual picked inventory_lot_line.id.
* On create: init = suggestedLotLineId; lot switch/scan updates this only (suggested stays).
* Downstream (display, COALESCE reads, stock_ledger.inventoryLotLineId / SOL.inventoryLotLineId)
* must use this, not [suggestedLotLine].
*/
@ManyToOne
@JoinColumn(name = "actualPickLotLineId")
open var actualPickLotLine: InventoryLotLine? = null

@JsonBackReference
@ManyToOne
@JoinColumn(name = "pickOrderLineId")


+ 3
- 0
src/main/java/com/ffii/fpsms/modules/stock/entity/projection/InventoryLotLineInfo.kt Datei anzeigen

@@ -50,6 +50,9 @@ interface InventoryLotLineInfo {
@get:Value("#{target.stockUom?.uom?.udfudesc}")
val uom: String?

@get:Value("#{target.stockUom?.uom?.id}")
val uomId: Long?

@get:Value("#{target.inventoryLot.expiryDate}")
val expiryDate: LocalDate
}


+ 16
- 1
src/main/java/com/ffii/fpsms/modules/stock/service/InventoryLotLineService.kt Datei anzeigen

@@ -456,6 +456,7 @@ open fun updateInventoryLotLineQuantities(request: UpdateInventoryLotLineQuantit
}

/**
* FP-MTMS Version Checklist | Functions Ref. No. 34 | v1.0.1 | 2026-07-22
* DO workbench label modal: pickable qty = in - out (aligned with workbench scan-pick).
*/
open fun analyzeQrCodeWorkbench(request: QrCodeAnalysisRequest): QrCodeAnalysisResponse {
@@ -463,16 +464,22 @@ open fun updateInventoryLotLineQuantities(request: UpdateInventoryLotLineQuantit
}

/**
* FP-MTMS Version Checklist | Functions Ref. No. 34 | v1.0.1 | 2026-07-22
* DO workbench label modal fallback (no stockInLineId/no-lot row):
* list available lots by item only, using pickable qty = in - out.
* When [uomId] is set, only lots whose stockUom.uom.id matches are returned.
*/
open fun listWorkbenchAvailableLotsByItem(itemId: Long): WorkbenchItemLotsResponse {
open fun listWorkbenchAvailableLotsByItem(itemId: Long, uomId: Long? = null): WorkbenchItemLotsResponse {
val source = inventoryLotLineRepository
.findAllByInventoryLotItemIdAndStatus(itemId, InventoryLotLineStatus.AVAILABLE)
.asSequence()
.filter { !it.deleted && it.inventoryLot?.item != null }
.filter { uomId == null || it.stockUom?.uom?.id == uomId }
.toList()
val item = source.firstOrNull()?.inventoryLot?.item
?: inventoryLotLineRepository
.findAllByInventoryLotItemIdAndStatus(itemId, InventoryLotLineStatus.AVAILABLE)
.firstOrNull { !it.deleted }?.inventoryLot?.item
if (item == null) {
return WorkbenchItemLotsResponse(
itemId = itemId,
@@ -486,6 +493,7 @@ open fun updateInventoryLotLineQuantities(request: UpdateInventoryLotLineQuantit
val lot = lotLine.inventoryLot ?: return@mapNotNull null
val lotNo = lot.stockInLine?.lotNo ?: return@mapNotNull null
val uomDesc = lotLine.stockUom?.uom?.udfudesc ?: return@mapNotNull null
val lotUomId = lotLine.stockUom?.uom?.id
val inQty = lotLine.inQty ?: BigDecimal.ZERO
val outQty = lotLine.outQty ?: BigDecimal.ZERO
val remainingQty = inQty.minus(outQty)
@@ -496,6 +504,7 @@ open fun updateInventoryLotLineQuantities(request: UpdateInventoryLotLineQuantit
stockInLineId = lot.stockInLine?.id,
availableQty = remainingQty,
uom = uomDesc,
uomId = lotUomId,
warehouseCode = lotLine.warehouse?.code,
warehouseName = lotLine.warehouse?.name,
)
@@ -523,14 +532,17 @@ open fun updateInventoryLotLineQuantities(request: UpdateInventoryLotLineQuantit
val item = scannedInventoryLotLine.inventoryLot?.item
?: throw IllegalStateException("Item not found for lot line id=${scannedInventoryLotLine.id}")

val filterUomId = request.uomId
val sameItemLots = inventoryLotLineRepository
.findAllByInventoryLotItemIdAndStatus(request.itemId, InventoryLotLineStatus.AVAILABLE)
.asSequence()
.filter { it.id != scannedInventoryLotLine.id }
.filter { filterUomId == null || it.stockUom?.uom?.id == filterUomId }
.mapNotNull { lotLine ->
val lot = lotLine.inventoryLot ?: return@mapNotNull null
val lotNo = lot.stockInLine?.lotNo ?: return@mapNotNull null
val uomDesc = lotLine.stockUom?.uom?.udfudesc ?: return@mapNotNull null
val lotUomId = lotLine.stockUom?.uom?.id
val whCode = lotLine.warehouse?.code
val whName = lotLine.warehouse?.name

@@ -547,6 +559,7 @@ open fun updateInventoryLotLineQuantities(request: UpdateInventoryLotLineQuantit
stockInLineId = lot.stockInLine?.id,
availableQty = remainingQty,
uom = uomDesc,
uomId = lotUomId,
warehouseCode = whCode,
warehouseName = whName,
)
@@ -571,6 +584,8 @@ open fun updateInventoryLotLineQuantities(request: UpdateInventoryLotLineQuantit
?: throw IllegalStateException("inventoryLotLineId missing on scanned lot line"),
warehouseCode = scannedInventoryLotLine.warehouse?.code,
warehouseName = scannedInventoryLotLine.warehouse?.name,
uom = scannedInventoryLotLine.stockUom?.uom?.udfudesc,
uomId = scannedInventoryLotLine.stockUom?.uom?.id,
),
sameItemLots = sameItemLots,
)


+ 38
- 0
src/main/java/com/ffii/fpsms/modules/stock/service/InventoryService.kt Datei anzeigen

@@ -88,6 +88,44 @@ open class InventoryService(
return RecordsRes<InventoryInfo>(records, total.toInt());
}

/**
* FP-MTMS Version Checklist | Functions Ref. No. 19 | v1.0.0 | 2026-07-17
* Inventory search page only: no baseUnit/uomId filter;
* one inventory row per item (latest id). Shared getRecordByPage is unchanged.
*/
open fun searchInventoriesByLatestInventory(
request: SearchInventoryRequest,
): RecordsRes<InventoryInfo> {
val pageable = PageRequest.of(request.pageNum ?: 0, request.pageSize ?: 10)
val code = request.code?.trim().orEmpty()
val name = request.name?.trim().orEmpty()
val type = request.type?.trim().orEmpty()
val lotNo = request.lotNo?.trim()?.takeIf { it.isNotEmpty() }

val response = if (lotNo != null) {
val itemIds = inventoryLotLineRepository.findDistinctItemIdsByLotNo(lotNo)
if (itemIds.isEmpty()) {
return RecordsRes(emptyList(), 0)
}
inventoryRepository.findLatestInventoryInfoByItemCodeContainsAndItemNameContainsAndItemTypeAndItemIdInAndDeletedIsFalse(
code = code,
name = name,
type = type,
itemIds = itemIds,
pageable = pageable,
)
} else {
inventoryRepository.findLatestInventoryInfoByItemCodeContainsAndItemNameContainsAndItemTypeAndDeletedIsFalse(
code = code,
name = name,
type = type,
pageable = pageable,
)
}

return RecordsRes(response.content, response.totalElements.toInt())
}

open fun allInventoriesByItems(items: List<Items>): List<InventoryInfo>{
return inventoryRepository.findInventoryInfoByItemInAndDeletedIsFalse(items);
}


+ 60
- 0
src/main/java/com/ffii/fpsms/modules/stock/service/ItemLotTraceLogService.kt Datei anzeigen

@@ -0,0 +1,60 @@
package com.ffii.fpsms.modules.stock.service

import com.ffii.fpsms.modules.stock.entity.ItemLotTraceLog
import com.ffii.fpsms.modules.stock.entity.ItemLotTraceLogRepository
import com.ffii.fpsms.modules.user.entity.User
import org.slf4j.LoggerFactory
import org.springframework.stereotype.Service
import org.springframework.transaction.annotation.Isolation
import org.springframework.transaction.annotation.Transactional

@Service
open class ItemLotTraceLogService(
private val itemLotTraceLogRepository: ItemLotTraceLogRepository,
) {
private val log = LoggerFactory.getLogger(javaClass)

@Transactional(isolation = Isolation.READ_COMMITTED, rollbackFor = [Exception::class], readOnly = false)
open fun insert(
user: User,
inventoryLotId: Long?,
lotNo: String?,
itemId: Long?,
itemCode: String?,
) {
val entity = ItemLotTraceLog().apply {
this.userId = user.id
this.username = user.username
this.inventoryLotId = inventoryLotId
this.lotNo = lotNo?.takeIf { it.isNotBlank() }
this.itemId = itemId
this.itemCode = itemCode?.takeIf { it.isNotBlank() }
}
itemLotTraceLogRepository.save(entity)
}

/** Best-effort audit write; never fails the main trace. */
open fun insertQuietly(
user: User?,
inventoryLotId: Long?,
lotNo: String?,
itemId: Long?,
itemCode: String?,
) {
if (user?.id == null) {
log.warn("Skip item_lot_trace_log insert: no authenticated user")
return
}
try {
insert(
user = user,
inventoryLotId = inventoryLotId,
lotNo = lotNo,
itemId = itemId,
itemCode = itemCode,
)
} catch (e: Exception) {
log.warn("Failed to insert item_lot_trace_log: {}", e.message, e)
}
}
}

+ 48
- 0
src/main/java/com/ffii/fpsms/modules/stock/service/ItemLotTraceService.kt Datei anzeigen

@@ -0,0 +1,48 @@
package com.ffii.fpsms.modules.stock.service

import com.ffii.fpsms.modules.common.SecurityUtils
import com.ffii.fpsms.modules.stock.trace.ItemLotTraceOrchestrator
import com.ffii.fpsms.modules.stock.web.model.ItemLotTraceLocationBlock
import com.ffii.fpsms.modules.stock.web.model.ItemLotTraceResponse
import org.springframework.stereotype.Service

@Service
open class ItemLotTraceService(
private val orchestrator: ItemLotTraceOrchestrator,
private val itemLotTraceLogService: ItemLotTraceLogService,
) {
/** FP-MTMS Version Checklist | Functions Ref. No. 10 | v1.0.0 | 2026-07-17 */
open fun trace(
stockInLineId: Long?,
inventoryLotLineId: Long?,
lotNo: String?,
itemId: Long?,
itemCode: String?,
): ItemLotTraceResponse {
val response = orchestrator.trace(
stockInLineId,
inventoryLotLineId,
lotNo,
itemId,
itemCode,
)
val lot = response.lot
itemLotTraceLogService.insertQuietly(
user = SecurityUtils.getUser().orElse(null),
inventoryLotId = lot.inventoryLotId,
lotNo = lot.lotNo,
itemId = lot.itemId,
itemCode = lot.itemCode,
)
return response
}

/**
* FP-MTMS Version Checklist | Functions Ref. No. 10 | v1.0.0 | 2026-07-17
* Lazy-load endpoint: returns the full location-scoped trace block for a single inventory lot.
* Lot-level dimensions (BOM, JO prelude, production, byproducts) are excluded — those are only
* available via the main /trace endpoint.
*/
open fun traceLocation(inventoryLotId: Long): ItemLotTraceLocationBlock =
orchestrator.traceLocation(inventoryLotId)
}

+ 24
- 6
src/main/java/com/ffii/fpsms/modules/stock/service/StockInLineService.kt Datei anzeigen

@@ -581,7 +581,11 @@ open class StockInLineService(
this.stockInLine = stockInLine
this.failQty = it.failQty
this.qcPassed = it.qcPassed
this.type = "qc"
this.type = when {
stockInLine.jobOrder != null -> "EPQC"
stockInLine.purchaseOrderLine != null -> "IQC"
else -> "IQC"
}
this.remarks = it.remarks
this.escalationLog = escalationLogService.find(escLogId).getOrNull()
}
@@ -1030,9 +1034,6 @@ open class StockInLineService(
if (request.status == StockInLineStatus.RECEIVED.status) {
val _tPutAwayTotal = System.nanoTime()
// Putaway
var savedInventoryLotLine: InventoryLotLine? = null

// savedInventoryLotLine = saveInventoryLotLineWhenStockIn(request = request, stockInLine = stockInLine)
val _tSaveLotLines = System.nanoTime()
val savedInventoryLotLines = saveInventoryLotLineWhenStockIn(request = request, stockInLine = stockInLine)
_logStep("saveInventoryLotLineWhenStockIn", _tSaveLotLines)
@@ -1041,6 +1042,11 @@ open class StockInLineService(
val inventoryLotLines = stockInLine.inventoryLot?.let { it.id?.let { _id -> inventoryLotLineRepository.findAllByInventoryLotId(_id) } } ?: listOf()
_logStep("inventoryLotLineRepository.findAllByInventoryLotId", _tFindAllLotLines)

// NOR putaway can create multiple ILL (per warehouse); SIL only has one FK — keep latest from this putaway.
(savedInventoryLotLines.lastOrNull() ?: inventoryLotLines.lastOrNull())?.let { primaryLotLine ->
stockInLine.inventoryLotLine = primaryLotLine
}

// ✅ 每次上架都寫一筆 stock_ledger(inQty = 本次上架的庫存數量)
val _tDeltaCompute = System.nanoTime()
val putAwayDeltaStockQty = (request.inventoryLotLines ?: listOf()).sumOf { line ->
@@ -1098,7 +1104,6 @@ open class StockInLineService(
else
StockInLineStatus.PARTIALLY_COMPLETE.status
}
// this.inventoryLotLine = savedInventoryLotLine
}
// Update JO Status
if (stockInLine.jobOrder != null) { //TODO Improve
@@ -1373,6 +1378,7 @@ open class StockInLineService(
}
@Transactional

/** FP-MTMS Version Checklist | Functions Ref. No. 46 | v1.0.0 | 2026-08-03 */
private fun createStockLedgerForStockIn(stockInLine: StockInLine, inQty: Double) {
val _t0 = System.nanoTime()
fun _msSince(t: Long): Double = (System.nanoTime() - t) / 1_000_000.0
@@ -1398,7 +1404,11 @@ open class StockInLineService(
val newBalance = previousBalance + inQty

val _tUom = System.nanoTime()
val stockUomId = itemUomService.findStockUnitByItemId(item.id!!)?.uom?.id ?: inventory.uom?.id
val stockUomId = StockLedgerLotSnapshot.resolveLedgerUomId(
stockInLine.inventoryLotLine,
itemUomService.findStockUnitByItemId(item.id!!)?.uom?.id,
inventory.uom?.id,
)
_logStep("itemUomService.findStockUnitByItemId", _tUom)

val stockLedger = StockLedger().apply {
@@ -1413,6 +1423,14 @@ open class StockInLineService(
this.uomId = stockUomId
this.date = LocalDate.now()
}
val ill = stockInLine.inventoryLotLine
val lotAfter = ill?.let { StockLedgerLotSnapshot.availableQty(it) }
StockLedgerLotSnapshot.apply(
ledger = stockLedger,
inventoryLotLine = ill,
lotAvailableAfterMove = lotAfter,
signedDelta = BigDecimal.valueOf(inQty),
)

val _tSave = System.nanoTime()
stockLedgerRepository.saveAndFlush(stockLedger)


+ 63
- 0
src/main/java/com/ffii/fpsms/modules/stock/service/StockLedgerLotSnapshot.kt Datei anzeigen

@@ -0,0 +1,63 @@
package com.ffii.fpsms.modules.stock.service

import com.ffii.fpsms.modules.stock.entity.InventoryLotLine
import com.ffii.fpsms.modules.stock.entity.StockLedger
import java.math.BigDecimal

/**
* FP-MTMS Version Checklist | Functions Ref. No. 46 | v1.0.1 | 2026-08-05
*
* Fills [StockLedger.inventoryLotLine], [lotQtyBefore], [lotQtyAfter] for each movement.
*
* [StockLedger.inventoryLotLine] / inventoryLotLineId = **actual** moved lot line id
* (the lot whose qty changed). Prefer explicit [inventoryLotLine]; else fall back to
* stockOutLine / stockInLine related lot.
*
* On pick/suggestion flows: pass/resolve the lot that was actually picked/posted
* (= suggested_pick_lot.actualPickLotLineId / stock_out_line.inventoryLotLineId),
* never suggested_pick_lot.suggestedLotLineId alone. Lot switch (suggested A, scan B)
* → ledger must point to B.
*
* [lotQtyBefore]/[lotQtyAfter] are snapshots of that same actual lot's available qty.
* [lotAvailableAfterMove] = lot (in−out) **after** this movement has already been applied.
* [signedDelta] = +inQty or −outQty for this ledger row (stock-in positive, stock-out negative).
*/
object StockLedgerLotSnapshot {
/**
* FP-MTMS Version Checklist | Functions Ref. No. 46 | v1.0.1 | 2026-08-05
* Sets ledger.inventoryLotLine to the actual lot, then lotQtyBefore/After from that lot.
*/
fun apply(
ledger: StockLedger,
inventoryLotLine: InventoryLotLine? = null,
lotAvailableAfterMove: BigDecimal? = null,
signedDelta: BigDecimal? = null,
) {
val ill = inventoryLotLine
?: ledger.inventoryLotLine
?: ledger.stockOutLine?.inventoryLotLine
?: ledger.stockInLine?.inventoryLotLine
ledger.inventoryLotLine = ill

if (ill == null || lotAvailableAfterMove == null || signedDelta == null) {
// Leave before/after null when we cannot snapshot (caller may set later).
return
}
ledger.lotQtyAfter = lotAvailableAfterMove
ledger.lotQtyBefore = lotAvailableAfterMove.subtract(signedDelta)
}

/** FP-MTMS Version Checklist | Functions Ref. No. 46 | v1.0.1 | 2026-08-05 */
fun availableQty(ill: InventoryLotLine): BigDecimal =
(ill.inQty ?: BigDecimal.ZERO).subtract(ill.outQty ?: BigDecimal.ZERO)

/**
* FP-MTMS Version Checklist | Functions Ref. No. 46 | v1.0.1 | 2026-08-05
* Ledger UOM must follow the lot line's stock ItemUom → uom.id
* (same item may have lots with different UOMs; item stockUnit flag can change later).
*/
fun resolveLedgerUomId(ill: InventoryLotLine?, vararg fallbacks: Long?): Long? {
ill?.stockUom?.uom?.id?.let { return it }
return fallbacks.firstOrNull { it != null }
}
}

+ 58
- 9
src/main/java/com/ffii/fpsms/modules/stock/service/StockOutLineService.kt Datei anzeigen

@@ -1511,6 +1511,7 @@ open fun newBatchSubmit(request: QrPickBatchSubmitRequest): MessageResponse {
}
}
@Transactional
/** FP-MTMS Version Checklist | Functions Ref. No. 46 | v1.0.1 | 2026-08-05 */
private fun createStockLedgerForStockOut(stockOutLine: StockOutLine) {
val item = stockOutLine.item ?: return
val inventory = itemUomService.findInventoryForItemBaseUom(item.id!!) ?: return
@@ -1521,6 +1522,9 @@ open fun newBatchSubmit(request: QrPickBatchSubmitRequest): MessageResponse {
val previousBalance = latestLedger?.balance ?: (inventory.onHandQty ?: BigDecimal.ZERO).toDouble()
val newBalance = previousBalance - outQty

val ill = stockOutLine.inventoryLotLine
val outQtyBd = BigDecimal.valueOf(outQty)
val lotAfter = ill?.let { StockLedgerLotSnapshot.availableQty(it) }
val stockLedger = StockLedger().apply {
this.stockOutLine = stockOutLine
this.inventory = inventory
@@ -1530,10 +1534,19 @@ open fun newBatchSubmit(request: QrPickBatchSubmitRequest): MessageResponse {
this.type = stockOutLine.type
this.itemId = item.id
this.itemCode = item.code
this.uomId = itemUomRespository.findByItemIdAndStockUnitIsTrueAndDeletedIsFalse(item.id!!)?.uom?.id
?: inventory.uom?.id
this.uomId = StockLedgerLotSnapshot.resolveLedgerUomId(
ill,
itemUomRespository.findByItemIdAndStockUnitIsTrueAndDeletedIsFalse(item.id!!)?.uom?.id,
inventory.uom?.id,
)
this.date = LocalDate.now()
}
StockLedgerLotSnapshot.apply(
ledger = stockLedger,
inventoryLotLine = ill,
lotAvailableAfterMove = lotAfter,
signedDelta = outQtyBd.negate(),
)

stockLedgerRepository.saveAndFlush(stockLedger)
}
@@ -1746,6 +1759,7 @@ open fun newBatchSubmit(request: QrPickBatchSubmitRequest): MessageResponse {
}


/** FP-MTMS Version Checklist | Functions Ref. No. 46 | v1.0.1 | 2026-08-05 */
private fun createStockLedgerForPickDelta(
stockOutLine: StockOutLine,
deltaQty: BigDecimal,
@@ -1793,6 +1807,8 @@ open fun newBatchSubmit(request: QrPickBatchSubmitRequest): MessageResponse {
}
}

val ill = stockOutLine.inventoryLotLine
val lotAfter = ill?.let { StockLedgerLotSnapshot.availableQty(it) }
val ledger = StockLedger().apply {
this.stockOutLine = stockOutLine
this.inventory = inventory
@@ -1802,10 +1818,19 @@ open fun newBatchSubmit(request: QrPickBatchSubmitRequest): MessageResponse {
this.type = "NOR"
this.itemId = item.id
this.itemCode = item.code
this.uomId = itemUomRespository.findByItemIdAndStockUnitIsTrueAndDeletedIsFalse(item.id!!)?.uom?.id
?: inventory.uom?.id
this.uomId = StockLedgerLotSnapshot.resolveLedgerUomId(
ill,
itemUomRespository.findByItemIdAndStockUnitIsTrueAndDeletedIsFalse(item.id!!)?.uom?.id,
inventory.uom?.id,
)
this.date = LocalDate.now()
}
StockLedgerLotSnapshot.apply(
ledger = ledger,
inventoryLotLine = ill,
lotAvailableAfterMove = lotAfter,
signedDelta = deltaQty.negate(),
)

if (flushAfterSave) {
stockLedgerRepository.saveAndFlush(ledger)
@@ -2199,6 +2224,9 @@ fun applyStockOutLineDelta(
val outQty = deltaQty.toDouble()
val newBalance = previousBalance - outQty

val ill = savedSol.inventoryLotLine
val outQtyBd = BigDecimal.valueOf(outQty)
val lotAfter = ill?.let { StockLedgerLotSnapshot.availableQty(it) }
val ledger = StockLedger().apply {
this.stockOutLine = savedSol
this.inventory = inventory
@@ -2209,14 +2237,23 @@ fun applyStockOutLineDelta(
this.itemId = item.id
this.itemCode = item.code
this.uomId =
itemUomRespository.findByItemIdAndStockUnitIsTrueAndDeletedIsFalse(item.id!!)?.uom?.id
?: inventory.uom?.id
StockLedgerLotSnapshot.resolveLedgerUomId(
ill,
itemUomRespository.findByItemIdAndStockUnitIsTrueAndDeletedIsFalse(item.id!!)?.uom?.id,
inventory.uom?.id,
)
this.date = eventTime.toLocalDate()
if (!operator.isNullOrBlank()) {
this.createdBy = operator
this.modifiedBy = operator
}
}
StockLedgerLotSnapshot.apply(
ledger = ledger,
inventoryLotLine = ill,
lotAvailableAfterMove = lotAfter,
signedDelta = outQtyBd.negate(),
)
if (deferPersistenceFlush) stockLedgerRepository.save(ledger)
else stockLedgerRepository.saveAndFlush(ledger)
}
@@ -2332,7 +2369,9 @@ open fun createStockOutBatch(request: BatchStockOutRequest): BatchStockOutResult
}
val newBalance = prevBalance - delta.toDouble()

ledgersToInsert += StockLedger().apply {
val ill = sol.inventoryLotLine
val lotAfter = ill?.let { StockLedgerLotSnapshot.availableQty(it) }
val ledger = StockLedger().apply {
this.stockOutLine = sol
this.inventory = inv
this.inQty = null
@@ -2341,10 +2380,20 @@ open fun createStockOutBatch(request: BatchStockOutRequest): BatchStockOutResult
this.type = request.type
this.itemId = itemId
this.itemCode = item.code
this.uomId = itemUomRespository.findByItemIdAndStockUnitIsTrueAndDeletedIsFalse(itemId)?.uom?.id
?: inv.uom?.id
this.uomId = StockLedgerLotSnapshot.resolveLedgerUomId(
ill,
itemUomRespository.findByItemIdAndStockUnitIsTrueAndDeletedIsFalse(itemId)?.uom?.id,
inv.uom?.id,
)
this.date = today
}
StockLedgerLotSnapshot.apply(
ledger = ledger,
inventoryLotLine = ill,
lotAvailableAfterMove = lotAfter,
signedDelta = delta.negate(),
)
ledgersToInsert += ledger
runningLedgerBalanceByItemId[itemId] = newBalance
}
stockLedgerRepository.saveAll(ledgersToInsert)


+ 26
- 2
src/main/java/com/ffii/fpsms/modules/stock/service/StockOutLineWorkbenchService.kt Datei anzeigen

@@ -197,6 +197,9 @@ open class StockOutLineWorkbenchService(
)
}

/**
* FP-MTMS Version Checklist | Functions Ref. No. 33 | v1.0.2 | 2026-08-03
*/
@Transactional(rollbackFor = [Exception::class])
open fun scanPick(request: WorkbenchScanPickRequest): MessageResponse {
val lotNo = request.lotNo.trim()
@@ -315,6 +318,15 @@ open class StockOutLineWorkbenchService(
)
}

val polUomId = pol.uom?.id
val lotUomId = scannedIll.stockUom?.uom?.id
if (polUomId != null && lotUomId != null && polUomId != lotUomId) {
return MessageResponse(
id = sol.id, name = "scan_pick", code = "INVALID", type = "workbench_scan_pick",
message = "Scanned lot UOM does not match pick line UOM", errorPosition = null, entity = null
)
}

sol.inventoryLotLine = scannedIll

val required = pol.qty ?: BigDecimal.ZERO
@@ -505,6 +517,9 @@ if (updated == 0) {
val onHandAfter = (inventory.onHandQty ?: BigDecimal.ZERO).toDouble()
val previousBalance = onHandAfter + deltaQty.toDouble()
val newBalance = previousBalance - deltaQty.toDouble()
val ill = sol.inventoryLotLine?.id?.let { inventoryLotLineRepository.findById(it).orElse(sol.inventoryLotLine) }
?: sol.inventoryLotLine
val lotAfter = ill?.let { StockLedgerLotSnapshot.availableQty(it) }
val ledger = StockLedger().apply {
this.stockOutLine = sol
this.inventory = inventory
@@ -514,10 +529,19 @@ if (updated == 0) {
this.type = "NOR"
this.itemId = solItem.id
this.itemCode = solItem.code
this.uomId = itemUomRespository.findByItemIdAndStockUnitIsTrueAndDeletedIsFalse(solItem.id!!)?.uom?.id
?: inventory.uom?.id
this.uomId = StockLedgerLotSnapshot.resolveLedgerUomId(
ill,
itemUomRespository.findByItemIdAndStockUnitIsTrueAndDeletedIsFalse(solItem.id!!)?.uom?.id,
inventory.uom?.id,
)
this.date = LocalDate.now()
}
StockLedgerLotSnapshot.apply(
ledger = ledger,
inventoryLotLine = ill,
lotAvailableAfterMove = lotAfter,
signedDelta = deltaQty.negate(),
)
stockLedgerRepository.save(ledger)
}



+ 27
- 2
src/main/java/com/ffii/fpsms/modules/stock/service/StockTakeRecordService.kt Datei anzeigen

@@ -2488,6 +2488,10 @@ private fun applyVarianceAdjustment(
}
val newBalance = previousBalance - qtyToRemove.toDouble()

// FP-MTMS Version Checklist | Functions Ref. No. 46 | v1.0.1 | 2026-08-05
// Snapshot from live lot line (in−out) at approve time — not stock_take_record.bookQty.
val lotAfterAvail = StockLedgerLotSnapshot.availableQty(latestLine).subtract(qtyToRemove)

val stockLedger = StockLedger().apply {
this.inventory = inventory
this.itemId = inventoryLot.item?.id
@@ -2497,9 +2501,18 @@ private fun applyVarianceAdjustment(
this.stockOutLine = stockOutLine
this.balance = newBalance
this.type = "TKE"
this.uomId = latestLine.stockUom?.uom?.id ?: inventory.uom?.id
this.uomId = StockLedgerLotSnapshot.resolveLedgerUomId(
latestLine,
inventory.uom?.id,
)
this.date = LocalDate.now()
}
StockLedgerLotSnapshot.apply(
ledger = stockLedger,
inventoryLotLine = latestLine,
lotAvailableAfterMove = lotAfterAvail,
signedDelta = qtyToRemove.negate(),
)

if (context != null) {
context.stockLedgers.add(stockLedger)
@@ -2613,6 +2626,9 @@ private fun applyVarianceAdjustment(
}
val newBalance = previousBalance + plusQty.toDouble()

// latestLine.inQty already includes plusQty above — snapshot live lot line after move.
val lotAfterAvail = StockLedgerLotSnapshot.availableQty(latestLine)

val stockLedger = StockLedger().apply {
this.inventory = inventory
this.itemId = inventoryLot.item?.id
@@ -2622,9 +2638,18 @@ private fun applyVarianceAdjustment(
this.stockInLine = stockInLine
this.balance = newBalance
this.type = "TKE"
this.uomId = latestLine.stockUom?.uom?.id ?: inventory.uom?.id
this.uomId = StockLedgerLotSnapshot.resolveLedgerUomId(
latestLine,
inventory.uom?.id,
)
this.date = LocalDate.now()
}
StockLedgerLotSnapshot.apply(
ledger = stockLedger,
inventoryLotLine = latestLine,
lotAvailableAfterMove = lotAfterAvail,
signedDelta = plusQty,
)

if (context != null) {
context.stockLedgers.add(stockLedger)


+ 23
- 6
src/main/java/com/ffii/fpsms/modules/stock/service/SuggestedPickLotService.kt Datei anzeigen

@@ -96,6 +96,7 @@ open class SuggestedPickLotService(
return SuggestedPickLotResponse(holdQtyMap = holdQtyMap, suggestedList = suggestedList)
}

/** FP-MTMS Version Checklist | Functions Ref. No. 32 | v1.0.1 | 2026-07-22 */
open fun suggestionForPickOrderLines(request: SuggestedPickLotForPolRequest): SuggestedPickLotResponse {
val pols = request.pickOrderLines
val itemIds = pols.mapNotNull { it.item?.id }
@@ -123,7 +124,9 @@ open class SuggestedPickLotService(
pols.forEach { line ->
val salesUnit = line.item?.id?.let { itemUomService.findSalesUnitByItemId(it) }
val polUomId = line.uom?.id
val lotLines = availableInventoryLotLines[line.item?.id].orEmpty()
.filter { polUomId == null || it.uomId == polUomId }
val ratio = one // (salesUnit?.ratioN ?: one).divide(salesUnit?.ratioD ?: one, 10, RoundingMode.HALF_UP)
val pickOrder = line.pickOrder
val isDoPickOrder = pickOrder?.type?.value == "do" || pickOrder?.type?.value == "delivery_order"
@@ -275,6 +278,7 @@ open class SuggestedPickLotService(
}
return SuggestedPickLotResponse(holdQtyMap = holdQtyMap, suggestedList = suggestedList)
}
/** FP-MTMS Version Checklist | Functions Ref. No. 32 | v1.0.1 | 2026-07-22 */
open fun suggestionForPickOrderLinesForJobOrder(request: SuggestedPickLotForPolRequest): SuggestedPickLotResponse {
val pols = request.pickOrderLines
val itemIds = pols.mapNotNull { it.item?.id }
@@ -302,8 +306,10 @@ open class SuggestedPickLotService(
pols.forEach { line ->
val itemId = line.item?.id
val isWipItem = itemId != null && wipItemIdSet.contains(itemId)
val polUomId = line.uom?.id

val lotInfos = availableInventoryLotLines[itemId].orEmpty()
.filter { polUomId == null || it.uomId == polUomId }
val lotIds = lotInfos.mapNotNull { it.id }
// 仅一次查询,避免后续重复 findById
val lotEntities = if (lotIds.isNotEmpty()) inventoryLotLineRepository.findAllById(lotIds) else emptyList()
@@ -580,6 +586,7 @@ open class SuggestedPickLotService(
return null
}
}
/** FP-MTMS Version Checklist | Functions Ref. No. 32 | v1.0.1 | 2026-07-22 */
@Transactional(rollbackFor = [Exception::class])
open fun resuggestPickOrder(pickOrderId: Long): MessageResponse {
try {
@@ -1290,11 +1297,15 @@ private fun generateOptimalSuggestionsForAllPickOrders(
var lotRemainingQty = availableQty
// Allocate this lot to pick order lines in order
// Allocate this lot to pick order lines in order (same UOM only)
remainingPickOrderLines.removeAll { pickOrderLine ->
val lineId = pickOrderLine.id
val lineRemainingQty = remainingQtyPerLine[lineId] ?: zero
val polUomId = pickOrderLine.uom?.id
if (polUomId != null && lot.stockUom?.uom?.id != polUomId) {
return@removeAll false
}

if (lineRemainingQty <= zero || lotRemainingQty <= zero) return@removeAll false
val assignQty = minOf(lotRemainingQty, lineRemainingQty)
@@ -1394,10 +1405,12 @@ private fun updateInventoryTableAfterResuggest(pickOrder: PickOrder) {
if (excessQtyInSalesUnits <= zero) return@forEach
// Get available inventory lots for this item (FEFO order)
val polUomId = orderLine.uom?.id
// Get available inventory lots for this item (FEFO order, same UOM)
val availableLots = inventoryLotLineService
.allInventoryLotLinesByItemIdIn(listOf(itemId))
.filter { it.status == InventoryLotLineStatus.AVAILABLE.value }
.filter { polUomId == null || it.uomId == polUomId }
.sortedBy { it.expiryDate }
var remainingQtyInBaseUnits = excessQtyInBaseUnits
@@ -1550,9 +1563,11 @@ private fun generateCorrectSuggestionsWithExistingHolds(pickOrder: PickOrder): L
println("Available lots: ${availableLots.size}, Unavailable lots: ${unavailableLots.size}")
println("Total unavailable hold qty to redistribute: $totalUnavailableHoldQty")
// Generate suggestions from available lots ONLY
val polUomId = orderLine.uom?.id
// Generate suggestions from available lots ONLY (same UOM as POL)
availableLots.forEach { lotInfo ->
if (remainingQtyInSalesUnits <= zero) return@forEach
if (polUomId != null && lotInfo.uomId != polUomId) return@forEach

val lot = lotInfo.id?.let { inventoryLotLineRepository.findById(it).orElse(null) }
?: return@forEach
@@ -1620,12 +1635,14 @@ private fun generateCorrectSuggestionsWithOriginalHolds(
val salesUnit = itemUomService.findSalesUnitByItemId(itemId)
val ratio = one
val polUomId = orderLine.uom?.id
// Get available inventory lots for this item (FEFO order)
// Get available inventory lots for this item (FEFO order, same UOM)
val availableLots = inventoryLotLineService
.allInventoryLotLinesByItemIdIn(listOf(itemId))
.filter { it.status == InventoryLotLineStatus.AVAILABLE.value }
.filter { it.expiryDate.isAfter(today) || it.expiryDate.isEqual(today) }
.filter { polUomId == null || it.uomId == polUomId }
.sortedBy { it.expiryDate }
// Calculate total quantity that needs to be redistributed


+ 44
- 2
src/main/java/com/ffii/fpsms/modules/stock/service/SuggestedPickLotWorkbenchService.kt Datei anzeigen

@@ -70,6 +70,7 @@ open class SuggestedPickLotWorkbenchService(
}

/**
* FP-MTMS Version Checklist | Functions Ref. No. 32 | v1.0.2 | 2026-08-03
* After workbench scan-pick updates [StockOutLine] qty/status, bind SPL and set **qty** to match SOL (已揀 + 進行中).
*/
@Transactional(rollbackFor = [Exception::class])
@@ -97,7 +98,14 @@ open class SuggestedPickLotWorkbenchService(
}
if (spl != null) {
spl.stockOutLine = sol
spl.suggestedLotLine = ill
// Keep suggestedLotLine as the system suggestion; only record what was actually picked.
if (spl.suggestedLotLine == null && ill != null) {
spl.suggestedLotLine = ill
}
spl.actualPickLotLine = ill
if (spl.actualPickLotLine == null && spl.suggestedLotLine != null) {
spl.actualPickLotLine = spl.suggestedLotLine
}
spl.pickOrderLine = pol
spl.qty = qtyBd
spl.type = SuggestedPickLotType.PICK_ORDER
@@ -109,6 +117,7 @@ open class SuggestedPickLotWorkbenchService(
type = SuggestedPickLotType.PICK_ORDER
stockOutLine = sol
suggestedLotLine = ill
actualPickLotLine = ill
pickOrderLine = pol
qty = qtyBd
},
@@ -150,6 +159,16 @@ open class SuggestedPickLotWorkbenchService(
}

/**
* FP-MTMS Version Checklist | Functions Ref. No. 32 | v1.0.2 | 2026-08-03
* Lot stock UOM must match pick_order_line.uom (UomConversion id).
*/
private fun matchesPolUom(lot: InventoryLotLine, line: PickOrderLine): Boolean {
val polUomId = line.uom?.id ?: return false
return lot.stockUom?.uom?.id == polUomId
}

/**
* FP-MTMS Version Checklist | Functions Ref. No. 32 | v1.0.2 | 2026-08-03
* Build suggestions with no-hold rule:
* available = inQty - outQty (ignore holdQty).
* When [desired] is a single segment, reuses one SPL row (max id) and repoints lot + qty (switch-lot without new id).
@@ -207,7 +226,7 @@ open class SuggestedPickLotWorkbenchService(
return@forEach
}

val lots = lotsByItem[line.item?.id].orEmpty()
val lots = lotsByItem[line.item?.id].orEmpty().filter { matchesPolUom(it, line) }
val desired = buildDesiredAllocations(lots, remaining, lineId)
created += syncSuggestedPickLotsForLine(line, desired)
}
@@ -282,6 +301,7 @@ open class SuggestedPickLotWorkbenchService(
}

/**
* FP-MTMS Version Checklist | Functions Ref. No. 32 | v1.0.2 | 2026-08-03
* Same no-hold allocation as [rebuildNoHoldSuggestionsForPickOrder], but only for one [pickOrderLineId].
* Used by workbench scan-pick to avoid rebuilding suggestions for every line on the pick order.
*/
@@ -340,6 +360,7 @@ open class SuggestedPickLotWorkbenchService(
.filter { it.status == InventoryLotLineStatus.AVAILABLE }
.filter { it.inventoryLot?.expiryDate?.isBefore(today) != true }
.filter { !isWarehouseExcluded(it, excludeSet) }
.filter { matchesPolUom(it, lineFresh) }
.filter { lot ->
if (storeKey == null) true
else normalizeStoreId(lot.warehouse?.store_id) == storeKey
@@ -372,6 +393,7 @@ open class SuggestedPickLotWorkbenchService(
}

/**
* FP-MTMS Version Checklist | Functions Ref. No. 32 | v1.0.2 | 2026-08-03
* Workbench explicit-qty remainder support (single-next-lot):
* Create exactly ONE suggested lot row for this POL with [targetQty] pointing to the next available lot.
* When [storeId] is provided, the next lot must be within the same store (warehouse.store_id).
@@ -411,6 +433,7 @@ open class SuggestedPickLotWorkbenchService(
.filter { it.status == InventoryLotLineStatus.AVAILABLE }
.filter { it.inventoryLot?.expiryDate?.isBefore(today) != true }
.filter { !isWarehouseExcluded(it, excludeSet) }
.filter { matchesPolUom(it, line) }
.filter { lot ->
if (excludeInventoryLotLineId == null) true else lot.id != excludeInventoryLotLineId
}
@@ -431,6 +454,7 @@ open class SuggestedPickLotWorkbenchService(
}

/**
* FP-MTMS Version Checklist | Functions Ref. No. 32 | v1.0.2 | 2026-08-03
* Workbench explicit-qty split support:
* Set suggestions for this POL to an exact [targetQty] (independent of POL.qty).
* - If [targetQty] <= 0: soft-delete all active suggestions for the line.
@@ -466,6 +490,7 @@ open class SuggestedPickLotWorkbenchService(
.filter { it.status == InventoryLotLineStatus.AVAILABLE }
.filter { it.inventoryLot?.expiryDate?.isBefore(today) != true }
.filter { !isWarehouseExcluded(it, excludeSet) }
.filter { matchesPolUom(it, line) }
.sortedBy { it.inventoryLot?.expiryDate }

val desired = buildDesiredAllocations(lots, targetQty, pickOrderLineId)
@@ -545,6 +570,7 @@ open class SuggestedPickLotWorkbenchService(
}

/**
* FP-MTMS Version Checklist | Functions Ref. No. 32 | v1.0.2 | 2026-08-03
* Align DB rows with [desired]. Returns count of newly inserted SPL rows.
*/
private fun syncSuggestedPickLotsForLine(
@@ -563,6 +589,14 @@ open class SuggestedPickLotWorkbenchService(
val reuse = keepers.maxByOrNull { it.id ?: 0L }
if (reuse != null) {
reuse.suggestedLotLine = ill
// Pending suggestion: keep actual in sync with suggested until user picks/switches.
if (reuse.actualPickLotLine == null ||
reuse.actualPickLotLine?.id == null ||
reuse.stockOutLine == null ||
!isSplFrozen(reuse)
) {
reuse.actualPickLotLine = ill
}
reuse.pickOrderLine = line
reuse.type = SuggestedPickLotType.PICK_ORDER
reuse.qty = qty
@@ -579,6 +613,7 @@ open class SuggestedPickLotWorkbenchService(
SuggestedPickLot().apply {
type = SuggestedPickLotType.PICK_ORDER
suggestedLotLine = ill
actualPickLotLine = ill
pickOrderLine = line
this.qty = qty
},
@@ -600,6 +635,12 @@ open class SuggestedPickLotWorkbenchService(
val spl = byIllKey[k]
if (spl != null) {
spl.suggestedLotLine = ill
if (spl.actualPickLotLine == null ||
spl.stockOutLine == null ||
!isSplFrozen(spl)
) {
spl.actualPickLotLine = ill
}
spl.pickOrderLine = line
spl.type = SuggestedPickLotType.PICK_ORDER
spl.qty = qty
@@ -613,6 +654,7 @@ open class SuggestedPickLotWorkbenchService(
SuggestedPickLot().apply {
type = SuggestedPickLotType.PICK_ORDER
suggestedLotLine = ill
actualPickLotLine = ill
pickOrderLine = line
this.qty = qty
},


+ 319
- 0
src/main/java/com/ffii/fpsms/modules/stock/trace/ItemLotTraceOrchestrator.kt Datei anzeigen

@@ -0,0 +1,319 @@
package com.ffii.fpsms.modules.stock.trace

import com.ffii.fpsms.modules.master.entity.ItemsRepository
import com.ffii.fpsms.modules.stock.web.model.ItemLotTraceResponse
import org.springframework.http.HttpStatus
import org.springframework.stereotype.Component
import org.springframework.web.server.ResponseStatusException

@Component
open class ItemLotTraceOrchestrator(
private val lotResolver: TraceLotResolver,
private val inboundLoader: TraceInboundLoader,
private val movementLoader: TraceMovementLoader,
private val outboundLoader: TraceOutboundLoader,
private val bomLoader: TraceBomLoader,
private val productionLoader: TraceProductionLoader,
private val joPreludeLoader: TraceJoPreludeLoader,
private val extendedLoader: TraceExtendedLoader,
private val upstreamLoader: TraceUpstreamLoader,
private val itemsRepository: ItemsRepository,
private val traceGraphSemanticsBuilder: TraceGraphSemanticsBuilder,
) {
open fun trace(
stockInLineId: Long?,
inventoryLotLineId: Long?,
lotNo: String?,
itemId: Long?,
itemCode: String?,
): ItemLotTraceResponse {
val resolvedItemId = itemId ?: itemCode?.trim()?.takeIf { it.isNotEmpty() }?.let { code ->
itemsRepository.findByCodeAndDeletedFalse(code)?.id
?: throw ResponseStatusException(HttpStatus.NOT_FOUND, "Item code not found: $code")
}

val inventoryLotId = lotResolver.resolveInventoryLotId(
stockInLineId,
inventoryLotLineId,
lotNo,
resolvedItemId,
)
val lot = lotResolver.loadLotInfo(inventoryLotId)
val lotLineIds = lotResolver.loadLotLineIds(inventoryLotId)
val stockInLineIds = lotResolver.loadStockInLineIds(inventoryLotId)

val joProvenance = upstreamLoader.resolveJoProvenance(
inventoryLotId,
itemId = lot.itemId,
lotNo = lot.lotNo,
)

val effectiveJoSilId = lot.primaryStockInLineId?.takeIf { bomLoader.hasJoStockIn(it) }
?: joProvenance?.joStockInLineId
val joId = lotResolver.resolveJobOrderIdForLot(inventoryLotId, lot.primaryStockInLineId)
?: joProvenance?.jobOrderId

val warehouseLines = lotResolver.loadWarehouseLines(inventoryLotId)
val alternateLocations = lotResolver.loadAlternateLocations(lot.itemId, lot.lotNo, inventoryLotId)
val altLotIdSet = alternateLocations.map { it.inventoryLotId }.toSet()

// Transfer-chain / JO upstream lots whose PO/QC/putaway should surface on the
// destination lot when its own stock-in is only TRANSFER (helpers were unused).
// Sibling same-lotNo lots already arrive via locationBlocks — exclude them here
// to avoid duplicating rows in merged tabs.
val upstreamMergeLotIds = (
upstreamLoader.resolveUpstreamLotIds(inventoryLotId, lot.itemId, lot.lotNo) +
(joProvenance?.upstreamLotIds.orEmpty())
)
.distinct()
.filter { it != inventoryLotId && it !in altLotIdSet }

val origins = inboundLoader.loadOriginsForLots(listOf(inventoryLotId) + upstreamMergeLotIds)
.distinctBy { it.stockInLineId }
val upstreamStockInLineIds = if (upstreamMergeLotIds.isNotEmpty()) {
lotResolver.loadStockInLineIdsForLots(upstreamMergeLotIds).values.flatten().distinct()
} else {
emptyList()
}
val mergedStockInLineIds = (stockInLineIds + upstreamStockInLineIds).distinct()
val purchaseEvents = inboundLoader.loadPurchaseEvents(mergedStockInLineIds)
.distinctBy { it.purchaseOrderLineId }
val qcResults = inboundLoader.loadQcResults(mergedStockInLineIds)
.distinctBy { "${it.stockInLineId}|${it.qcItemCode}|${it.qcType}|${it.created}" }
val putawayEvents = (
inboundLoader.loadPutawayEvents(inventoryLotId) +
upstreamMergeLotIds.flatMap { inboundLoader.loadPutawayEvents(it) }
)
.distinctBy { "${it.inventoryLotLineId}|${it.timestamp}|${it.refCode}|${it.qty}" }
val movements = movementLoader.loadMovements(inventoryLotId, lotLineIds)
val outboundUsage = outboundLoader.loadOutboundUsage(inventoryLotId)
val stockTakeEvents = movementLoader.loadStockTakeEvents(lotLineIds)
val adjustments = movementLoader.loadAdjustments(inventoryLotId, lotLineIds)
val transfers = movementLoader.loadTransfers(inventoryLotId)
val bomTrace = bomLoader.loadBomTrace(
inventoryLotId,
effectiveJoSilId ?: lot.primaryStockInLineId,
lot.itemId,
lotLineIds,
)
val joPrelude = effectiveJoSilId?.let { silId ->
if (bomLoader.hasJoStockIn(silId)) joPreludeLoader.loadJoPrelude(silId) else null
}
val productionSteps = joId?.let { productionLoader.loadProductionSteps(it) } ?: emptyList()
val byproductLots = joId?.let { productionLoader.loadByproductLots(it, lot.itemId) } ?: emptyList()
val openMovements = movementLoader.loadOpenMovements(inventoryLotId)
val failEvents = extendedLoader.loadFailEvents(inventoryLotId)
val rawMovements = movements.sortedByDescending { it.timestamp ?: "" }
val doDeliveries = resolveDoDeliveries(listOf(inventoryLotId), rawMovements)
val movementsWithDoMeta = enrichDoMovements(rawMovements, doDeliveries)
val returnEvents = outboundLoader.loadReturnEvents(inventoryLotId, lotLineIds)
val replenishmentEvents = outboundLoader.loadReplenishmentEvents(inventoryLotId)
val lotRelations = extendedLoader.loadLotRelations(inventoryLotId, lot.itemId, lot.lotNo)

// Batch pre-load alternate location data to avoid N+1 queries
val altLotIds = alternateLocations.map { it.inventoryLotId }
val altLotInfoByLotId = if (altLotIds.isNotEmpty()) lotResolver.loadLotInfoBatch(altLotIds) else emptyMap()
val altStockInLineIdsByLotId = if (altLotIds.isNotEmpty()) lotResolver.loadStockInLineIdsForLots(altLotIds) else emptyMap()
val altLotLineIdsByLotId = if (altLotIds.isNotEmpty()) lotResolver.loadLotLineIdsForLots(altLotIds) else emptyMap()
val altPutawayByLotId = if (altLotIds.isNotEmpty()) inboundLoader.loadPutawayEventsByLotIds(altLotIds) else emptyMap()

// Batch pre-load origins, purchase events, and QC results across ALL alternate locations
val altOriginsByLotId: Map<Long, List<com.ffii.fpsms.modules.stock.web.model.ItemLotTraceOrigin>> =
if (altLotIds.isNotEmpty()) {
inboundLoader.loadOriginsForLots(altLotIds)
.groupBy { origin ->
// Map back to lot via stockInLineId lookup
altStockInLineIdsByLotId.entries
.firstOrNull { (_, silIds) -> silIds.contains(origin.stockInLineId) }?.key
?: altLotIds.first()
}
} else emptyMap()

val allAltStockInLineIds = altStockInLineIdsByLotId.values.flatten().distinct()
val altPurchaseEventsBySilId: Map<Long, List<com.ffii.fpsms.modules.stock.web.model.ItemLotTracePurchaseEvent>> =
if (allAltStockInLineIds.isNotEmpty()) {
inboundLoader.loadPurchaseEvents(allAltStockInLineIds)
.groupBy { it.purchaseOrderLineId }
} else emptyMap()

val altQcResultsBySilId: Map<Long, List<com.ffii.fpsms.modules.stock.web.model.ItemLotTraceQcResult>> =
if (allAltStockInLineIds.isNotEmpty()) {
inboundLoader.loadQcResults(allAltStockInLineIds)
.groupBy { it.stockInLineId }
} else emptyMap()

val locationBlocks = alternateLocations.map { alt ->
val altLot = altLotInfoByLotId[alt.inventoryLotId]
val altSilIds = altStockInLineIdsByLotId[alt.inventoryLotId].orEmpty()
traceLocationBlock(
inventoryLotId = alt.inventoryLotId,
itemCode = altLot?.itemCode ?: lot.itemCode,
lotNo = altLot?.lotNo ?: lot.lotNo,
itemName = altLot?.itemName ?: "",
uom = altLot?.uom ?: "",
expiryDate = altLot?.expiryDate,
productionDate = altLot?.productionDate,
stockInDate = altLot?.stockInDate,
primaryStockInLineId = altLot?.primaryStockInLineId,
preloadedStockInLineIds = altSilIds,
preloadedLotLineIds = altLotLineIdsByLotId[alt.inventoryLotId],
preloadedPutawayEvents = altPutawayByLotId[alt.inventoryLotId],
preloadedOrigins = altOriginsByLotId[alt.inventoryLotId],
preloadedPurchaseEvents = altSilIds.flatMap { sid -> altPurchaseEventsBySilId[sid].orEmpty() }.distinctBy { it.purchaseOrderLineId },
preloadedQcResults = altSilIds.flatMap { sid -> altQcResultsBySilId[sid].orEmpty() },
)
}

return ItemLotTraceResponse(
lot = lot,
warehouseLines = warehouseLines,
origins = origins,
purchaseEvents = purchaseEvents,
qcResults = qcResults,
putawayEvents = putawayEvents.sortedBy { it.timestamp ?: "" },
movements = movementsWithDoMeta,
outboundUsage = outboundUsage.sortedByDescending { it.timestamp ?: "" },
stockTakeEvents = stockTakeEvents.sortedByDescending { it.timestamp ?: "" },
adjustments = adjustments.sortedByDescending { it.timestamp ?: "" },
transfers = transfers.sortedByDescending { it.timestamp ?: "" },
bomTrace = bomTrace,
joPrelude = joPrelude,
productionSteps = productionSteps,
byproductLots = byproductLots,
openMovements = openMovements,
failEvents = failEvents,
doDeliveries = doDeliveries,
replenishmentEvents = replenishmentEvents,
returnEvents = returnEvents,
lotRelations = lotRelations,
alternateLocations = alternateLocations,
locationBlocks = locationBlocks,
).let { response ->
response.copy(traceGraph = traceGraphSemanticsBuilder.build(response))
}
}

private fun resolveDoDeliveries(
mergedLotIds: List<Long>,
movements: List<com.ffii.fpsms.modules.stock.web.model.ItemLotTraceMovement>,
): List<com.ffii.fpsms.modules.stock.web.model.ItemLotTraceDoDelivery> {
val fromLots = mergedLotIds.flatMap { outboundLoader.loadDoDeliveries(it) }
val covered = fromLots.map { it.pickOrderCode.trim() }.filter { it.isNotEmpty() }.toSet()
val missingCodes = movements
.filter { it.refType.equals("DO", ignoreCase = true) }
.map { it.refCode.trim() }
.filter { it.isNotEmpty() && it !in covered }
.distinct()
val supplemental = outboundLoader.loadDoDeliveriesByPickOrderCodes(missingCodes)
return (fromLots + supplemental).distinctBy { it.stockOutLineId }
}

/** FP-MTMS Version Checklist | Functions Ref. No. 24 | v1.0.0 | 2026-07-20 */
private fun enrichDoMovements(
movements: List<com.ffii.fpsms.modules.stock.web.model.ItemLotTraceMovement>,
doDeliveries: List<com.ffii.fpsms.modules.stock.web.model.ItemLotTraceDoDelivery>,
): List<com.ffii.fpsms.modules.stock.web.model.ItemLotTraceMovement> {
val metaByPickCode = doDeliveries.associateBy { it.pickOrderCode.trim() }
return movements.map { movement ->
if (!movement.refType.equals("DO", ignoreCase = true)) return@map movement
val meta = metaByPickCode[movement.refCode.trim()] ?: return@map movement
movement.copy(
pickConsoCode = meta.consoCode,
outboundTicketNo = meta.ticketNo,
deliveryOrderPickOrderId = meta.deliveryOrderPickOrderId,
relationshipId = meta.relationshipId,
doOutboundIsExtra = meta.isExtra,
doOutboundIsReplenish = meta.isReplenish,
doOutboundQtyChanged = meta.qtyChanged,
)
}
}

/**
* Builds a location-scoped trace block for a single alternate [inventoryLotId].
*
* Only includes dimensions that are location-scoped (warehouse lines, movements, origins,
* outbound usage, stock take, etc.). Lot-level dimensions (BOM, JO prelude, production steps,
* byproduct lots, lot relations, trace graph) are NOT included — they're already at the top
* level of [ItemLotTraceResponse] and are shared across all locations of the same lot.
*/
open fun traceLocationBlock(
inventoryLotId: Long,
itemCode: String,
lotNo: String,
itemName: String = "",
uom: String = "",
expiryDate: String? = null,
productionDate: String? = null,
stockInDate: String? = null,
primaryStockInLineId: Long? = null,
/** Pre-loaded from batch query — avoids per-location stockInLineIds query. */
preloadedStockInLineIds: List<Long>? = null,
/** Pre-loaded from batch query — avoids per-location lotLineIds query. */
preloadedLotLineIds: List<Long>? = null,
/** Pre-loaded from batch query — avoids per-location putaway query. */
preloadedPutawayEvents: List<com.ffii.fpsms.modules.stock.web.model.ItemLotTracePutawayEvent>? = null,
/** Pre-loaded from batch query — avoids per-location origins query. */
preloadedOrigins: List<com.ffii.fpsms.modules.stock.web.model.ItemLotTraceOrigin>? = null,
/** Pre-loaded from batch query — avoids per-location purchaseEvents query. */
preloadedPurchaseEvents: List<com.ffii.fpsms.modules.stock.web.model.ItemLotTracePurchaseEvent>? = null,
/** Pre-loaded from batch query — avoids per-location qcResults query. */
preloadedQcResults: List<com.ffii.fpsms.modules.stock.web.model.ItemLotTraceQcResult>? = null,
): com.ffii.fpsms.modules.stock.web.model.ItemLotTraceLocationBlock {
val lotLineIds = preloadedLotLineIds ?: lotResolver.loadLotLineIds(inventoryLotId)
val stockInLineIds = preloadedStockInLineIds ?: lotResolver.loadStockInLineIds(inventoryLotId)
val effectiveSilId = primaryStockInLineId ?: stockInLineIds.firstOrNull()
val rawMovements = movementLoader.loadMovements(inventoryLotId, lotLineIds)
.sortedByDescending { it.timestamp ?: "" }
val doDeliveries = resolveDoDeliveries(listOf(inventoryLotId), rawMovements)
val movementsWithDoMeta = enrichDoMovements(rawMovements, doDeliveries)

return com.ffii.fpsms.modules.stock.web.model.ItemLotTraceLocationBlock(
inventoryLotId = inventoryLotId,
lotNo = lotNo,
itemCode = itemCode,
itemName = itemName,
uom = uom,
expiryDate = expiryDate,
productionDate = productionDate,
stockInDate = stockInDate,
stockInLineId = effectiveSilId,
warehouseLines = lotResolver.loadWarehouseLines(inventoryLotId),
origins = preloadedOrigins ?: inboundLoader.loadOrigins(inventoryLotId),
purchaseEvents = preloadedPurchaseEvents ?: inboundLoader.loadPurchaseEvents(stockInLineIds),
qcResults = preloadedQcResults ?: inboundLoader.loadQcResults(stockInLineIds),
putawayEvents = preloadedPutawayEvents ?: inboundLoader.loadPutawayEvents(inventoryLotId),
movements = movementsWithDoMeta,
outboundUsage = outboundLoader.loadOutboundUsage(inventoryLotId),
stockTakeEvents = movementLoader.loadStockTakeEvents(lotLineIds),
adjustments = movementLoader.loadAdjustments(inventoryLotId, lotLineIds),
transfers = movementLoader.loadTransfers(inventoryLotId),
openMovements = movementLoader.loadOpenMovements(inventoryLotId),
failEvents = extendedLoader.loadFailEvents(inventoryLotId),
doDeliveries = doDeliveries,
replenishmentEvents = outboundLoader.loadReplenishmentEvents(inventoryLotId),
returnEvents = outboundLoader.loadReturnEvents(inventoryLotId, lotLineIds),
)
}

/**
* Lazy-load entry point: resolves lot info from [inventoryLotId] then delegates to
* [traceLocationBlock]. Used by the controller's `GET /trace/location/{inventoryLotId}` endpoint.
*/
open fun traceLocation(
inventoryLotId: Long,
): com.ffii.fpsms.modules.stock.web.model.ItemLotTraceLocationBlock {
val lot = lotResolver.loadLotInfo(inventoryLotId)
return traceLocationBlock(
inventoryLotId = inventoryLotId,
itemCode = lot.itemCode,
lotNo = lot.lotNo,
itemName = lot.itemName,
uom = lot.uom,
expiryDate = lot.expiryDate,
productionDate = lot.productionDate,
stockInDate = lot.stockInDate,
primaryStockInLineId = lot.primaryStockInLineId,
)
}
}

+ 308
- 0
src/main/java/com/ffii/fpsms/modules/stock/trace/TraceBomLoader.kt Datei anzeigen

@@ -0,0 +1,308 @@
package com.ffii.fpsms.modules.stock.trace

import com.ffii.core.support.JdbcDao
import com.ffii.fpsms.modules.stock.web.model.*
import org.springframework.stereotype.Component

data class BomStepAssignment(
val bomProcessId: Long,
val bomProcessSeqNo: Long,
val stepName: String,
)

@Component
open class TraceBomLoader(
private val jdbcDao: JdbcDao,
private val sql: TraceSqlSupport,
private val lotResolver: TraceLotResolver,
) {
open fun itemCodeKey(code: String): String = code.trim().uppercase()

open fun loadBomTrace(
inventoryLotId: Long,
primaryStockInLineId: Long?,
itemId: Long,
lotLineIds: List<Long>,
): ItemLotTraceBomTrace {
val isFinishedGood = primaryStockInLineId?.let { hasJoStockIn(it) } == true
val isMaterial = lotLineIds.isNotEmpty() && hasJoStockOut(lotLineIds)

val direction = when {
isFinishedGood -> "FINISHED_GOOD"
isMaterial -> "MATERIAL"
else -> "UNKNOWN"
}

val downstream = if (isMaterial) loadBomDownstream(lotLineIds) else emptyList()
val upstream = if (isFinishedGood && primaryStockInLineId != null) {
loadBomUpstream(primaryStockInLineId)
} else {
emptyList()
}

val bomRecipe = when {
isFinishedGood && primaryStockInLineId != null -> loadBomRecipeForFgStockIn(primaryStockInLineId)
isMaterial && downstream.isNotEmpty() -> {
val joId = downstream.first().jobOrderId
if (joId != null) loadBomRecipeForJobOrder(joId) else emptyList()
}
else -> emptyList()
}

return ItemLotTraceBomTrace(
direction = direction,
downstream = downstream,
upstream = upstream,
bomRecipe = bomRecipe,
)
}

open fun hasJoStockIn(stockInLineId: Long): Boolean {
val query = """
SELECT COUNT(*) AS cnt FROM stock_in_line
WHERE id = :stockInLineId AND deleted = 0 AND jobOrderId IS NOT NULL
""".trimIndent()
val cnt = jdbcDao.queryForList(query, mapOf("stockInLineId" to stockInLineId))
.firstOrNull()?.let { (it["cnt"] as? Number)?.toLong() ?: 0L } ?: 0L
return cnt > 0
}

/** Resolve the JO finished-goods stock-in line for a material lot (semi-finished from another JO). */
open fun resolveJoProducedStockInLineId(
materialStockInLineId: Long?,
materialInventoryLotId: Long?,
silIdsByLot: Map<Long, List<Long>>,
): Long? {
materialStockInLineId?.takeIf { hasJoStockIn(it) }?.let { return it }
materialInventoryLotId?.let { lotId ->
silIdsByLot[lotId]
?.sortedDescending()
?.firstOrNull { hasJoStockIn(it) }
}?.let { return it }
if (materialInventoryLotId == null) return null
val query = """
SELECT id FROM stock_in_line
WHERE inventoryLotId = :inventoryLotId AND deleted = 0 AND jobOrderId IS NOT NULL
ORDER BY created DESC
LIMIT 1
""".trimIndent()
return jdbcDao.queryForList(query, mapOf("inventoryLotId" to materialInventoryLotId))
.firstOrNull()?.let { sql.longVal(it["id"]) }
}

open fun isJoProducedMaterial(
resolvedSilId: Long?,
materialInventoryLotId: Long?,
stockInOrigin: ItemLotTraceOrigin?,
): Boolean {
if (resolvedSilId != null && hasJoStockIn(resolvedSilId)) return true
if (stockInOrigin?.type.equals("JO", ignoreCase = true)) return true
return materialInventoryLotId?.let { lotResolver.resolveJobOrderIdForLot(it, null) != null } == true
}

open fun hasJoStockOut(lotLineIds: List<Long>): Boolean {
val query = """
SELECT COUNT(*) AS cnt
FROM stock_out_line sol
INNER JOIN stock_out so ON sol.stockOutId = so.id AND so.deleted = 0
WHERE sol.deleted = 0 AND sol.inventoryLotLineId IN (:lotLineIds)
AND so.type IN ('job', 'jo')
""".trimIndent()
val cnt = jdbcDao.queryForList(query, mapOf("lotLineIds" to lotLineIds))
.firstOrNull()?.let { (it["cnt"] as? Number)?.toLong() ?: 0L } ?: 0L
return cnt > 0
}

open fun loadBomDownstream(lotLineIds: List<Long>): List<ItemLotTraceBomDownstream> {
val query = """
SELECT DISTINCT
COALESCE(jo.code, '') AS jobOrderCode,
jo.id AS jobOrderId,
COALESCE(fg_stock_it.code, fg_bom_it.code, '') AS finishedItemCode,
COALESCE(fg_il.lotNo, fg_sil.lotNo, '') AS finishedLotNo,
fg_sil.id AS finishedStockInLineId,
COALESCE(fg_sil.acceptedQty, 0) AS fgQty,
COALESCE(fg_uc.udfudesc, '') AS fgUom,
COALESCE(sol.qty, 0) AS materialQtyUsed
FROM stock_out_line sol
INNER JOIN stock_out so ON sol.stockOutId = so.id AND so.deleted = 0
AND so.type IN ('job', 'jo')
INNER JOIN pick_order_line pol ON sol.pickOrderLineId = pol.id AND pol.deleted = 0
INNER JOIN pick_order po ON pol.poId = po.id AND po.deleted = 0 AND po.joId IS NOT NULL
INNER JOIN job_order jo ON po.joId = jo.id AND jo.deleted = 0
LEFT JOIN bom b ON jo.bomId = b.id AND b.deleted = 0
LEFT JOIN items fg_bom_it ON b.itemId = fg_bom_it.id AND fg_bom_it.deleted = 0
LEFT JOIN stock_in_line fg_sil ON fg_sil.jobOrderId = jo.id AND fg_sil.deleted = 0
LEFT JOIN inventory_lot fg_il ON fg_sil.inventoryLotId = fg_il.id AND fg_il.deleted = 0
LEFT JOIN items fg_stock_it ON fg_il.itemId = fg_stock_it.id AND fg_stock_it.deleted = 0
LEFT JOIN items fg_item ON fg_item.id = COALESCE(fg_stock_it.id, fg_bom_it.id)
LEFT JOIN item_uom fg_iu ON fg_item.id = fg_iu.itemId AND fg_iu.stockUnit = 1 AND fg_iu.deleted = 0
LEFT JOIN uom_conversion fg_uc ON fg_iu.uomId = fg_uc.id AND fg_uc.deleted = 0
WHERE sol.deleted = 0 AND sol.inventoryLotLineId IN (:lotLineIds)
ORDER BY jobOrderCode, finishedLotNo
""".trimIndent()
return jdbcDao.queryForList(query, mapOf("lotLineIds" to lotLineIds)).map { row ->
ItemLotTraceBomDownstream(
jobOrderCode = sql.str(row["jobOrderCode"]),
jobOrderId = sql.longVal(row["jobOrderId"]),
finishedItemCode = sql.str(row["finishedItemCode"]),
finishedLotNo = sql.str(row["finishedLotNo"]),
finishedStockInLineId = sql.longVal(row["finishedStockInLineId"]),
fgQty = sql.decimal(row["fgQty"]),
fgUom = sql.str(row["fgUom"]),
materialQtyUsed = sql.decimal(row["materialQtyUsed"]),
)
}
}

open fun loadBomUpstream(primaryStockInLineId: Long): List<ItemLotTraceBomUpstream> {
val query = """
SELECT
COALESCE(jo.code, '') AS jobOrderCode,
jo.id AS jobOrderId,
COALESCE(mat_it.code, '') AS materialItemCode,
COALESCE(mat_il.lotNo, mat_sil.lotNo, '') AS materialLotNo,
mat_sil.id AS materialStockInLineId,
COALESCE(sol.qty, 0) AS materialQty,
bm.qty AS bomQtyPerUnit
FROM stock_in_line fg_sil
INNER JOIN job_order jo ON fg_sil.jobOrderId = jo.id AND jo.deleted = 0
INNER JOIN pick_order po ON po.joId = jo.id AND po.deleted = 0
AND po.type IN ('jo', 'JOB_ORDER')
INNER JOIN pick_order_line pol ON pol.poId = po.id AND pol.deleted = 0
INNER JOIN stock_out_line sol ON sol.pickOrderLineId = pol.id AND sol.deleted = 0
INNER JOIN inventory_lot_line mat_ill ON sol.inventoryLotLineId = mat_ill.id AND mat_ill.deleted = 0
INNER JOIN inventory_lot mat_il ON mat_ill.inventoryLotId = mat_il.id AND mat_il.deleted = 0
LEFT JOIN stock_in_line mat_sil ON mat_il.stockInLineId = mat_sil.id AND mat_sil.deleted = 0
LEFT JOIN items mat_it ON mat_il.itemId = mat_it.id AND mat_it.deleted = 0
LEFT JOIN bom b ON jo.bomId = b.id AND b.deleted = 0
LEFT JOIN bom_material bm ON bm.bomId = b.id AND bm.itemId = mat_it.id AND bm.deleted = 0
WHERE fg_sil.id = :primaryStockInLineId AND fg_sil.deleted = 0
ORDER BY mat_it.code, mat_il.lotNo
""".trimIndent()
return jdbcDao.queryForList(query, mapOf("primaryStockInLineId" to primaryStockInLineId)).map { row ->
ItemLotTraceBomUpstream(
jobOrderCode = sql.str(row["jobOrderCode"]),
jobOrderId = sql.longVal(row["jobOrderId"]),
materialItemCode = sql.str(row["materialItemCode"]),
materialLotNo = sql.str(row["materialLotNo"]),
materialStockInLineId = sql.longVal(row["materialStockInLineId"]),
materialQty = sql.decimal(row["materialQty"]),
bomQtyPerUnit = row["bomQtyPerUnit"]?.let { sql.decimal(it) },
)
}
}

open fun loadBomRecipeForFgStockIn(primaryStockInLineId: Long): List<ItemLotTraceBomRecipeLine> {
val joId = getJobOrderIdForFgStockIn(primaryStockInLineId)
return if (joId != null) loadBomRecipeForJobOrder(joId) else emptyList()
}

open fun loadBomRecipeForJobOrder(jobOrderId: Long): List<ItemLotTraceBomRecipeLine> {
val stepByItem = loadBomMaterialStepAssignments(jobOrderId)
val query = """
SELECT
COALESCE(mat_it.code, '') AS materialItemCode,
COALESCE(bm.itemName, mat_it.name, '') AS materialItemName,
COALESCE(bm.qty, 0) AS qtyPerUnit,
COALESCE(bm.uomName, uc.udfudesc, '') AS uom
FROM job_order jo
INNER JOIN bom b ON jo.bomId = b.id AND b.deleted = 0
INNER JOIN bom_material bm ON bm.bomId = b.id AND bm.deleted = 0
LEFT JOIN items mat_it ON bm.itemId = mat_it.id AND mat_it.deleted = 0
LEFT JOIN uom_conversion uc ON bm.uomId = uc.id
WHERE jo.id = :jobOrderId AND jo.deleted = 0
ORDER BY mat_it.code
""".trimIndent()
return jdbcDao.queryForList(query, mapOf("jobOrderId" to jobOrderId)).map { row ->
val itemCode = sql.str(row["materialItemCode"])
val assignment = stepByItem[itemCodeKey(itemCode)]
ItemLotTraceBomRecipeLine(
materialItemCode = itemCode,
materialItemName = sql.str(row["materialItemName"]),
qtyPerUnit = sql.decimal(row["qtyPerUnit"]),
uom = sql.str(row["uom"]),
bomProcessId = assignment?.bomProcessId,
bomProcessSeqNo = assignment?.bomProcessSeqNo,
assignedStepName = assignment?.stepName ?: "",
)
}
}

open fun loadBomMaterialStepAssignments(jobOrderId: Long): Map<String, BomStepAssignment> {
val query = """
SELECT
COALESCE(it.code, '') AS materialItemCode,
bp.id AS bomProcessId,
bp.seqNo AS bomProcessSeqNo,
COALESCE(p.name, bp.description, '') AS stepName
FROM job_order jo
INNER JOIN bom b ON jo.bomId = b.id AND b.deleted = 0
INNER JOIN bom_material bm ON bm.bomId = b.id AND bm.deleted = 0
INNER JOIN bom_process_material bpm ON bpm.bomMaterialId = bm.id AND bpm.deleted = 0
INNER JOIN bom_process bp ON bp.id = bpm.bomProcessId AND bp.deleted = 0
LEFT JOIN process p ON p.id = bp.processId AND p.deleted = 0
LEFT JOIN items it ON bm.itemId = it.id AND it.deleted = 0
WHERE jo.id = :jobOrderId AND jo.deleted = 0
ORDER BY bp.seqNo, it.code
""".trimIndent()
val result = linkedMapOf<String, BomStepAssignment>()
jdbcDao.queryForList(query, mapOf("jobOrderId" to jobOrderId)).forEach { row ->
val key = itemCodeKey(sql.str(row["materialItemCode"]))
if (key.isEmpty() || key in result) return@forEach
val bomProcessId = sql.longVal(row["bomProcessId"]) ?: return@forEach
result[key] = BomStepAssignment(
bomProcessId = bomProcessId,
bomProcessSeqNo = sql.longVal(row["bomProcessSeqNo"]) ?: 0L,
stepName = sql.str(row["stepName"]),
)
}
return result
}

open fun loadStepMaterialsByJobOrder(jobOrderId: Long): Map<Long, List<ItemLotTraceStepMaterialRef>> {
val query = """
SELECT
bp.id AS bomProcessId,
COALESCE(it.code, '') AS materialItemCode,
COALESCE(bm.itemName, it.name, '') AS materialItemName,
COALESCE(bm.qty, 0) AS qtyPerUnit,
COALESCE(bm.uomName, uc.udfudesc, '') AS uom
FROM job_order jo
INNER JOIN bom b ON jo.bomId = b.id AND b.deleted = 0
INNER JOIN bom_process bp ON bp.bomId = b.id AND bp.deleted = 0
INNER JOIN bom_process_material bpm ON bpm.bomProcessId = bp.id AND bpm.deleted = 0
INNER JOIN bom_material bm ON bm.id = bpm.bomMaterialId AND bm.deleted = 0
LEFT JOIN items it ON bm.itemId = it.id AND it.deleted = 0
LEFT JOIN uom_conversion uc ON bm.uomId = uc.id
WHERE jo.id = :jobOrderId AND jo.deleted = 0
ORDER BY bp.seqNo, it.code
""".trimIndent()
val grouped = linkedMapOf<Long, MutableList<ItemLotTraceStepMaterialRef>>()
jdbcDao.queryForList(query, mapOf("jobOrderId" to jobOrderId)).forEach { row ->
val bomProcessId = sql.longVal(row["bomProcessId"]) ?: return@forEach
val itemCode = sql.str(row["materialItemCode"])
val list = grouped.getOrPut(bomProcessId) { mutableListOf() }
if (list.any { it.materialItemCode.equals(itemCode, ignoreCase = true) }) return@forEach
list.add(
ItemLotTraceStepMaterialRef(
materialItemCode = itemCode,
materialItemName = sql.str(row["materialItemName"]),
qtyPerUnit = sql.decimal(row["qtyPerUnit"]),
uom = sql.str(row["uom"]),
),
)
}
return grouped
}

open fun getJobOrderIdForFgStockIn(primaryStockInLineId: Long): Long? {
val query = """
SELECT jo.id AS jobOrderId FROM stock_in_line sil
INNER JOIN job_order jo ON sil.jobOrderId = jo.id AND jo.deleted = 0
WHERE sil.id = :primaryStockInLineId AND sil.deleted = 0
""".trimIndent()
return jdbcDao.queryForList(query, mapOf("primaryStockInLineId" to primaryStockInLineId))
.firstOrNull()?.let { sql.longVal(it["jobOrderId"]) }
}
}

+ 112
- 0
src/main/java/com/ffii/fpsms/modules/stock/trace/TraceExtendedLoader.kt Datei anzeigen

@@ -0,0 +1,112 @@
package com.ffii.fpsms.modules.stock.trace

import com.ffii.core.support.JdbcDao
import com.ffii.fpsms.modules.stock.web.model.*
import org.springframework.stereotype.Component

@Component
open class TraceExtendedLoader(
private val jdbcDao: JdbcDao,
private val sql: TraceSqlSupport,
) {
open fun loadFailEvents(inventoryLotId: Long): List<ItemLotTraceFailEvent> {
val query = """
SELECT
f.id AS failId,
COALESCE(f.type, '') AS failType,
COALESCE(f.category, '') AS category,
COALESCE(f.qty, 0) AS qty,
COALESCE(u.name, '') AS handlerName,
f.recordDate AS recordDate,
COALESCE(po.code, '') AS pickOrderCode,
po.id AS pickOrderId,
COALESCE(wh.code, '') AS warehouseCode
FROM fail_inventory_lot_line f
LEFT JOIN user u ON f.handerId = u.id AND u.deleted = 0
LEFT JOIN stock_out_line sol ON f.stock_out_line_id = sol.id AND sol.deleted = 0
LEFT JOIN pick_order_line pol ON sol.pickOrderLineId = pol.id AND pol.deleted = 0
LEFT JOIN pick_order po ON pol.poId = po.id AND po.deleted = 0
LEFT JOIN inventory_lot_line ill ON sol.inventoryLotLineId = ill.id AND ill.deleted = 0
LEFT JOIN warehouse wh ON ill.warehouseId = wh.id AND wh.deleted = 0
WHERE f.deleted = 0 AND f.Lot_id = :inventoryLotId
ORDER BY recordDate DESC, f.id DESC
""".trimIndent()
return jdbcDao.queryForList(query, mapOf("inventoryLotId" to inventoryLotId)).map { row ->
ItemLotTraceFailEvent(
failId = sql.longVal(row["failId"]) ?: 0L,
failType = sql.str(row["failType"]),
category = sql.str(row["category"]),
qty = sql.decimal(row["qty"]),
handlerName = sql.str(row["handlerName"]),
recordDate = sql.formatDate(row["recordDate"]),
pickOrderCode = sql.str(row["pickOrderCode"]),
pickOrderId = sql.longVal(row["pickOrderId"]),
warehouseCode = sql.str(row["warehouseCode"]),
)
}
}

open fun loadLotRelations(
inventoryLotId: Long,
itemId: Long,
lotNo: String,
): List<ItemLotTraceLotRelation> {
val query = """
SELECT
'REPACK_SIBLING' AS relationType,
il2.id AS inventoryLotId,
COALESCE(il2.lotNo, '') AS lotNo,
COALESCE(it.code, '') AS itemCode,
COALESCE(it.name, '') AS itemName,
COALESCE(sil2.acceptedQty, 0) AS qty,
COALESCE(sil_src.productLotNo, '') AS productLotNo,
COALESCE(sil2.created, sil2.modified, sil2.receiptDate) AS timestamp
FROM inventory_lot il
INNER JOIN stock_in_line sil_src ON il.stockInLineId = sil_src.id AND sil_src.deleted = 0
INNER JOIN stock_in_line sil2 ON sil2.deleted = 0
AND sil2.itemId = il.itemId
AND sil2.productLotNo IS NOT NULL
AND TRIM(sil2.productLotNo) <> ''
AND sil2.productLotNo = sil_src.productLotNo
INNER JOIN inventory_lot il2 ON sil2.inventoryLotId = il2.id AND il2.deleted = 0
INNER JOIN items it ON il2.itemId = it.id AND it.deleted = 0
WHERE il.id = :inventoryLotId
AND il.deleted = 0
AND il2.id <> :inventoryLotId
AND COALESCE(il2.lotNo, '') <> :lotNo
AND sil2.purchaseOrderId IS NULL
AND (
sil_src.purchaseOrderId IS NULL
OR sil2.stockTransferId IS NOT NULL
OR EXISTS (
SELECT 1
FROM stock_transfer_record tr
INNER JOIN stock_out_line sol ON tr.stockOutLineId = sol.id AND sol.deleted = 0
INNER JOIN inventory_lot_line ill ON sol.inventoryLotLineId = ill.id AND ill.deleted = 0
WHERE tr.stockInLineId = sil2.id
AND ill.inventoryLotId = il.id
)
OR (
sil2.jobOrderId IS NOT NULL
AND sil2.jobOrderId = sil_src.jobOrderId
)
)
ORDER BY timestamp, il2.lotNo
""".trimIndent()
return jdbcDao.queryForList(
query,
mapOf("inventoryLotId" to inventoryLotId, "lotNo" to lotNo),
).map { row ->
ItemLotTraceLotRelation(
relationType = sql.str(row["relationType"]),
inventoryLotId = sql.longVal(row["inventoryLotId"]),
lotNo = sql.str(row["lotNo"]),
itemCode = sql.str(row["itemCode"]),
itemName = sql.str(row["itemName"]),
qty = sql.decimal(row["qty"]),
productLotNo = sql.str(row["productLotNo"]),
timestamp = sql.formatDateTime(row["timestamp"]),
)
}.distinctBy { "${it.itemCode}:${it.lotNo}" }
}
}

+ 855
- 0
src/main/java/com/ffii/fpsms/modules/stock/trace/TraceGraphNodeBuilder.kt Datei anzeigen

@@ -0,0 +1,855 @@
package com.ffii.fpsms.modules.stock.trace

import com.ffii.fpsms.modules.stock.web.model.ItemLotTraceJoPrelude
import com.ffii.fpsms.modules.stock.web.model.ItemLotTraceMaterialInput
import com.ffii.fpsms.modules.stock.web.model.ItemLotTraceQcResult
import com.ffii.fpsms.modules.stock.web.model.ItemLotTraceResponse
import java.math.BigDecimal
import java.time.LocalDate
import java.time.LocalDateTime
import java.time.format.DateTimeFormatter

internal data class TraceGraphNode(
val id: String,
val kind: String,
val timestamp: String?,
val sortKey: Long,
val refCode: String? = null,
val refId: Long? = null,
val traceLotNo: String? = null,
val traceItemCode: String? = null,
val consoCode: String? = null,
val jobOrderCode: String? = null,
val bomProcessId: Long? = null,
val bomProcessSeqNo: Long? = null,
val feedsProductionScopeLotNo: String? = null,
var doGroupId: String? = null,
val refType: String? = null,
val warehouseCode: String? = null,
val transferFromWarehouse: String? = null,
val transferToWarehouse: String? = null,
val replenishmentStockOutLineId: Long? = null,
)

internal object TraceGraphNodeBuilder {
private val MOVEMENT_REF_TYPES_IN_DEDICATED_ARRAYS = setOf("TRANSFER", "ADJ", "OPEN", "TKE")
private val DATE_TIME = DateTimeFormatter.ofPattern("yyyy-MM-dd HH:mm:ss")
private val DATE_ONLY = DateTimeFormatter.ISO_LOCAL_DATE

fun buildNodes(data: ItemLotTraceResponse): List<TraceGraphNode> {
val hasPrelude = data.joPrelude != null
val hasScrap = data.productionSteps.any { (it.scrapQty + it.defectQty) > BigDecimal.ZERO }
val hasMaterialProduction =
data.joPrelude?.materialInputs?.let { materialInputsHaveProduction(it) } == true
val hasProduction =
data.productionSteps.isNotEmpty() ||
data.byproductLots.isNotEmpty() ||
hasScrap ||
hasMaterialProduction

val preludeNodes = data.joPrelude?.let { buildJoPreludeGraphNodes(it) } ?: emptyList()
val productionNodes =
if (hasProduction) {
buildProductionGraphNodes(
data.productionSteps,
data.byproductLots,
data.lot.uom,
)
} else {
emptyList()
}
val extendedNodes = buildExtendedTraceGraphNodes(data)
val fgNodes = buildTraceGraphNodes(data)
val locationBlockNodes = buildLocationBlockGraphNodes(data.locationBlocks)

return (preludeNodes + productionNodes + extendedNodes + fgNodes + locationBlockNodes).sortedWith(
compareBy<TraceGraphNode> { it.sortKey }.thenBy { it.id },
)
}

internal fun materialInputsHaveProduction(inputs: List<ItemLotTraceMaterialInput>): Boolean =
inputs.any { m ->
m.productionSteps.isNotEmpty() ||
(m.nestedJoPrelude?.materialInputs?.let { materialInputsHaveProduction(it) } == true)
}

private fun buildTraceGraphNodes(data: ItemLotTraceResponse): List<TraceGraphNode> {
val nodes = mutableListOf<TraceGraphNode>()
var seq = 0
val stockUom = data.lot.uom

data.purchaseEvents.forEachIndexed { i, p ->
nodes +=
TraceGraphNode(
id = "purchase-$i-${p.purchaseOrderLineId}",
kind = "PURCHASE",
timestamp = p.orderDate,
sortKey = parseSortKey(p.orderDate, seq++),
refCode = p.purchaseOrderCode,
refId = p.purchaseOrderId,
)
}

data.movements.forEachIndexed { i, m ->
val refType = norm(m.refType)
if (MOVEMENT_REF_TYPES_IN_DEDICATED_ARRAYS.contains(refType)) return@forEachIndexed
if (isStockTakeLedgerMovement(m.movementType)) return@forEachIndexed
if (refType == "DO" && data.doDeliveries.isNotEmpty()) return@forEachIndexed
if (refType == "RETURN" && data.returnEvents.isNotEmpty()) return@forEachIndexed

val kind =
when {
m.direction == "IN" && refType == "PO" -> "RECEIPT"
m.direction == "IN" && refType == "JO" -> "JO_CREATED"
m.direction == "IN" -> "IN"
m.direction == "OUT" && refType == "DO" -> "DO_OUT"
m.direction == "OUT" && refType == "JO_PICK" -> "JO_OUT"
m.direction == "OUT" && refType == "PO_PICK" -> "PO_OUT"
else -> "OUT"
}
nodes +=
TraceGraphNode(
id = "mov-$i-${m.refCode}-${m.timestamp}",
kind = kind,
timestamp = m.timestamp,
sortKey = parseSortKey(m.timestamp, seq++),
refCode = m.refCode,
refId = m.refId,
)
}

buildQcNodes(data.qcResults, seq).let { (qcNodes, nextSeq) ->
nodes += qcNodes
seq = nextSeq
}

data.putawayEvents.forEachIndexed { i, p ->
if (norm(p.refType) == "TRANSFER") return@forEachIndexed
nodes +=
TraceGraphNode(
id = "putaway-$i-${p.inventoryLotLineId}-${p.timestamp}",
kind = "PUTAWAY",
timestamp = p.timestamp,
sortKey = parseSortKey(p.timestamp, seq++),
refCode = p.refCode,
refId = p.refId,
refType = p.refType,
warehouseCode = p.warehouseCode,
)
}

data.stockTakeEvents.forEachIndexed { e, st ->
nodes +=
TraceGraphNode(
id = "st-$e-${st.stockTakeCode}-${st.timestamp}",
kind = "STOCK_TAKE",
timestamp = st.timestamp,
sortKey = parseSortKey(st.timestamp, seq++),
refCode = st.stockTakeCode,
)
}

data.adjustments.forEachIndexed { i, a ->
if (norm(a.adjustmentType) == "TKE" && data.stockTakeEvents.isNotEmpty()) return@forEachIndexed
nodes +=
TraceGraphNode(
id = "adj-$i-${a.refCode}-${a.timestamp}-${a.direction}",
kind = "ADJUSTMENT",
timestamp = a.timestamp,
sortKey = parseSortKey(a.timestamp, seq++),
refCode = a.refCode,
)
}

data.transfers.forEachIndexed { i, tr ->
nodes +=
TraceGraphNode(
id = "tr-$i-${tr.transferCode}-${tr.timestamp}",
kind = "TRANSFER",
timestamp = tr.timestamp,
sortKey = parseSortKey(tr.timestamp, seq++),
refCode = tr.transferCode,
transferFromWarehouse = tr.fromWarehouse,
transferToWarehouse = tr.toWarehouse,
)
}

val totalAvailable = data.warehouseLines.fold(BigDecimal.ZERO) { acc, w -> acc + w.availableQty }
val expiry = data.lot.expiryDate
if (!expiry.isNullOrBlank()) {
runCatching { LocalDate.parse(expiry, DATE_ONLY) }.getOrNull()?.let { expDate ->
if (expDate.isBefore(LocalDate.now())) {
nodes +=
TraceGraphNode(
id = "terminal-expired-${data.lot.lotNo}",
kind = "EXPIRED",
timestamp = expiry,
sortKey = parseExpirySortKey(expiry, seq + 1_000_000),
)
}
}
}
if (totalAvailable <= BigDecimal.ZERO && data.warehouseLines.isNotEmpty()) {
val lastTs =
data.movements.firstOrNull()?.timestamp
?: data.outboundUsage.firstOrNull()?.timestamp
?: data.lot.stockInDate
nodes +=
TraceGraphNode(
id = "terminal-depleted-${data.lot.lotNo}",
kind = "DEPLETED",
timestamp = lastTs,
sortKey = parseSortKey(lastTs, seq + 2_000_000),
)
}

return nodes
}

private fun buildQcNodes(
qcResults: List<ItemLotTraceQcResult>,
seqStart: Int,
): Pair<List<TraceGraphNode>, Int> {
val nodes = mutableListOf<TraceGraphNode>()
var seq = seqStart
groupQcResultsBySession(qcResults).forEachIndexed { i, group ->
val first = group.firstOrNull() ?: return@forEachIndexed
nodes +=
TraceGraphNode(
id = "qc-$i-${first.stockInLineId}-${first.created}",
kind = "QC",
timestamp = first.created,
sortKey = parseSortKey(first.created, seq++),
)
}
return nodes to seq
}

private fun buildProductionGraphNodes(
productionSteps: List<com.ffii.fpsms.modules.stock.web.model.ItemLotTraceProductionStep>,
byproductLots: List<com.ffii.fpsms.modules.stock.web.model.ItemLotTraceByproductLot>,
lotUom: String,
): List<TraceGraphNode> {
val nodes = mutableListOf<TraceGraphNode>()
var seq = 0

productionSteps.forEach { step ->
val ts = step.endTime ?: step.startTime
nodes +=
TraceGraphNode(
id = "prod-step-${step.processLineId}",
kind = "PRODUCTION_STEP",
timestamp = ts,
sortKey = parseSortKey(ts, seq++),
refCode = null,
bomProcessId = step.bomProcessId,
bomProcessSeqNo = step.seqNo,
)
if (step.scrapQty > BigDecimal.ZERO) {
nodes +=
TraceGraphNode(
id = "scrap-${step.processLineId}",
kind = "SCRAP",
timestamp = ts,
sortKey = parseSortKey(ts, seq++) + 1,
bomProcessId = step.bomProcessId,
bomProcessSeqNo = step.seqNo,
)
}
if (step.defectQty > BigDecimal.ZERO) {
nodes +=
TraceGraphNode(
id = "defect-${step.processLineId}",
kind = "DEFECT",
timestamp = ts,
sortKey = parseSortKey(ts, seq++) + 1,
bomProcessId = step.bomProcessId,
bomProcessSeqNo = step.seqNo,
)
}
}

byproductLots.forEachIndexed { i, bp ->
nodes +=
TraceGraphNode(
id = "byproduct-${bp.inventoryLotId ?: i}-${bp.lotNo}",
kind = "BYPRODUCT",
timestamp = bp.producedAt,
sortKey = parseSortKey(bp.producedAt, seq++),
refCode = bp.jobOrderCode,
refId = bp.jobOrderId,
traceLotNo = bp.lotNo,
traceItemCode = bp.itemCode,
)
}

return nodes
}

private fun buildExtendedTraceGraphNodes(data: ItemLotTraceResponse): List<TraceGraphNode> {
val nodes = mutableListOf<TraceGraphNode>()
var seq = 0
val stockUom = data.lot.uom

data.openMovements.forEach { o ->
nodes +=
TraceGraphNode(
id = "open-${o.stockInLineId}",
kind = "OPEN",
timestamp = o.timestamp,
sortKey = parseSortKey(o.timestamp, seq++),
refCode = o.refCode,
warehouseCode = o.warehouseCode,
)
}

data.failEvents.forEach { f ->
nodes +=
TraceGraphNode(
id = "fail-${f.failId}",
kind = "FAIL",
timestamp = f.recordDate,
sortKey = parseSortKey(f.recordDate, seq++),
refCode = f.pickOrderCode,
refId = f.pickOrderId,
consoCode = f.pickOrderCode,
warehouseCode = f.warehouseCode,
)
}

data.doDeliveries.forEach { d ->
nodes +=
TraceGraphNode(
id = "do-${d.stockOutLineId}",
kind = "DO_OUT",
timestamp = d.timestamp,
sortKey = parseSortKey(d.timestamp, seq++),
refCode = d.deliveryOrderCode.ifBlank { d.pickOrderCode },
refId = d.deliveryOrderId ?: d.pickOrderId,
consoCode = d.consoCode.ifBlank { d.pickOrderCode },
)
}

data.replenishmentEvents.forEach { r ->
nodes +=
TraceGraphNode(
id = "replenish-${r.replenishmentId}",
kind = "REPLENISHMENT_CREATED",
timestamp = r.timestamp,
sortKey = parseSortKey(r.timestamp, seq++),
refCode = r.sourceDoCode.ifBlank { r.replenishmentCode },
refId = r.sourceDoId,
replenishmentStockOutLineId = r.stockOutLineId,
)
}

data.returnEvents.forEach { r ->
nodes +=
TraceGraphNode(
id = "return-${r.stockOutLineId}-${r.timestamp}",
kind = "RETURN",
timestamp = r.timestamp,
sortKey = parseSortKey(r.timestamp, seq++),
refCode = r.refCode,
warehouseCode = r.warehouseCode,
)
}

data.lotRelations.forEachIndexed { i, rel ->
nodes +=
TraceGraphNode(
id = "repack-${rel.inventoryLotId ?: i}-${rel.lotNo}",
kind = "REPACK",
timestamp = rel.timestamp,
sortKey = parseSortKey(rel.timestamp, seq++),
traceLotNo = rel.lotNo,
traceItemCode = rel.itemCode,
)
}

return nodes
}

private data class PreludeBuildContext(
val seenPurchaseLines: MutableSet<Long> = mutableSetOf(),
val seenInboundLots: MutableSet<Long> = mutableSetOf(),
val seenReceiptLots: MutableSet<Long> = mutableSetOf(),
val seenQcKeys: MutableSet<String> = mutableSetOf(),
val seenPutawayKeys: MutableSet<String> = mutableSetOf(),
var seq: Int = 0,
)

private data class LotPickRef(
val pickOrderCode: String,
val pickOrderId: Long?,
val consoCode: String,
)

private fun buildJoPreludeGraphNodes(joPrelude: ItemLotTraceJoPrelude): List<TraceGraphNode> {
val ctx = PreludeBuildContext()
val lotPicks = buildLotPickOrderMap(joPrelude.materialInputs)
val chainNodes = buildMaterialInputChainNodes(joPrelude.materialInputs, lotPicks, ctx)
val pickNodes =
joPrelude.materialInputs.mapIndexed { i, m ->
createMaterialPickNode(m, i, ctx)
}
return (chainNodes + pickNodes).sortedWith(compareBy<TraceGraphNode> { it.sortKey }.thenBy { it.id })
}

private fun buildLotPickOrderMap(
materialInputs: List<ItemLotTraceMaterialInput>,
): Map<String, List<LotPickRef>> {
val map = mutableMapOf<String, MutableList<LotPickRef>>()
materialInputs.forEach { m ->
val code = m.pickOrderCode.trim()
if (code.isEmpty()) return@forEach
val key = materialLotKey(m.materialItemCode, m.materialLotNo)
val list = map.getOrPut(key) { mutableListOf() }
if (list.none { it.pickOrderCode == code }) {
list +=
LotPickRef(
pickOrderCode = code,
pickOrderId = m.pickOrderId,
consoCode = m.consoCode,
)
}
val nestedInputs = m.nestedJoPrelude?.materialInputs ?: emptyList()
if (nestedInputs.isNotEmpty()) {
mergeLotPickMaps(map, buildLotPickOrderMap(nestedInputs))
}
}
return map
}

private fun mergeLotPickMaps(
target: MutableMap<String, MutableList<LotPickRef>>,
source: Map<String, List<LotPickRef>>,
) {
source.forEach { (key, picks) ->
val list = target.getOrPut(key) { mutableListOf() }
picks.forEach { p ->
if (list.none { it.pickOrderCode == p.pickOrderCode }) {
list += p
}
}
}
}

private fun buildMaterialInputChainNodes(
materialInputs: List<ItemLotTraceMaterialInput>,
lotPicks: Map<String, List<LotPickRef>>,
ctx: PreludeBuildContext,
): List<TraceGraphNode> {
val nodes = mutableListOf<TraceGraphNode>()
materialInputs.forEach { m ->
val joProduced = isJoProducedMaterial(m)
val nestedInputs = m.nestedJoPrelude?.materialInputs ?: emptyList()

if (joProduced && m.nestedJoPrelude != null) {
buildNestedJoCreatedNode(m, ctx)?.let { nodes += it }
}

if (joProduced && nestedInputs.isNotEmpty()) {
val nestedLotPicks = buildLotPickOrderMap(nestedInputs)
nodes += buildMaterialInputChainNodes(nestedInputs, nestedLotPicks, ctx)
nestedInputs.forEachIndexed { ni, nm ->
nodes += createMaterialPickNode(nm, ni, ctx, m.materialLotNo)
}
}

if (joProduced && m.productionSteps.isNotEmpty()) {
nodes += buildMaterialProductionNodes(m, ctx)
}

nodes += buildMaterialStockInPreludeNodes(m, lotPicks, ctx)
}
return nodes
}

private fun buildNestedJoCreatedNode(
m: ItemLotTraceMaterialInput,
ctx: PreludeBuildContext,
): TraceGraphNode? {
val nested = m.nestedJoPrelude ?: return null
val jo = nested.jobOrder
val joCode = jo.jobOrderCode.trim()
if (joCode.isEmpty()) return null

val nestedInputs = nested.materialInputs
val nestedPickTs = nestedInputs.mapNotNull { it.pickedAt?.trim() }.firstOrNull()
val nestedProdTs = m.productionSteps.mapNotNull { it.startTime?.trim() }.firstOrNull()
val ts =
jo.createdAt?.trim()?.takeIf { it.isNotEmpty() }
?: jo.planStart?.trim()?.takeIf { it.isNotEmpty() }
?: nestedPickTs
?: nestedProdTs
?: m.stockInOrigin?.receiptDate?.trim()?.takeIf { it.isNotEmpty() }

return TraceGraphNode(
id = "mat-jo-created-${jo.jobOrderId}-${m.materialLotNo}",
kind = "JO_CREATED",
timestamp = ts,
sortKey = parseSortKey(ts, ctx.seq++),
refType = "JO",
refCode = jo.jobOrderCode,
refId = jo.jobOrderId,
traceLotNo = m.materialLotNo,
traceItemCode = m.materialItemCode,
)
}

private fun buildMaterialProductionNodes(
m: ItemLotTraceMaterialInput,
ctx: PreludeBuildContext,
): List<TraceGraphNode> {
if (m.productionSteps.isEmpty()) return emptyList()
val matUom = m.materialUom.trim()
return buildProductionGraphNodes(m.productionSteps, emptyList(), matUom).map { node ->
node.copy(
id = "mat-prod-${m.materialLotNo}-${node.id}",
traceLotNo = m.materialLotNo,
traceItemCode = m.materialItemCode,
)
}
}

private fun buildMaterialStockInPreludeNodes(
m: ItemLotTraceMaterialInput,
lotPicks: Map<String, List<LotPickRef>>,
ctx: PreludeBuildContext,
): List<TraceGraphNode> {
val nodes = mutableListOf<TraceGraphNode>()
val lotId = m.materialInventoryLotId
val origin = m.stockInOrigin
val matUom = m.materialUom.trim()

m.purchaseEvents.forEach { p ->
if (ctx.seenPurchaseLines.contains(p.purchaseOrderLineId)) return@forEach
ctx.seenPurchaseLines += p.purchaseOrderLineId
nodes +=
TraceGraphNode(
id = "mat-purchase-${p.purchaseOrderLineId}-${m.materialLotNo}",
kind = "PURCHASE",
timestamp = p.orderDate,
sortKey = parseSortKey(p.orderDate, ctx.seq++),
refCode = p.purchaseOrderCode,
refId = p.purchaseOrderId,
traceLotNo = m.materialLotNo,
traceItemCode = m.materialItemCode,
)
}

if (origin != null && lotId != null) {
val originType = origin.type.trim().uppercase()
if (originType == "PO" && !ctx.seenReceiptLots.contains(lotId)) {
ctx.seenReceiptLots += lotId
nodes +=
TraceGraphNode(
id = "mat-receipt-$lotId-${origin.stockInLineId}",
kind = "RECEIPT",
timestamp = origin.receiptDate,
sortKey = parseSortKey(origin.receiptDate, ctx.seq++),
refCode = origin.refCode,
refId = origin.refId,
traceLotNo = m.materialLotNo,
traceItemCode = m.materialItemCode,
)
} else if (originType != "PO" && !ctx.seenInboundLots.contains(lotId)) {
val skipJoOriginBecauseNested =
originType == "JO" &&
!m.nestedJoPrelude?.jobOrder?.jobOrderCode?.trim().isNullOrEmpty()
if (!skipJoOriginBecauseNested) {
ctx.seenInboundLots += lotId
val isJoOrigin = originType == "JO"
nodes +=
TraceGraphNode(
id = "mat-in-$lotId-${origin.stockInLineId}",
kind = if (isJoOrigin) "JO_CREATED" else "MATERIAL_IN",
timestamp = origin.receiptDate,
sortKey = parseSortKey(origin.receiptDate, ctx.seq++),
refCode = origin.refCode,
refId = origin.refId,
traceLotNo = m.materialLotNo,
traceItemCode = m.materialItemCode,
)
}
}
}

groupQcResultsBySession(m.qcResults).forEach { group ->
val first = group.firstOrNull() ?: return@forEach
val key = "${first.stockInLineId}-${first.created}"
if (ctx.seenQcKeys.contains(key)) return@forEach
ctx.seenQcKeys += key
nodes +=
TraceGraphNode(
id = "mat-qc-$key",
kind = "MATERIAL_QC",
timestamp = first.created,
sortKey = parseSortKey(first.created, ctx.seq++),
traceLotNo = m.materialLotNo,
traceItemCode = m.materialItemCode,
)
}

m.putawayEvents.forEach { p ->
val key = "${p.inventoryLotLineId}-${p.timestamp}"
if (ctx.seenPutawayKeys.contains(key)) return@forEach
if (norm(p.refType) == "TRANSFER") return@forEach
ctx.seenPutawayKeys += key
nodes +=
TraceGraphNode(
id = "mat-putaway-${p.inventoryLotLineId}-${p.timestamp}",
kind = "PUTAWAY",
timestamp = p.timestamp,
sortKey = parseSortKey(p.timestamp, ctx.seq++),
refCode = p.refCode,
refId = p.refId,
traceLotNo = m.materialLotNo,
traceItemCode = m.materialItemCode,
)
}

return nodes
}

private fun createMaterialPickNode(
m: ItemLotTraceMaterialInput,
index: Int,
ctx: PreludeBuildContext,
feedsProductionScopeLotNo: String? = null,
): TraceGraphNode =
TraceGraphNode(
id = "mat-pick-$index-${m.pickOrderId}-${m.materialLotNo}-${m.pickedAt}",
kind = "MATERIAL_PICK",
timestamp = m.pickedAt,
sortKey = parseSortKey(m.pickedAt, ctx.seq++),
refCode = m.pickOrderCode,
refId = m.pickOrderId,
traceLotNo = m.materialLotNo,
traceItemCode = m.materialItemCode,
consoCode = m.consoCode,
jobOrderCode = m.jobOrderCode.trim().ifEmpty { null },
bomProcessId = m.bomProcessId,
bomProcessSeqNo = m.bomProcessSeqNo,
feedsProductionScopeLotNo = feedsProductionScopeLotNo,
)

private fun isJoProducedMaterial(m: ItemLotTraceMaterialInput): Boolean =
m.productionSteps.isNotEmpty() ||
m.nestedJoPrelude != null ||
m.stockInOrigin?.type?.trim()?.uppercase() == "JO"

private fun groupQcResultsBySession(results: List<ItemLotTraceQcResult>): List<List<ItemLotTraceQcResult>> {
val map = linkedMapOf<String, MutableList<ItemLotTraceQcResult>>()
results.forEach { q ->
val key = "${q.stockInLineId}:${q.created ?: ""}"
map.getOrPut(key) { mutableListOf() } += q
}
return map.values.toList()
}

/**
* Builds trace graph nodes for all alternate location blocks, tagged with warehouseCode
* so the frontend can visually distinguish multi-location flows.
*/
private fun buildLocationBlockGraphNodes(
locationBlocks: List<com.ffii.fpsms.modules.stock.web.model.ItemLotTraceLocationBlock>,
): List<TraceGraphNode> {
if (locationBlocks.isEmpty()) return emptyList()
val nodes = mutableListOf<TraceGraphNode>()
var seq = 0

locationBlocks.forEach { block ->
val whCode = block.warehouseLines.firstOrNull()?.warehouseCode ?: ""
val locPrefix = "loc-${block.inventoryLotId}"

// Purchase events
block.purchaseEvents.forEachIndexed { i, p ->
nodes += TraceGraphNode(
id = "$locPrefix-purchase-$i-${p.purchaseOrderLineId}",
kind = "PURCHASE", timestamp = p.orderDate,
sortKey = parseSortKey(p.orderDate, seq++),
refCode = p.purchaseOrderCode, refId = p.purchaseOrderId,
warehouseCode = whCode,
)
}

// Origins (receipts)
block.origins.forEachIndexed { i, o ->
val kind = when (norm(o.type)) {
"PO" -> "RECEIPT"
"JO" -> "JO_CREATED"
"OPEN" -> "OPEN"
else -> "IN"
}
nodes += TraceGraphNode(
id = "$locPrefix-origin-$i-${o.stockInLineId}",
kind = kind, timestamp = o.receiptDate,
sortKey = parseSortKey(o.receiptDate, seq++),
refCode = o.refCode, refId = o.refId,
warehouseCode = whCode,
)
}

// QC results
val qcBySession = groupQcResultsBySession(block.qcResults)
qcBySession.forEachIndexed { i, group ->
val first = group.firstOrNull() ?: return@forEachIndexed
nodes += TraceGraphNode(
id = "$locPrefix-qc-$i-${first.stockInLineId}",
kind = "QC", timestamp = first.created,
sortKey = parseSortKey(first.created, seq++),
warehouseCode = whCode,
)
}

// Putaway events
block.putawayEvents.forEachIndexed { i, p ->
if (norm(p.refType) == "TRANSFER") return@forEachIndexed
nodes += TraceGraphNode(
id = "$locPrefix-putaway-$i-${p.inventoryLotLineId}",
kind = "PUTAWAY", timestamp = p.timestamp,
sortKey = parseSortKey(p.timestamp, seq++),
refCode = p.refCode, refId = p.refId,
refType = p.refType, warehouseCode = whCode,
)
}

// Movements (non-transfer, non-ADJ, non-TKE)
block.movements.forEachIndexed { i, m ->
val refType = norm(m.refType)
if (refType in MOVEMENT_REF_TYPES_IN_DEDICATED_ARRAYS) return@forEachIndexed
if (isStockTakeLedgerMovement(m.movementType)) return@forEachIndexed
val kind = when {
m.direction == "IN" && refType == "PO" -> "RECEIPT"
m.direction == "IN" && refType == "JO" -> "JO_CREATED"
m.direction == "IN" -> "IN"
m.direction == "OUT" && refType == "DO" -> "DO_OUT"
m.direction == "OUT" -> "OUT"
else -> "OUT"
}
nodes += TraceGraphNode(
id = "$locPrefix-mov-$i-${m.refCode}",
kind = kind, timestamp = m.timestamp,
sortKey = parseSortKey(m.timestamp, seq++),
refCode = m.refCode, refId = m.refId,
warehouseCode = whCode,
)
}

// Stock take events
block.stockTakeEvents.forEachIndexed { i, st ->
nodes += TraceGraphNode(
id = "$locPrefix-st-$i-${st.stockTakeCode}",
kind = "STOCK_TAKE", timestamp = st.timestamp,
sortKey = parseSortKey(st.timestamp, seq++),
refCode = st.stockTakeCode,
warehouseCode = whCode,
)
}

// Adjustments
block.adjustments.forEachIndexed { i, a ->
nodes += TraceGraphNode(
id = "$locPrefix-adj-$i-${a.refCode}-${a.timestamp}",
kind = "ADJUSTMENT", timestamp = a.timestamp,
sortKey = parseSortKey(a.timestamp, seq++),
refCode = a.refCode,
warehouseCode = whCode,
)
}

// Primary buildNodes already emits every TR-* for the traced lot; skip here to avoid duplicates.

// DO deliveries
block.doDeliveries.forEachIndexed { i, dd ->
nodes += TraceGraphNode(
id = "$locPrefix-dodel-$i-${dd.stockOutLineId}",
kind = "DO_OUT", timestamp = dd.timestamp,
sortKey = parseSortKey(dd.timestamp, seq++),
refCode = dd.pickOrderCode,
warehouseCode = whCode,
)
}

block.replenishmentEvents.forEach { r ->
nodes +=
TraceGraphNode(
id = "$locPrefix-replenish-${r.replenishmentId}",
kind = "REPLENISHMENT_CREATED",
timestamp = r.timestamp,
sortKey = parseSortKey(r.timestamp, seq++),
refCode = r.sourceDoCode.ifBlank { r.replenishmentCode },
refId = r.sourceDoId,
replenishmentStockOutLineId = r.stockOutLineId,
warehouseCode = whCode,
)
}

// Fail events
block.failEvents.forEachIndexed { i, fe ->
nodes += TraceGraphNode(
id = "$locPrefix-fail-$i-${fe.failId}",
kind = "FAIL", timestamp = fe.recordDate,
sortKey = parseSortKey(fe.recordDate, seq++),
refCode = fe.pickOrderCode,
warehouseCode = whCode,
)
}

// Return events
block.returnEvents.forEachIndexed { i, re ->
nodes += TraceGraphNode(
id = "$locPrefix-return-$i-${re.stockOutLineId}",
kind = "RETURN", timestamp = re.timestamp,
sortKey = parseSortKey(re.timestamp, seq++),
refCode = re.refCode,
warehouseCode = whCode,
)
}

// Open movements
block.openMovements.forEachIndexed { i, om ->
nodes += TraceGraphNode(
id = "$locPrefix-open-$i-${om.stockInLineId}",
kind = "OPEN", timestamp = om.timestamp,
sortKey = parseSortKey(om.timestamp, seq++),
refCode = om.refCode,
warehouseCode = whCode,
)
}
}

return nodes
}

private fun materialLotKey(itemCode: String, lotNo: String): String = "$itemCode::$lotNo"

private fun norm(v: String?): String = v?.trim()?.uppercase() ?: ""

private fun isStockTakeLedgerMovement(movementType: String): Boolean {
val mt = movementType.trim().uppercase()
return mt == "TKE" || mt == "STOCKTAKE"
}

private fun parseSortKey(ts: String?, seq: Int): Long {
if (ts.isNullOrBlank()) return seq.toLong()
return runCatching { LocalDateTime.parse(ts, DATE_TIME).toEpochSecond(java.time.ZoneOffset.UTC) * 1000 }
.getOrElse {
runCatching { LocalDate.parse(ts, DATE_ONLY).atStartOfDay().toEpochSecond(java.time.ZoneOffset.UTC) * 1000 }
.getOrElse { seq.toLong() }
}
}

/** Expiry is date-only; sort at end of that calendar day (23:59:59). */
private fun parseExpirySortKey(expiry: String?, seq: Int): Long {
if (expiry.isNullOrBlank()) return seq.toLong()
return runCatching {
val dateStr = if (expiry.length >= 10) expiry.substring(0, 10) else expiry.trim()
LocalDate.parse(dateStr, DATE_ONLY)
.atTime(23, 59, 59)
.toEpochSecond(java.time.ZoneOffset.UTC) * 1000
}.getOrElse { seq.toLong() }
}
}

+ 1110
- 0
src/main/java/com/ffii/fpsms/modules/stock/trace/TraceGraphSemanticsBuilder.kt
Datei-Diff unterdrückt, da er zu groß ist
Datei anzeigen


+ 396
- 0
src/main/java/com/ffii/fpsms/modules/stock/trace/TraceInboundLoader.kt Datei anzeigen

@@ -0,0 +1,396 @@
package com.ffii.fpsms.modules.stock.trace

import com.ffii.core.support.JdbcDao
import com.ffii.fpsms.modules.stock.web.model.*
import org.springframework.stereotype.Component

@Component
open class TraceInboundLoader(
private val jdbcDao: JdbcDao,
private val sql: TraceSqlSupport,
) {
open fun loadOrigins(inventoryLotId: Long): List<ItemLotTraceOrigin> =
loadOriginsForLots(listOf(inventoryLotId))

open fun loadOriginsForLots(inventoryLotIds: List<Long>): List<ItemLotTraceOrigin> {
if (inventoryLotIds.isEmpty()) return emptyList()
val query = """
SELECT
sil.inventoryLotId AS inventoryLotId,
sil.id AS stockInLineId,
CASE
WHEN sil.purchaseOrderId IS NOT NULL THEN 'PO'
WHEN sil.jobOrderId IS NOT NULL THEN 'JO'
WHEN sil.stockTakeLineId IS NOT NULL THEN 'TKE'
WHEN sil.stockTransferId IS NOT NULL THEN 'TRANSFER'
WHEN UPPER(COALESCE(sil.type, '')) IN ('ADJ', 'OPEN') THEN UPPER(sil.type)
ELSE COALESCE(UPPER(sil.type), 'OTHER')
END AS type,
COALESCE(po.code, jo.code, st.code, si.code, '') AS refCode,
COALESCE(po.id, jo.id, st.id, si.id) AS refId,
COALESCE(sp_si.code, sp_po.code, '') AS supplierCode,
COALESCE(sp_si.name, sp_po.name, '') AS supplierName,
COALESCE(sil.dnNo, '') AS dnNo,
sil.receiptDate AS receiptDate,
COALESCE(sil.acceptedQty, 0) AS acceptedQty,
COALESCE(sil.status, '') AS status
FROM stock_in_line sil
LEFT JOIN stock_in si ON sil.stockInId = si.id AND si.deleted = 0
LEFT JOIN purchase_order po ON sil.purchaseOrderId = po.id AND po.deleted = 0
LEFT JOIN job_order jo ON sil.jobOrderId = jo.id AND jo.deleted = 0
LEFT JOIN stock_take_line stl ON sil.stockTakeLineId = stl.id AND stl.deleted = 0
LEFT JOIN stock_take st ON stl.stockTakeId = st.id AND st.deleted = 0
LEFT JOIN shop sp_si ON si.supplierId = sp_si.id AND sp_si.deleted = 0
LEFT JOIN shop sp_po ON po.supplierId = sp_po.id AND sp_po.deleted = 0
WHERE sil.inventoryLotId IN (:inventoryLotIds) AND sil.deleted = 0
ORDER BY sil.receiptDate DESC, sil.id DESC
""".trimIndent()
return jdbcDao.queryForList(query, mapOf("inventoryLotIds" to inventoryLotIds)).map { row ->
ItemLotTraceOrigin(
stockInLineId = sql.longVal(row["stockInLineId"]) ?: 0L,
type = sql.str(row["type"]),
refCode = sql.str(row["refCode"]),
refId = sql.longVal(row["refId"]),
supplierCode = sql.str(row["supplierCode"]),
supplierName = sql.str(row["supplierName"]),
dnNo = sql.str(row["dnNo"]),
receiptDate = sql.formatDate(row["receiptDate"]),
acceptedQty = sql.decimal(row["acceptedQty"]),
status = sql.str(row["status"]),
)
}
}

open fun loadPurchaseEvents(stockInLineIds: List<Long>): List<ItemLotTracePurchaseEvent> {
if (stockInLineIds.isEmpty()) return emptyList()
val query = """
SELECT
pol.id AS purchaseOrderLineId,
po.id AS purchaseOrderId,
COALESCE(po.code, '') AS purchaseOrderCode,
COALESCE(i.code, pol.itemNo, '') AS itemCode,
COALESCE(i.name, '') AS itemName,
COALESCE(pol.qtyM18, pol.qty, 0) AS orderQty,
COALESCE(pa.putAwayQty, 0) AS putAwayQty,
COALESCE(ucm18.udfudesc, uc.udfudesc, '') AS purchaseUnit,
COALESCE(sp.code, '') AS supplierCode,
COALESCE(sp.name, '') AS supplierName,
po.orderDate AS orderDate,
COALESCE(pol.status, '') AS status
FROM stock_in_line sil
INNER JOIN purchase_order_line pol ON sil.purchaseOrderLineId = pol.id AND pol.deleted = 0
INNER JOIN purchase_order po ON pol.purchaseOrderId = po.id AND po.deleted = 0
LEFT JOIN shop sp ON po.supplierId = sp.id AND sp.deleted = 0
LEFT JOIN items i ON pol.itemId = i.id AND i.deleted = 0
LEFT JOIN item_uom iu ON iu.itemId = pol.itemId AND iu.purchaseUnit = true AND iu.deleted = 0
LEFT JOIN uom_conversion uc ON uc.id = iu.uomId AND uc.deleted = 0
LEFT JOIN uom_conversion ucm18 ON ucm18.id = pol.uomIdM18 AND ucm18.deleted = 0
LEFT JOIN (
SELECT
sil_pa.purchaseOrderLineId,
SUM(
COALESCE(ill.inQty, 0)
* COALESCE(iu_s.ratioN, 1) / NULLIF(COALESCE(iu_s.ratioD, 1), 0)
/ NULLIF(
COALESCE(iu_p.ratioN, 1) / NULLIF(COALESCE(iu_p.ratioD, 1), 0),
0
)
) AS putAwayQty
FROM stock_in_line sil_pa
INNER JOIN inventory_lot_line ill ON sil_pa.inventoryLotLineId = ill.id AND ill.deleted = 0
INNER JOIN items it_pa ON sil_pa.itemId = it_pa.id AND it_pa.deleted = 0
LEFT JOIN item_uom iu_s ON iu_s.itemId = it_pa.id AND iu_s.stockUnit = true AND iu_s.deleted = 0
LEFT JOIN item_uom iu_p ON iu_p.itemId = it_pa.id AND iu_p.purchaseUnit = true AND iu_p.deleted = 0
WHERE sil_pa.deleted = 0
AND sil_pa.purchaseOrderLineId IS NOT NULL
AND COALESCE(ill.inQty, 0) > 0
GROUP BY sil_pa.purchaseOrderLineId
) pa ON pa.purchaseOrderLineId = pol.id
WHERE sil.deleted = 0
AND sil.id IN (:stockInLineIds)
AND sil.purchaseOrderLineId IS NOT NULL
GROUP BY
pol.id, po.id, po.code, i.code, pol.itemNo, i.name,
pol.qtyM18, pol.qty, pa.putAwayQty, ucm18.udfudesc, uc.udfudesc,
sp.code, sp.name, po.orderDate, pol.status
ORDER BY po.orderDate ASC, pol.id ASC
""".trimIndent()
val fromLines = jdbcDao.queryForList(query, mapOf("stockInLineIds" to stockInLineIds)).map { row ->
mapPurchaseEventRow(row)
}
val headerQuery = """
SELECT
po.id AS purchaseOrderId,
COALESCE(po.code, '') AS purchaseOrderCode,
COALESCE(i.code, '') AS itemCode,
COALESCE(i.name, '') AS itemName,
COALESCE(SUM(sil.acceptedQty), 0) AS orderQty,
COALESCE(uc.udfudesc, '') AS purchaseUnit,
COALESCE(sp.code, '') AS supplierCode,
COALESCE(sp.name, '') AS supplierName,
po.orderDate AS orderDate,
COALESCE(MIN(sil.status), '') AS status
FROM stock_in_line sil
INNER JOIN purchase_order po ON sil.purchaseOrderId = po.id AND po.deleted = 0
LEFT JOIN shop sp ON po.supplierId = sp.id AND sp.deleted = 0
LEFT JOIN items i ON sil.itemId = i.id AND i.deleted = 0
LEFT JOIN item_uom iu ON iu.itemId = sil.itemId AND iu.purchaseUnit = true AND iu.deleted = 0
LEFT JOIN uom_conversion uc ON uc.id = iu.uomId AND uc.deleted = 0
WHERE sil.deleted = 0
AND sil.id IN (:stockInLineIds)
AND sil.purchaseOrderId IS NOT NULL
AND sil.purchaseOrderLineId IS NULL
GROUP BY po.id, po.code, i.code, i.name, uc.udfudesc, sp.code, sp.name, po.orderDate
ORDER BY po.orderDate ASC, po.id ASC
""".trimIndent()
val fromHeader = jdbcDao.queryForList(headerQuery, mapOf("stockInLineIds" to stockInLineIds)).map { row ->
ItemLotTracePurchaseEvent(
purchaseOrderLineId = 0L,
purchaseOrderId = sql.longVal(row["purchaseOrderId"]),
purchaseOrderCode = sql.str(row["purchaseOrderCode"]),
itemCode = sql.str(row["itemCode"]),
itemName = sql.str(row["itemName"]),
orderQty = sql.decimal(row["orderQty"]),
putAwayQty = java.math.BigDecimal.ZERO,
purchaseUnit = sql.str(row["purchaseUnit"]),
supplierCode = sql.str(row["supplierCode"]),
supplierName = sql.str(row["supplierName"]),
orderDate = sql.formatDateTime(row["orderDate"]),
status = sql.str(row["status"]),
)
}
return (fromLines + fromHeader).distinctBy {
if (it.purchaseOrderLineId > 0L) "line-${it.purchaseOrderLineId}" else "po-${it.purchaseOrderId}"
}
}

private fun mapPurchaseEventRow(row: Map<String, Any?>): ItemLotTracePurchaseEvent =
ItemLotTracePurchaseEvent(
purchaseOrderLineId = sql.longVal(row["purchaseOrderLineId"]) ?: 0L,
purchaseOrderId = sql.longVal(row["purchaseOrderId"]),
purchaseOrderCode = sql.str(row["purchaseOrderCode"]),
itemCode = sql.str(row["itemCode"]),
itemName = sql.str(row["itemName"]),
orderQty = sql.decimal(row["orderQty"]),
putAwayQty = sql.decimal(row["putAwayQty"]),
purchaseUnit = sql.str(row["purchaseUnit"]),
supplierCode = sql.str(row["supplierCode"]),
supplierName = sql.str(row["supplierName"]),
orderDate = sql.formatDateTime(row["orderDate"]),
status = sql.str(row["status"]),
)

open fun loadQcResults(stockInLineIds: List<Long>): List<ItemLotTraceQcResult> {
if (stockInLineIds.isEmpty()) return emptyList()
val query = """
SELECT
qr.stockInLineId AS stockInLineId,
COALESCE(qr.qcPassed, 1) AS qcPassed,
COALESCE(qr.failQty, 0) AS failQty,
COALESCE(sil.acceptedQty, 0) AS acceptedQty,
COALESCE(qr.remarks, '') AS remarks,
COALESCE(qr.createdBy, '') AS handledBy,
qr.created AS created,
COALESCE(qi.code, '') AS qcItemCode,
COALESCE(qi.name, '') AS qcItemName,
COALESCE(qi.description, '') AS qcItemDescription,
CASE
WHEN sil.jobOrderId IS NOT NULL THEN 'EPQC'
WHEN sil.purchaseOrderId IS NOT NULL THEN 'IQC'
WHEN UPPER(COALESCE(qr.type, '')) IN ('IQC', 'IPQC', 'EPQC', 'FQC') THEN UPPER(qr.type)
ELSE 'IQC'
END AS qcType
FROM qc_result qr
LEFT JOIN qc_item qi ON qr.qcItemId = qi.id AND qi.deleted = 0
LEFT JOIN stock_in_line sil ON qr.stockInLineId = sil.id AND sil.deleted = 0
WHERE qr.deleted = 0 AND qr.stockInLineId IN (:stockInLineIds)
ORDER BY qr.created DESC, qi.code ASC
""".trimIndent()
return jdbcDao.queryForList(query, mapOf("stockInLineIds" to stockInLineIds)).map { row ->
ItemLotTraceQcResult(
stockInLineId = sql.longVal(row["stockInLineId"]) ?: 0L,
qcPassed = (row["qcPassed"] as? Number)?.toInt() != 0,
failQty = sql.decimal(row["failQty"]),
acceptedQty = sql.decimal(row["acceptedQty"]),
remarks = sql.str(row["remarks"]),
handledBy = sql.str(row["handledBy"]),
created = sql.formatDateTime(row["created"]),
qcItemCode = sql.str(row["qcItemCode"]),
qcItemName = sql.str(row["qcItemName"]),
qcItemDescription = sql.str(row["qcItemDescription"]),
qcType = sql.str(row["qcType"]),
)
}
}

/** FP-MTMS Version Checklist | Functions Ref. No. 22 | v1.0.0 | 2026-07-20 */
open fun loadPutawayEvents(inventoryLotId: Long): List<ItemLotTracePutawayEvent> =
loadPutawayEventsByLotIds(listOf(inventoryLotId))[inventoryLotId].orEmpty()

/**
* Putaway card qty must be the original shelf-in amount, not the live [InventoryLotLine.inQty].
* Stock-take 盤盈 adds variance onto the same lot line inQty (see applyVarianceAdjustment),
* which would otherwise inflate the 已上架 node (e.g. 150 → 1500).
*/
private fun putawayQtySql(illIdExpr: String): String = """
GREATEST(
COALESCE(ill.inQty, 0) - COALESCE((
SELECT SUM(COALESCE(sil_tke.acceptedQty, 0))
FROM stock_in_line sil_tke
INNER JOIN stock_take_line stl_tke
ON sil_tke.stockTakeLineId = stl_tke.id AND stl_tke.deleted = 0
WHERE stl_tke.inventoryLotLineId = $illIdExpr
AND sil_tke.deleted = 0
AND UPPER(COALESCE(sil_tke.type, '')) = 'TKE'
AND COALESCE(sil_tke.acceptedQty, 0) > 0
), 0),
0
)
""".trimIndent()

/** FP-MTMS Version Checklist | Functions Ref. No. 22 | v1.0.0 | 2026-07-20 */
open fun loadPutawayEventsByLotIds(lotIds: List<Long>): Map<Long, List<ItemLotTracePutawayEvent>> {
if (lotIds.isEmpty()) return emptyMap()
val illPutawayQty = putawayQtySql("ill.id")
val fromIllSql = """
SELECT
il.id AS inventoryLotId,
ill.id AS inventoryLotLineId,
COALESCE(sil_link.id, sil_primary.id) AS stockInLineId,
COALESCE(wh.code, '') AS warehouseCode,
$illPutawayQty AS qty,
COALESCE(ill.createdBy, sil_link.createdBy, sil_primary.createdBy, '') AS handledBy,
COALESCE(ill.created, sil_link.receiptDate, sil_primary.receiptDate, sil_link.created, sil_primary.created) AS timestamp,
CASE
WHEN COALESCE(sil_link.purchaseOrderId, sil_primary.purchaseOrderId) IS NOT NULL THEN 'PO'
WHEN COALESCE(sil_link.jobOrderId, sil_primary.jobOrderId) IS NOT NULL THEN 'JO'
WHEN COALESCE(sil_link.stockTakeLineId, sil_primary.stockTakeLineId) IS NOT NULL THEN 'TKE'
WHEN COALESCE(sil_link.stockTransferId, sil_primary.stockTransferId) IS NOT NULL THEN 'TRANSFER'
WHEN UPPER(COALESCE(sil_link.type, sil_primary.type, '')) IN ('ADJ', 'OPEN') THEN UPPER(COALESCE(sil_link.type, sil_primary.type))
ELSE 'STOCK_IN'
END AS refType,
COALESCE(po.code, jo.code, st.code, si.code, '') AS refCode,
COALESCE(po.id, jo.id, st.id, si.id) AS refId,
COALESCE(sil_link.status, sil_primary.status, '') AS status
FROM inventory_lot_line ill
INNER JOIN inventory_lot il ON ill.inventoryLotId = il.id AND il.deleted = 0
LEFT JOIN warehouse wh ON ill.warehouseId = wh.id AND wh.deleted = 0
LEFT JOIN stock_in_line sil_link ON sil_link.inventoryLotLineId = ill.id AND sil_link.deleted = 0
LEFT JOIN stock_in_line sil_primary ON sil_primary.id = il.stockInLineId AND sil_primary.deleted = 0
LEFT JOIN stock_in si ON COALESCE(sil_link.stockInId, sil_primary.stockInId) = si.id AND si.deleted = 0
LEFT JOIN purchase_order po ON COALESCE(sil_link.purchaseOrderId, sil_primary.purchaseOrderId) = po.id AND po.deleted = 0
LEFT JOIN job_order jo ON COALESCE(sil_link.jobOrderId, sil_primary.jobOrderId) = jo.id AND jo.deleted = 0
LEFT JOIN stock_take_line stl ON COALESCE(sil_link.stockTakeLineId, sil_primary.stockTakeLineId) = stl.id AND stl.deleted = 0
LEFT JOIN stock_take st ON stl.stockTakeId = st.id AND st.deleted = 0
WHERE il.id IN (:lotIds) AND ill.deleted = 0
""".trimIndent()
val fromSilSql = """
SELECT
il.id AS inventoryLotId,
COALESCE(ill.id, sil.inventoryLotLineId) AS inventoryLotLineId,
sil.id AS stockInLineId,
COALESCE(wh.code, '') AS warehouseCode,
CASE
WHEN ill.id IS NOT NULL THEN $illPutawayQty
ELSE COALESCE(sil.acceptedQty, 0)
END AS qty,
COALESCE(ill.createdBy, sil.createdBy, '') AS handledBy,
COALESCE(ill.created, sil.receiptDate, sil.created) AS timestamp,
CASE
WHEN sil.purchaseOrderId IS NOT NULL THEN 'PO'
WHEN sil.jobOrderId IS NOT NULL THEN 'JO'
WHEN sil.stockTakeLineId IS NOT NULL THEN 'TKE'
WHEN sil.stockTransferId IS NOT NULL THEN 'TRANSFER'
WHEN UPPER(COALESCE(sil.type, '')) IN ('ADJ', 'OPEN') THEN UPPER(sil.type)
ELSE 'STOCK_IN'
END AS refType,
COALESCE(po.code, jo.code, st.code, si.code, '') AS refCode,
COALESCE(po.id, jo.id, st.id, si.id) AS refId,
COALESCE(sil.status, '') AS status
FROM stock_in_line sil
INNER JOIN inventory_lot il ON sil.inventoryLotId = il.id AND il.deleted = 0
LEFT JOIN inventory_lot_line ill ON sil.inventoryLotLineId = ill.id
LEFT JOIN warehouse wh ON ill.warehouseId = wh.id AND wh.deleted = 0
LEFT JOIN stock_in si ON sil.stockInId = si.id AND si.deleted = 0
LEFT JOIN purchase_order po ON sil.purchaseOrderId = po.id AND po.deleted = 0
LEFT JOIN job_order jo ON sil.jobOrderId = jo.id AND jo.deleted = 0
LEFT JOIN stock_take_line stl ON sil.stockTakeLineId = stl.id AND stl.deleted = 0
LEFT JOIN stock_take st ON stl.stockTakeId = st.id AND st.deleted = 0
WHERE il.id IN (:lotIds) AND sil.deleted = 0 AND sil.inventoryLotLineId IS NOT NULL
""".trimIndent()

val grouped = mutableMapOf<Long, MutableList<ItemLotTracePutawayEvent>>()
val seen = mutableSetOf<String>()
fun addRow(row: Map<String, Any?>) {
val lotId = sql.longVal(row["inventoryLotId"]) ?: return
val illId = sql.longVal(row["inventoryLotLineId"]) ?: 0L
val timestamp = sql.formatDateTime(row["timestamp"])
val dedupeKey = "$lotId-$illId-$timestamp"
if (!seen.add(dedupeKey)) return
grouped.getOrPut(lotId) { mutableListOf() }.add(
ItemLotTracePutawayEvent(
inventoryLotLineId = illId,
stockInLineId = sql.longVal(row["stockInLineId"]),
warehouseCode = sql.str(row["warehouseCode"]),
qty = sql.decimal(row["qty"]),
handledBy = sql.str(row["handledBy"]),
timestamp = timestamp,
refType = sql.str(row["refType"]),
refCode = sql.str(row["refCode"]),
refId = sql.longVal(row["refId"]),
status = sql.str(row["status"]),
),
)
}
jdbcDao.queryForList(fromIllSql, mapOf("lotIds" to lotIds)).forEach(::addRow)
jdbcDao.queryForList(fromSilSql, mapOf("lotIds" to lotIds)).forEach(::addRow)
grouped.values.forEach { list ->
list.sortWith(compareBy<ItemLotTracePutawayEvent> { it.timestamp ?: "" }.thenBy { it.inventoryLotLineId })
}
return grouped
}

open fun synthesizeMaterialPutawayEvent(
origin: ItemLotTraceOrigin,
pickedInventoryLotLineId: Long?,
pickedWarehouseCode: String,
): ItemLotTracePutawayEvent =
ItemLotTracePutawayEvent(
inventoryLotLineId = pickedInventoryLotLineId ?: 0L,
stockInLineId = origin.stockInLineId,
warehouseCode = pickedWarehouseCode,
qty = origin.acceptedQty,
handledBy = "",
timestamp = origin.receiptDate,
refType = origin.type,
refCode = origin.refCode,
refId = origin.refId,
status = origin.status,
)

open fun resolveMaterialPutawayEvents(
lotId: Long?,
origin: ItemLotTraceOrigin?,
pickedInventoryLotLineId: Long?,
pickedWarehouseCode: String,
putawayByLot: Map<Long, List<ItemLotTracePutawayEvent>>,
): List<ItemLotTracePutawayEvent> {
if (lotId == null) return emptyList()
val fromLot = putawayByLot[lotId].orEmpty()
if (fromLot.isNotEmpty()) return fromLot
if (origin == null) return emptyList()

val originType = origin.type.trim().uppercase()
val immediateInbound = originType in setOf("ADJ", "OPEN", "JO", "TRANSFER")
val poInbound = originType == "PO"
if (!immediateInbound && !poInbound) return emptyList()

return listOf(
synthesizeMaterialPutawayEvent(
origin = origin,
pickedInventoryLotLineId = pickedInventoryLotLineId,
pickedWarehouseCode = pickedWarehouseCode,
),
)
}
}

+ 357
- 0
src/main/java/com/ffii/fpsms/modules/stock/trace/TraceJoPreludeLoader.kt Datei anzeigen

@@ -0,0 +1,357 @@
package com.ffii.fpsms.modules.stock.trace

import com.ffii.core.support.JdbcDao
import com.ffii.fpsms.modules.stock.web.model.*
import org.springframework.stereotype.Component
import java.math.BigDecimal

data class MaterialInputRow(
val jobOrderCode: String,
val jobOrderId: Long?,
val materialItemCode: String,
val materialItemName: String,
val materialLotNo: String,
val materialStockInLineId: Long?,
val materialInventoryLotId: Long?,
val pickedInventoryLotLineId: Long?,
val pickedWarehouseCode: String,
val materialQty: BigDecimal,
val materialUom: String,
val bomQtyPerUnit: BigDecimal?,
val pickOrderCode: String,
val pickOrderId: Long?,
val consoCode: String,
val pickedAt: String?,
val processingStatus: String,
val matchStatus: String,
)

@Component
open class TraceJoPreludeLoader(
private val jdbcDao: JdbcDao,
private val sql: TraceSqlSupport,
private val bomLoader: TraceBomLoader,
private val productionLoader: TraceProductionLoader,
private val inboundLoader: TraceInboundLoader,
private val lotResolver: TraceLotResolver,
) {
open fun loadJoPrelude(primaryStockInLineId: Long): ItemLotTraceJoPrelude? {
val joId = bomLoader.getJobOrderIdForFgStockIn(primaryStockInLineId) ?: return null
val jobOrder = loadJoContext(joId) ?: return null
val pickOrders = loadJoPickOrders(joId)
val materialInputs = loadJoMaterialInputs(primaryStockInLineId, mutableSetOf())
return ItemLotTraceJoPrelude(
jobOrder = jobOrder,
pickOrders = pickOrders,
materialInputs = materialInputs,
)
}

open fun loadJoContext(jobOrderId: Long): ItemLotTraceJoContext? {
val query = """
SELECT
jo.id AS jobOrderId,
COALESCE(jo.code, '') AS jobOrderCode,
jo.planStart AS planStart,
(
SELECT MIN(jpo.created)
FROM pick_order po
INNER JOIN jo_pick_order jpo
ON jpo.pick_order_id = po.id AND jpo.deleted = 0
WHERE po.joId = jo.id AND po.deleted = 0
) AS createdAt,
COALESCE(jo.reqQty, 0) AS reqQty,
COALESCE(jo.status, '') AS status
FROM job_order jo
WHERE jo.id = :jobOrderId AND jo.deleted = 0
""".trimIndent()
val row = jdbcDao.queryForList(query, mapOf("jobOrderId" to jobOrderId)).firstOrNull() ?: return null
return ItemLotTraceJoContext(
jobOrderId = sql.longVal(row["jobOrderId"]) ?: jobOrderId,
jobOrderCode = sql.str(row["jobOrderCode"]),
planStart = sql.formatDateTime(row["planStart"]),
createdAt = sql.formatDateTime(row["createdAt"]),
reqQty = sql.decimal(row["reqQty"]),
status = sql.str(row["status"]),
)
}

open fun loadJoPickOrders(jobOrderId: Long): List<ItemLotTraceJoPickOrder> {
val headerSql = """
SELECT
po.id AS pickOrderId,
COALESCE(po.code, '') AS pickOrderCode,
COALESCE(po.consoCode, '') AS consoCode,
COALESCE(po.status, '') AS status,
po.targetDate AS targetDate,
po.completeDate AS completeDate,
po.releasedDate AS releasedDate
FROM pick_order po
WHERE po.joId = :jobOrderId AND po.deleted = 0
AND po.type IN ('jo', 'JOB_ORDER')
ORDER BY po.code
""".trimIndent()
val headers = jdbcDao.queryForList(headerSql, mapOf("jobOrderId" to jobOrderId))
if (headers.isEmpty()) return emptyList()

val lineSql = """
SELECT
pol.poId AS pickOrderId,
pol.id AS pickOrderLineId,
COALESCE(it.code, '') AS itemCode,
COALESCE(it.name, '') AS itemName,
COALESCE(pol.qty, 0) AS requiredQty,
COALESCE(pol.status, '') AS lineStatus,
COALESCE(SUM(sol.qty), 0) AS pickedQty
FROM pick_order_line pol
INNER JOIN pick_order po ON pol.poId = po.id AND po.deleted = 0
LEFT JOIN items it ON pol.itemId = it.id AND it.deleted = 0
LEFT JOIN stock_out_line sol ON sol.pickOrderLineId = pol.id AND sol.deleted = 0
WHERE po.joId = :jobOrderId AND pol.deleted = 0
AND po.type IN ('jo', 'JOB_ORDER')
GROUP BY pol.id, pol.poId, it.code, it.name, pol.qty, pol.status
ORDER BY it.code
""".trimIndent()
val linesByPo = jdbcDao.queryForList(lineSql, mapOf("jobOrderId" to jobOrderId))
.groupBy { sql.longVal(it["pickOrderId"]) }

return headers.map { row ->
val poId = sql.longVal(row["pickOrderId"]) ?: 0L
val lines = (linesByPo[poId] ?: emptyList()).map { lineRow ->
ItemLotTraceJoPickLine(
pickOrderLineId = sql.longVal(lineRow["pickOrderLineId"]) ?: 0L,
itemCode = sql.str(lineRow["itemCode"]),
itemName = sql.str(lineRow["itemName"]),
requiredQty = sql.decimal(lineRow["requiredQty"]),
pickedQty = sql.decimal(lineRow["pickedQty"]),
status = sql.str(lineRow["lineStatus"]),
)
}
ItemLotTraceJoPickOrder(
pickOrderId = poId,
pickOrderCode = sql.str(row["pickOrderCode"]),
consoCode = sql.str(row["consoCode"]),
status = sql.str(row["status"]),
targetDate = sql.formatDate(row["targetDate"]),
completeDate = sql.formatDateTime(row["completeDate"]),
releasedDate = sql.formatDateTime(row["releasedDate"]),
lines = lines,
)
}
}

open fun loadJoMaterialInputs(
primaryStockInLineId: Long,
visitedStockInLineIds: MutableSet<Long>,
): List<ItemLotTraceMaterialInput> {
if (primaryStockInLineId in visitedStockInLineIds) return emptyList()
visitedStockInLineIds.add(primaryStockInLineId)

val query = """
SELECT
COALESCE(jo.code, '') AS jobOrderCode,
jo.id AS jobOrderId,
COALESCE(mat_it.code, '') AS materialItemCode,
COALESCE(mat_it.name, '') AS materialItemName,
COALESCE(mat_il.lotNo, mat_sil.lotNo, '') AS materialLotNo,
mat_sil.id AS materialStockInLineId,
mat_il.id AS materialInventoryLotId,
mat_ill.id AS pickedInventoryLotLineId,
COALESCE(wh_pick.code, '') AS pickedWarehouseCode,
COALESCE(sol.qty, 0) AS materialQty,
COALESCE(mat_uc.udfudesc, '') AS materialUom,
bm.qty AS bomQtyPerUnit,
COALESCE(po.code, '') AS pickOrderCode,
po.id AS pickOrderId,
COALESCE(po.consoCode, '') AS consoCode,
COALESCE(sol.endTime, sol.pickTime, sol.created) AS pickedAt,
CASE
WHEN sol.status = 'completed' THEN 'completed'
WHEN sol.status = 'rejected' THEN 'rejected'
WHEN sol.status = 'created' THEN 'pending'
ELSE 'pending'
END AS processingStatus,
COALESCE(jpo.match_status, '') AS matchStatus
FROM stock_in_line fg_sil
INNER JOIN job_order jo ON fg_sil.jobOrderId = jo.id AND jo.deleted = 0
INNER JOIN pick_order po ON po.joId = jo.id AND po.deleted = 0
AND po.type IN ('jo', 'JOB_ORDER')
INNER JOIN pick_order_line pol ON pol.poId = po.id AND pol.deleted = 0
INNER JOIN stock_out_line sol ON sol.pickOrderLineId = pol.id AND sol.deleted = 0
INNER JOIN inventory_lot_line mat_ill ON sol.inventoryLotLineId = mat_ill.id AND mat_ill.deleted = 0
INNER JOIN inventory_lot mat_il ON mat_ill.inventoryLotId = mat_il.id AND mat_il.deleted = 0
LEFT JOIN warehouse wh_pick ON mat_ill.warehouseId = wh_pick.id AND wh_pick.deleted = 0
LEFT JOIN stock_in_line mat_sil ON mat_il.stockInLineId = mat_sil.id AND mat_sil.deleted = 0
LEFT JOIN items mat_it ON mat_il.itemId = mat_it.id AND mat_it.deleted = 0
LEFT JOIN item_uom mat_iu ON mat_it.id = mat_iu.itemId AND mat_iu.stockUnit = 1 AND mat_iu.deleted = 0
LEFT JOIN uom_conversion mat_uc ON mat_iu.uomId = mat_uc.id AND mat_uc.deleted = 0
LEFT JOIN bom b ON jo.bomId = b.id AND b.deleted = 0
LEFT JOIN bom_material bm ON bm.bomId = b.id AND bm.itemId = mat_it.id AND bm.deleted = 0
LEFT JOIN jo_pick_order jpo ON jpo.pick_order_id = po.id
AND jpo.item_id = mat_it.id AND jpo.deleted = 0
WHERE fg_sil.id = :primaryStockInLineId AND fg_sil.deleted = 0
ORDER BY mat_it.code, mat_il.lotNo, sol.created
""".trimIndent()
val rows = jdbcDao.queryForList(query, mapOf("primaryStockInLineId" to primaryStockInLineId)).map { row ->
MaterialInputRow(
jobOrderCode = sql.str(row["jobOrderCode"]),
jobOrderId = sql.longVal(row["jobOrderId"]),
materialItemCode = sql.str(row["materialItemCode"]),
materialItemName = sql.str(row["materialItemName"]),
materialLotNo = sql.str(row["materialLotNo"]),
materialStockInLineId = sql.longVal(row["materialStockInLineId"]),
materialInventoryLotId = sql.longVal(row["materialInventoryLotId"]),
pickedInventoryLotLineId = sql.longVal(row["pickedInventoryLotLineId"]),
pickedWarehouseCode = sql.str(row["pickedWarehouseCode"]),
materialQty = sql.decimal(row["materialQty"]),
materialUom = sql.str(row["materialUom"]),
bomQtyPerUnit = row["bomQtyPerUnit"]?.let { sql.decimal(it) },
pickOrderCode = sql.str(row["pickOrderCode"]),
pickOrderId = sql.longVal(row["pickOrderId"]),
consoCode = sql.str(row["consoCode"]),
pickedAt = sql.formatDateTime(row["pickedAt"]),
processingStatus = sql.str(row["processingStatus"]),
matchStatus = sql.str(row["matchStatus"]),
)
}
if (rows.isEmpty()) return emptyList()

val joIdForMapping = rows.firstNotNullOfOrNull { it.jobOrderId }
val stepAssignments = joIdForMapping?.let { bomLoader.loadBomMaterialStepAssignments(it) } ?: emptyMap()

val lotIds = rows.mapNotNull { it.materialInventoryLotId }.distinct()
val silIdsByLot = lotResolver.loadStockInLineIdsForLots(lotIds)
val allSilIds = silIdsByLot.values.flatten().distinct()
val allOrigins = inboundLoader.loadOriginsForLots(lotIds)
val allQc = inboundLoader.loadQcResults(allSilIds)
val putawayByLot = inboundLoader.loadPutawayEventsByLotIds(lotIds)

fun materialStockInOriginForLot(lotId: Long?): ItemLotTraceOrigin? {
if (lotId == null) return null
val silIds = silIdsByLot[lotId]?.toSet() ?: emptySet()
if (silIds.isEmpty()) return null
val origins = allOrigins.filter { it.stockInLineId in silIds }
if (origins.isEmpty()) return null

fun earliestOfType(type: String): ItemLotTraceOrigin? =
origins
.filter { it.type.equals(type, ignoreCase = true) }
.minWithOrNull(
compareBy<ItemLotTraceOrigin> { it.receiptDate ?: "9999-12-31" }
.thenBy { it.stockInLineId },
)

earliestOfType("PO")?.let { return it }
earliestOfType("JO")?.let { return it }
earliestOfType("TRANSFER")?.let { return it }

return origins
.filter { o ->
val t = o.type.uppercase()
t !in setOf("ADJ", "OPEN", "TKE")
}
.minWithOrNull(
compareBy<ItemLotTraceOrigin> { it.receiptDate ?: "9999-12-31" }
.thenBy { it.stockInLineId },
)
?: origins.minWithOrNull(
compareBy<ItemLotTraceOrigin> { it.receiptDate ?: "9999-12-31" }
.thenBy { it.stockInLineId },
)
}

fun qcForLot(lotId: Long?): List<ItemLotTraceQcResult> {
if (lotId == null) return emptyList()
val silIds = silIdsByLot[lotId]?.toSet() ?: emptySet()
return allQc.filter { it.stockInLineId in silIds }
}

fun purchaseForLot(lotId: Long?): List<ItemLotTracePurchaseEvent> {
if (lotId == null) return emptyList()
val silIds = silIdsByLot[lotId].orEmpty()
return inboundLoader.loadPurchaseEvents(silIds)
}

return rows.map { r ->
val stockInOrigin = materialStockInOriginForLot(r.materialInventoryLotId)
val resolvedSilId = bomLoader.resolveJoProducedStockInLineId(
r.materialStockInLineId,
r.materialInventoryLotId,
silIdsByLot,
)
val joProduced = bomLoader.isJoProducedMaterial(
resolvedSilId,
r.materialInventoryLotId,
stockInOrigin,
)
val producingJoId = resolvedSilId?.let { bomLoader.getJobOrderIdForFgStockIn(it) }
?: r.materialInventoryLotId?.let { lotResolver.resolveJobOrderIdForLot(it, resolvedSilId) }
val nestedJoPrelude = if (joProduced && producingJoId != null) {
val jobOrder = loadJoContext(producingJoId)
if (jobOrder != null) {
val nestedSilId = resolvedSilId ?: producingJoId.let { joId ->
jdbcDao.queryForList(
"""
SELECT sil.id AS id FROM stock_in_line sil
WHERE sil.jobOrderId = :joId AND sil.deleted = 0
ORDER BY sil.created DESC LIMIT 1
""".trimIndent(),
mapOf("joId" to joId),
).firstOrNull()?.let { sql.longVal(it["id"]) }
}
val materialInputs = nestedSilId
?.let { loadJoMaterialInputs(it, visitedStockInLineIds) }
?: emptyList()
ItemLotTraceJoPrelude(
jobOrder = jobOrder,
pickOrders = loadJoPickOrders(producingJoId),
materialInputs = materialInputs,
)
} else {
null
}
} else {
null
}
val productionSteps = if (joProduced && producingJoId != null) {
productionLoader.loadProductionSteps(producingJoId)
} else {
emptyList()
}
val assignment = stepAssignments[bomLoader.itemCodeKey(r.materialItemCode)]
ItemLotTraceMaterialInput(
jobOrderCode = r.jobOrderCode,
jobOrderId = r.jobOrderId,
materialItemCode = r.materialItemCode,
materialItemName = r.materialItemName,
materialLotNo = r.materialLotNo,
materialStockInLineId = resolvedSilId ?: r.materialStockInLineId,
materialInventoryLotId = r.materialInventoryLotId,
materialQty = r.materialQty,
materialUom = r.materialUom,
bomQtyPerUnit = r.bomQtyPerUnit,
bomProcessId = assignment?.bomProcessId,
bomProcessSeqNo = assignment?.bomProcessSeqNo,
assignedStepName = assignment?.stepName ?: "",
pickOrderCode = r.pickOrderCode,
pickOrderId = r.pickOrderId,
consoCode = r.consoCode,
pickedAt = r.pickedAt,
processingStatus = r.processingStatus,
matchStatus = r.matchStatus,
stockInOrigin = stockInOrigin,
purchaseEvents = purchaseForLot(r.materialInventoryLotId),
qcResults = qcForLot(r.materialInventoryLotId),
putawayEvents = inboundLoader.resolveMaterialPutawayEvents(
lotId = r.materialInventoryLotId,
origin = stockInOrigin,
pickedInventoryLotLineId = r.pickedInventoryLotLineId,
pickedWarehouseCode = r.pickedWarehouseCode,
putawayByLot = putawayByLot,
),
nestedJoPrelude = nestedJoPrelude,
productionSteps = productionSteps,
)
}
}
}

+ 319
- 0
src/main/java/com/ffii/fpsms/modules/stock/trace/TraceLotResolver.kt Datei anzeigen

@@ -0,0 +1,319 @@
package com.ffii.fpsms.modules.stock.trace

import com.ffii.core.support.JdbcDao
import com.ffii.fpsms.modules.stock.entity.InventoryLotLineRepository
import com.ffii.fpsms.modules.stock.entity.InventoryLotRepository
import com.ffii.fpsms.modules.stock.entity.StockInLineRepository
import com.ffii.fpsms.modules.stock.web.model.*
import org.springframework.http.HttpStatus
import org.springframework.stereotype.Component
import org.springframework.web.server.ResponseStatusException

@Component
open class TraceLotResolver(
private val jdbcDao: JdbcDao,
private val inventoryLotRepository: InventoryLotRepository,
private val inventoryLotLineRepository: InventoryLotLineRepository,
private val stockInLineRepository: StockInLineRepository,
private val sql: TraceSqlSupport,
) {
open fun resolveInventoryLotId(
stockInLineId: Long?,
inventoryLotLineId: Long?,
lotNo: String?,
itemId: Long?,
): Long {
stockInLineId?.let { silId ->
val sil = stockInLineRepository.findById(silId).orElseThrow {
ResponseStatusException(HttpStatus.NOT_FOUND, "stockInLineId $silId not found")
}
val lotId = sil.inventoryLot?.id
?: inventoryLotRepository.findFirstByStockInLineIdAndDeletedFalseOrderByIdAsc(silId)?.id
return lotId ?: throw ResponseStatusException(
HttpStatus.NOT_FOUND,
"No inventory lot for stockInLineId $silId",
)
}

inventoryLotLineId?.let { illId ->
val ill = inventoryLotLineRepository.findById(illId).orElseThrow {
ResponseStatusException(HttpStatus.NOT_FOUND, "inventoryLotLineId $illId not found")
}
return ill.inventoryLot?.id
?: throw ResponseStatusException(HttpStatus.NOT_FOUND, "No inventory lot for inventoryLotLineId $illId")
}

if (!lotNo.isNullOrBlank() && itemId != null) {
val trimmed = lotNo.trim()
val inventoryLotId = resolvePrimaryInventoryLotIdByLotNo(trimmed, itemId)
?: throw ResponseStatusException(
HttpStatus.NOT_FOUND,
"Lot not found: lotNo=$trimmed itemId=$itemId",
)
return inventoryLotId
}

throw ResponseStatusException(
HttpStatus.BAD_REQUEST,
"Provide stockInLineId, inventoryLotLineId, or lotNo+itemId/itemCode",
)
}

/** lotNo+itemId is not unique — pick the lot with the most available qty (tie: lowest id). */
open fun resolvePrimaryInventoryLotIdByLotNo(lotNo: String, itemId: Long): Long? {
val query = """
SELECT il.id AS inventoryLotId
FROM inventory_lot il
LEFT JOIN inventory_lot_line ill ON ill.inventoryLotId = il.id AND ill.deleted = 0
WHERE il.deleted = 0
AND il.itemId = :itemId
AND il.lotNo = :lotNo
GROUP BY il.id
ORDER BY COALESCE(SUM(COALESCE(ill.inQty, 0) - COALESCE(ill.outQty, 0)), 0) DESC, il.id ASC
LIMIT 1
""".trimIndent()
return jdbcDao.queryForList(query, mapOf("itemId" to itemId, "lotNo" to lotNo))
.firstOrNull()
?.let { sql.longVal(it["inventoryLotId"]) }
}

open fun loadAlternateLocations(
itemId: Long,
lotNo: String,
currentInventoryLotId: Long,
): List<ItemLotTraceAlternateLocation> {
if (lotNo.isBlank()) return emptyList()
val query = """
SELECT
il.id AS inventoryLotId,
il.stockInLineId AS stockInLineId,
ill.id AS inventoryLotLineId,
COALESCE(wh.code, '') AS warehouseCode,
COALESCE(ill.inQty, 0) - COALESCE(ill.outQty, 0) AS availableQty
FROM inventory_lot il
INNER JOIN inventory_lot_line ill ON ill.inventoryLotId = il.id AND ill.deleted = 0
LEFT JOIN warehouse wh ON ill.warehouseId = wh.id AND wh.deleted = 0
WHERE il.deleted = 0
AND il.itemId = :itemId
AND il.lotNo = :lotNo
AND il.id <> :currentInventoryLotId
ORDER BY wh.code, il.id, ill.id
""".trimIndent()
return jdbcDao.queryForList(
query,
mapOf("itemId" to itemId, "lotNo" to lotNo, "currentInventoryLotId" to currentInventoryLotId),
).map { row ->
ItemLotTraceAlternateLocation(
inventoryLotId = sql.longVal(row["inventoryLotId"]) ?: 0L,
stockInLineId = sql.longVal(row["stockInLineId"]),
inventoryLotLineId = sql.longVal(row["inventoryLotLineId"]) ?: 0L,
warehouseCode = sql.str(row["warehouseCode"]),
availableQty = sql.decimal(row["availableQty"]),
)
}
}

open fun loadLotInfo(inventoryLotId: Long): ItemLotTraceLotInfo {
val query = """
SELECT
il.id AS inventoryLotId,
COALESCE(il.lotNo, '') AS lotNo,
it.id AS itemId,
COALESCE(it.code, '') AS itemCode,
COALESCE(it.name, '') AS itemName,
il.expiryDate AS expiryDate,
(SELECT sil.productionDate
FROM stock_in_line sil
WHERE sil.inventoryLotId = il.id
AND sil.jobOrderId IS NOT NULL
AND sil.deleted = 0
ORDER BY sil.receiptDate DESC, sil.id DESC
LIMIT 1) AS productionDate,
il.stockInDate AS stockInDate,
COALESCE(uc.udfudesc, '') AS uom,
il.stockInLineId AS primaryStockInLineId
FROM inventory_lot il
INNER JOIN items it ON il.itemId = it.id AND it.deleted = 0
LEFT JOIN item_uom iu ON it.id = iu.itemId AND iu.stockUnit = 1
LEFT JOIN uom_conversion uc ON iu.uomId = uc.id
WHERE il.id = :inventoryLotId AND il.deleted = 0
""".trimIndent()
val row = jdbcDao.queryForList(query, mapOf("inventoryLotId" to inventoryLotId)).firstOrNull()
?: throw ResponseStatusException(HttpStatus.NOT_FOUND, "Inventory lot $inventoryLotId not found")

return ItemLotTraceLotInfo(
inventoryLotId = inventoryLotId,
lotNo = sql.str(row["lotNo"]),
itemId = sql.longVal(row["itemId"]) ?: inventoryLotId,
itemCode = sql.str(row["itemCode"]),
itemName = sql.str(row["itemName"]),
expiryDate = sql.formatDate(row["expiryDate"]),
productionDate = sql.formatDateTime(row["productionDate"]),
stockInDate = sql.formatDateTime(row["stockInDate"]),
uom = sql.str(row["uom"]),
primaryStockInLineId = sql.longVal(row["primaryStockInLineId"]),
)
}

open fun loadLotLineIds(inventoryLotId: Long): List<Long> {
val query = """
SELECT id FROM inventory_lot_line
WHERE inventoryLotId = :inventoryLotId AND deleted = 0
""".trimIndent()
return jdbcDao.queryForList(query, mapOf("inventoryLotId" to inventoryLotId))
.mapNotNull { sql.longVal(it["id"]) }
}

open fun loadStockInLineIds(inventoryLotId: Long): List<Long> {
val query = """
SELECT sil.id AS id
FROM stock_in_line sil
LEFT JOIN inventory_lot_line ill ON sil.inventoryLotLineId = ill.id AND ill.deleted = 0
WHERE sil.deleted = 0
AND (
sil.inventoryLotId = :inventoryLotId
OR ill.inventoryLotId = :inventoryLotId
)
""".trimIndent()
return jdbcDao.queryForList(query, mapOf("inventoryLotId" to inventoryLotId))
.mapNotNull { sql.longVal(it["id"]) }
.distinct()
}

open fun loadWarehouseLines(inventoryLotId: Long): List<ItemLotTraceWarehouseLine> {
val query = """
SELECT
ill.id AS inventoryLotLineId,
COALESCE(wh.code, '') AS warehouseCode,
COALESCE(ill.inQty, 0) AS inQty,
COALESCE(ill.outQty, 0) AS outQty,
COALESCE(ill.issueQty, 0) AS issueQty,
COALESCE(ill.status, '') AS status,
COALESCE(ill.inQty, 0) - COALESCE(ill.outQty, 0) AS availableQty
FROM inventory_lot_line ill
LEFT JOIN warehouse wh ON ill.warehouseId = wh.id AND wh.deleted = 0
WHERE ill.inventoryLotId = :inventoryLotId AND ill.deleted = 0
ORDER BY wh.code
""".trimIndent()
return jdbcDao.queryForList(query, mapOf("inventoryLotId" to inventoryLotId)).map { row ->
ItemLotTraceWarehouseLine(
inventoryLotLineId = sql.longVal(row["inventoryLotLineId"]) ?: 0L,
warehouseCode = sql.str(row["warehouseCode"]),
inQty = sql.decimal(row["inQty"]),
outQty = sql.decimal(row["outQty"]),
issueQty = sql.decimal(row["issueQty"]),
status = sql.str(row["status"]),
availableQty = sql.decimal(row["availableQty"]),
)
}
}

/** Batch variant of [loadLotLineIds]. */
open fun loadLotLineIdsForLots(inventoryLotIds: List<Long>): Map<Long, List<Long>> {
if (inventoryLotIds.isEmpty()) return emptyMap()
val query = """
SELECT inventoryLotId, id FROM inventory_lot_line
WHERE inventoryLotId IN (:inventoryLotIds) AND deleted = 0
ORDER BY id
""".trimIndent()
val grouped = mutableMapOf<Long, MutableList<Long>>()
jdbcDao.queryForList(query, mapOf("inventoryLotIds" to inventoryLotIds)).forEach { row ->
val lotId = sql.longVal(row["inventoryLotId"]) ?: return@forEach
val illId = sql.longVal(row["id"]) ?: return@forEach
grouped.getOrPut(lotId) { mutableListOf() }.add(illId)
}
return grouped
}

open fun loadStockInLineIdsForLots(inventoryLotIds: List<Long>): Map<Long, List<Long>> {
if (inventoryLotIds.isEmpty()) return emptyMap()
val query = """
SELECT inventoryLotId, id FROM stock_in_line
WHERE inventoryLotId IN (:inventoryLotIds) AND deleted = 0
""".trimIndent()
val grouped = mutableMapOf<Long, MutableList<Long>>()
jdbcDao.queryForList(query, mapOf("inventoryLotIds" to inventoryLotIds)).forEach { row ->
val lotId = sql.longVal(row["inventoryLotId"]) ?: return@forEach
val silId = sql.longVal(row["id"]) ?: return@forEach
grouped.getOrPut(lotId) { mutableListOf() }.add(silId)
}
return grouped
}

open fun loadLotInfoBatch(inventoryLotIds: List<Long>): Map<Long, ItemLotTraceLotInfo> {
if (inventoryLotIds.isEmpty()) return emptyMap()
val query = """
SELECT
il.id AS inventoryLotId,
COALESCE(il.lotNo, '') AS lotNo,
it.id AS itemId,
COALESCE(it.code, '') AS itemCode,
COALESCE(it.name, '') AS itemName,
il.expiryDate AS expiryDate,
(SELECT sil.productionDate
FROM stock_in_line sil
WHERE sil.inventoryLotId = il.id
AND sil.jobOrderId IS NOT NULL
AND sil.deleted = 0
ORDER BY sil.receiptDate DESC, sil.id DESC
LIMIT 1) AS productionDate,
il.stockInDate AS stockInDate,
COALESCE(uc.udfudesc, '') AS uom,
il.stockInLineId AS primaryStockInLineId
FROM inventory_lot il
INNER JOIN items it ON il.itemId = it.id AND it.deleted = 0
LEFT JOIN item_uom iu ON it.id = iu.itemId AND iu.stockUnit = 1
LEFT JOIN uom_conversion uc ON iu.uomId = uc.id
WHERE il.id IN (:inventoryLotIds) AND il.deleted = 0
""".trimIndent()
return jdbcDao.queryForList(query, mapOf("inventoryLotIds" to inventoryLotIds))
.associate { row ->
val lotId = sql.longVal(row["inventoryLotId"]) ?: return@associate 0L to ItemLotTraceLotInfo(
inventoryLotId = 0L, lotNo = "", itemId = 0L, itemCode = "", itemName = "",
expiryDate = null, productionDate = null, stockInDate = null, uom = "",
primaryStockInLineId = null,
)
lotId to ItemLotTraceLotInfo(
inventoryLotId = lotId,
lotNo = sql.str(row["lotNo"]),
itemId = sql.longVal(row["itemId"]) ?: lotId,
itemCode = sql.str(row["itemCode"]),
itemName = sql.str(row["itemName"]),
expiryDate = sql.formatDate(row["expiryDate"]),
productionDate = sql.formatDateTime(row["productionDate"]),
stockInDate = sql.formatDateTime(row["stockInDate"]),
uom = sql.str(row["uom"]),
primaryStockInLineId = sql.longVal(row["primaryStockInLineId"]),
)
}
}

open fun resolveJobOrderIdForLot(inventoryLotId: Long, primaryStockInLineId: Long?): Long? {
primaryStockInLineId?.let { silId ->
jdbcDao.queryForList(
"""
SELECT jo.id AS jobOrderId FROM stock_in_line sil
INNER JOIN job_order jo ON sil.jobOrderId = jo.id AND jo.deleted = 0
WHERE sil.id = :primaryStockInLineId AND sil.deleted = 0
""".trimIndent(),
mapOf("primaryStockInLineId" to silId),
).firstOrNull()?.let { return sql.longVal(it["jobOrderId"]) }
}
val query = """
SELECT jo.id AS jobOrderId
FROM stock_in_line sil
INNER JOIN job_order jo ON sil.jobOrderId = jo.id AND jo.deleted = 0
LEFT JOIN inventory_lot_line ill ON sil.inventoryLotLineId = ill.id AND ill.deleted = 0
WHERE sil.deleted = 0
AND sil.jobOrderId IS NOT NULL
AND (
sil.inventoryLotId = :inventoryLotId
OR ill.inventoryLotId = :inventoryLotId
)
ORDER BY sil.created DESC
LIMIT 1
""".trimIndent()
return jdbcDao.queryForList(query, mapOf("inventoryLotId" to inventoryLotId))
.firstOrNull()?.let { sql.longVal(it["jobOrderId"]) }
}
}

+ 351
- 0
src/main/java/com/ffii/fpsms/modules/stock/trace/TraceMovementLoader.kt Datei anzeigen

@@ -0,0 +1,351 @@
package com.ffii.fpsms.modules.stock.trace

import com.ffii.core.support.JdbcDao
import com.ffii.fpsms.modules.stock.web.model.*
import org.springframework.stereotype.Component

@Component
open class TraceMovementLoader(
private val jdbcDao: JdbcDao,
private val sql: TraceSqlSupport,
) {
open fun loadMovements(inventoryLotId: Long, lotLineIds: List<Long>): List<ItemLotTraceMovement> {
if (lotLineIds.isEmpty()) return emptyList()
val query = """
SELECT * FROM (
SELECT
'IN' AS direction,
COALESCE(sil.acceptedQty, ill.inQty, 0) AS qty,
COALESCE(UPPER(sil.type), 'IN') AS movementType,
CASE
WHEN sil.purchaseOrderId IS NOT NULL THEN 'PO'
WHEN sil.jobOrderId IS NOT NULL THEN 'JO'
WHEN sil.stockTakeLineId IS NOT NULL THEN 'TKE'
WHEN sil.stockTransferId IS NOT NULL THEN 'TRANSFER'
WHEN UPPER(COALESCE(sil.type, '')) IN ('ADJ', 'OPEN') THEN UPPER(sil.type)
ELSE 'STOCK_IN'
END AS refType,
COALESCE(po.code, jo.code, st.code, si.code, '') AS refCode,
COALESCE(po.id, jo.id, st.id, si.id) AS refId,
COALESCE(wh.code, '') AS warehouseCode,
COALESCE(sil.createdBy, '') AS handledBy,
COALESCE(
(
SELECT MIN(jpo.created)
FROM pick_order po
INNER JOIN jo_pick_order jpo
ON jpo.pick_order_id = po.id AND jpo.deleted = 0
WHERE po.joId = sil.jobOrderId AND po.deleted = 0
),
sil.created,
sil.modified,
sil.receiptDate
) AS timestamp,
COALESCE(sil.remarks, '') AS remarks,
'' AS processingStatus,
'' AS matchStatus
FROM stock_in_line sil
LEFT JOIN stock_in si ON sil.stockInId = si.id AND si.deleted = 0
LEFT JOIN purchase_order po ON sil.purchaseOrderId = po.id AND po.deleted = 0
LEFT JOIN job_order jo ON sil.jobOrderId = jo.id AND jo.deleted = 0
LEFT JOIN stock_take_line stl ON sil.stockTakeLineId = stl.id AND stl.deleted = 0
LEFT JOIN stock_take st ON stl.stockTakeId = st.id AND st.deleted = 0
LEFT JOIN inventory_lot_line ill ON sil.inventoryLotLineId = ill.id AND ill.deleted = 0
LEFT JOIN warehouse wh ON ill.warehouseId = wh.id AND wh.deleted = 0
WHERE sil.deleted = 0
AND (
sil.inventoryLotId = :inventoryLotId
OR ill.inventoryLotId = :inventoryLotId
)

UNION ALL

SELECT
'OUT' AS direction,
COALESCE(sol.qty, 0) AS qty,
COALESCE(UPPER(so.type), UPPER(sol.type), 'OUT') AS movementType,
CASE
WHEN sol.pickOrderLineId IS NOT NULL AND UPPER(COALESCE(po.type, '')) = 'DO' THEN 'DO'
WHEN sol.pickOrderLineId IS NOT NULL AND UPPER(COALESCE(po.type, '')) = 'JO' THEN 'JO_PICK'
WHEN sol.pickOrderLineId IS NOT NULL AND po.joId IS NOT NULL THEN 'JO_PICK'
WHEN sol.pickOrderLineId IS NOT NULL AND po.doId IS NOT NULL THEN 'DO'
WHEN sol.pickOrderLineId IS NOT NULL AND dpo.id IS NOT NULL THEN 'DO'
WHEN sol.pickOrderLineId IS NOT NULL AND dpor.id IS NOT NULL THEN 'DO'
WHEN sol.pickOrderLineId IS NOT NULL THEN 'PO_PICK'
WHEN sol.stockTransferId IS NOT NULL THEN 'TRANSFER'
WHEN UPPER(COALESCE(so.type, '')) IN ('ADJ', 'TKE') THEN UPPER(so.type)
ELSE 'STOCK_OUT'
END AS refType,
COALESCE(po.code, do_tbl.code, so.consoPickOrderCode, so.deliveryOrderCode, '') AS refCode,
COALESCE(po.id, do_tbl.id, so.id) AS refId,
COALESCE(wh.code, '') AS warehouseCode,
COALESCE(picker_user.name, sol.createdBy, '') AS handledBy,
COALESCE(sol.endTime, sol.pickTime, sol.created) AS timestamp,
COALESCE(so.remarks, '') AS remarks,
CASE
WHEN sol.status = 'completed' THEN 'completed'
WHEN sol.status = 'rejected' THEN 'rejected'
WHEN sol.status = 'created' THEN 'pending'
ELSE 'pending'
END AS processingStatus,
COALESCE(jpo.match_status, '') AS matchStatus
FROM stock_out_line sol
INNER JOIN stock_out so ON sol.stockOutId = so.id AND so.deleted = 0
LEFT JOIN pick_order_line pol ON sol.pickOrderLineId = pol.id AND pol.deleted = 0
LEFT JOIN pick_order po ON pol.poId = po.id AND po.deleted = 0
LEFT JOIN do_pick_order dpo
ON po.id = dpo.pick_order_id AND dpo.deleted = 0
LEFT JOIN do_pick_order_record dpor
ON po.id = dpor.pick_order_id AND dpor.deleted = 0
LEFT JOIN delivery_order do_tbl
ON do_tbl.deleted = 0
AND do_tbl.id = COALESCE(po.doId, dpo.do_order_id, dpor.do_order_id)
LEFT JOIN inventory_lot_line ill ON sol.inventoryLotLineId = ill.id AND ill.deleted = 0
LEFT JOIN inventory_lot il ON ill.inventoryLotId = il.id AND il.deleted = 0
LEFT JOIN jo_pick_order jpo ON jpo.pick_order_id = po.id
AND jpo.item_id = il.itemId AND jpo.deleted = 0
LEFT JOIN warehouse wh ON ill.warehouseId = wh.id AND wh.deleted = 0
LEFT JOIN user picker_user ON sol.handled_by = picker_user.id AND picker_user.deleted = 0
WHERE sol.deleted = 0 AND sol.inventoryLotLineId IN (:lotLineIds)
) movements
ORDER BY timestamp DESC
""".trimIndent()
return jdbcDao.queryForList(
query,
mapOf("inventoryLotId" to inventoryLotId, "lotLineIds" to lotLineIds),
).map { row ->
fun col(vararg keys: String): Any? {
for (k in keys) {
if (row.containsKey(k)) return row[k]
}
val lower = row.keys.associateBy { it.lowercase() }
for (k in keys) {
lower[k.lowercase()]?.let { return row[it] }
}
return null
}
ItemLotTraceMovement(
direction = sql.str(col("direction")),
qty = sql.decimal(col("qty")),
movementType = sql.str(col("movementType")),
refType = sql.str(col("refType")),
refCode = sql.str(col("refCode")),
refId = sql.longVal(col("refId")),
warehouseCode = sql.str(col("warehouseCode")),
handledBy = sql.str(col("handledBy")),
timestamp = sql.formatDateTime(col("timestamp")),
remarks = sql.str(col("remarks")),
processingStatus = sql.str(col("processingStatus")),
matchStatus = sql.str(col("matchStatus")),
)
}
}

/** FP-MTMS Version Checklist | Functions Ref. No. 25 | v1.0.0 | 2026-07-20 */
open fun loadStockTakeEvents(lotLineIds: List<Long>): List<ItemLotTraceStockTakeEvent> {
if (lotLineIds.isEmpty()) return emptyList()
val query = """
SELECT
stl.id AS stockTakeLineId,
COALESCE(st.code, '') AS stockTakeCode,
COALESCE(il.lotNo, str.lotNo, '') AS lotNo,
COALESCE(it.code, str.itemCode, '') AS itemCode,
COALESCE(it.name, str.itemName, '') AS itemName,
COALESCE(wh.code, '') AS warehouseCode,
COALESCE(NULLIF(st.stockTakeSection, ''), NULLIF(str.stockTakeSection, ''), '') AS stockTakeSection,
COALESCE(st.stockTakeRoundId, str.stockTakeRoundId) AS stockTakeRoundId,
COALESCE(NULLIF(st.stockTakeRoundName, ''), '') AS stockTakeRoundName,
COALESCE(str.varianceQty, stl.finalQty - stl.initialQty) AS varianceQty,
stl.initialQty AS beforeQty,
stl.finalQty AS afterQty,
COALESCE(NULLIF(str.approverName, ''), NULLIF(str.stockTakerName, ''), '') AS approver,
COALESCE(str.approverTime, stl.completeDate, stl.modified) AS timestamp,
COALESCE(str.bookQty, 0) AS bookQty,
str.pickerFirstStockTakeQty AS pickerFirstQty,
str.pickerFirstBadQty AS pickerFirstBadQty,
str.pickerSecondStockTakeQty AS pickerSecondQty,
str.pickerSecondBadQty AS pickerSecondBadQty,
str.approverStockTakeQty AS approverQty,
str.approverBadQty AS approverBadQty,
str.stockTakerName AS stockTakerName,
NULLIF(str.approverName, '') AS approverName,
str.stockTakeStartTime AS stockTakeStartTime,
str.stockTakeEndTime AS stockTakeEndTime,
str.approverTime AS approverTime,
str.status AS recordStatus,
str.lastSelect AS lastSelect,
str.varianceQty AS recordVarianceQty,
str.id AS stockTakeRecordId
FROM stock_take_line stl
INNER JOIN stock_take st ON stl.stockTakeId = st.id AND st.deleted = 0
LEFT JOIN stocktakerecord str ON stl.stockTakeRecordId = str.id AND str.deleted = 0
LEFT JOIN inventory_lot_line ill ON stl.inventoryLotLineId = ill.id AND ill.deleted = 0
LEFT JOIN inventory_lot il ON ill.inventoryLotId = il.id AND il.deleted = 0
LEFT JOIN items it ON il.itemId = it.id AND it.deleted = 0
LEFT JOIN warehouse wh ON ill.warehouseId = wh.id AND wh.deleted = 0
WHERE stl.deleted = 0 AND stl.inventoryLotLineId IN (:lotLineIds)
ORDER BY timestamp DESC
""".trimIndent()
return jdbcDao.queryForList(query, mapOf("lotLineIds" to lotLineIds)).map { row ->
val stockTakeLineId = sql.longVal(row["stockTakeLineId"])
val hasRecordDetail = sql.longVal(row["stockTakeRecordId"]) != null
ItemLotTraceStockTakeEvent(
stockTakeCode = sql.str(row["stockTakeCode"]),
lotNo = sql.str(row["lotNo"]),
itemCode = sql.str(row["itemCode"]),
itemName = sql.str(row["itemName"]),
warehouseCode = sql.str(row["warehouseCode"]),
stockTakeSection = sql.str(row["stockTakeSection"]),
stockTakeRoundId = sql.longVal(row["stockTakeRoundId"]),
stockTakeRoundName = sql.str(row["stockTakeRoundName"]),
varianceQty = sql.decimalOrNull(row["varianceQty"]) ?: java.math.BigDecimal.ZERO,
beforeQty = sql.decimalOrNull(row["beforeQty"]) ?: java.math.BigDecimal.ZERO,
afterQty = sql.decimalOrNull(row["afterQty"]),
approver = sql.str(row["approver"]),
timestamp = sql.formatDateTime(row["timestamp"]),
recordDetail = if (hasRecordDetail) {
ItemLotTraceStockTakeRecordDetail(
stockTakeLineId = stockTakeLineId ?: 0L,
bookQty = sql.decimalOrNull(row["bookQty"]) ?: java.math.BigDecimal.ZERO,
pickerFirstQty = sql.decimalOrNull(row["pickerFirstQty"]),
pickerFirstBadQty = sql.decimalOrNull(row["pickerFirstBadQty"]),
pickerSecondQty = sql.decimalOrNull(row["pickerSecondQty"]),
pickerSecondBadQty = sql.decimalOrNull(row["pickerSecondBadQty"]),
approverQty = sql.decimalOrNull(row["approverQty"]),
approverBadQty = sql.decimalOrNull(row["approverBadQty"]),
stockTakerName = sql.strOrNull(row["stockTakerName"]),
approverName = sql.strOrNull(row["approverName"]),
stockTakeStartTime = sql.formatDateTime(row["stockTakeStartTime"]),
stockTakeEndTime = sql.formatDateTime(row["stockTakeEndTime"]),
approverTime = sql.formatDateTime(row["approverTime"]),
recordStatus = sql.strOrNull(row["recordStatus"]),
lastSelect = sql.intVal(row["lastSelect"]),
varianceQty = sql.decimalOrNull(row["recordVarianceQty"]),
)
} else null,
)
}
}

open fun loadAdjustments(inventoryLotId: Long, lotLineIds: List<Long>): List<ItemLotTraceAdjustment> {
if (lotLineIds.isEmpty()) return emptyList()
val query = """
SELECT * FROM (
SELECT
COALESCE(UPPER(sil.type), 'ADJ') AS adjustmentType,
'IN' AS direction,
COALESCE(sil.acceptedQty, 0) AS qty,
COALESCE(sil.remarks, '') AS reason,
COALESCE(sil.createdBy, '') AS handledBy,
COALESCE(sil.created, sil.modified, sil.receiptDate) AS timestamp,
COALESCE(si.code, '') AS refCode,
COALESCE(wh.code, '') AS warehouseCode
FROM stock_in_line sil
INNER JOIN stock_in si ON sil.stockInId = si.id AND si.deleted = 0
LEFT JOIN inventory_lot_line ill ON sil.inventoryLotLineId = ill.id AND ill.deleted = 0
LEFT JOIN warehouse wh ON ill.warehouseId = wh.id AND wh.deleted = 0
WHERE sil.deleted = 0 AND sil.inventoryLotId = :inventoryLotId
AND UPPER(COALESCE(sil.type, '')) = 'ADJ'

UNION ALL

SELECT
COALESCE(UPPER(so.type), 'ADJ') AS adjustmentType,
'OUT' AS direction,
COALESCE(sol.qty, 0) AS qty,
COALESCE(so.remarks, '') AS reason,
COALESCE(sol.createdBy, '') AS handledBy,
COALESCE(sol.endTime, sol.created) AS timestamp,
COALESCE(so.consoPickOrderCode, so.deliveryOrderCode, '') AS refCode,
COALESCE(wh.code, '') AS warehouseCode
FROM stock_out_line sol
INNER JOIN stock_out so ON sol.stockOutId = so.id AND so.deleted = 0
LEFT JOIN inventory_lot_line ill ON sol.inventoryLotLineId = ill.id AND ill.deleted = 0
LEFT JOIN warehouse wh ON ill.warehouseId = wh.id AND wh.deleted = 0
WHERE sol.deleted = 0 AND sol.inventoryLotLineId IN (:lotLineIds)
AND UPPER(COALESCE(so.type, sol.type, '')) IN ('ADJ', 'TKE')
) adj
ORDER BY timestamp DESC
""".trimIndent()
return jdbcDao.queryForList(
query,
mapOf("inventoryLotId" to inventoryLotId, "lotLineIds" to lotLineIds),
).map { row ->
ItemLotTraceAdjustment(
adjustmentType = sql.str(row["adjustmentType"]),
direction = sql.str(row["direction"]),
qty = sql.decimal(row["qty"]),
reason = sql.str(row["reason"]),
handledBy = sql.str(row["handledBy"]),
timestamp = sql.formatDateTime(row["timestamp"]),
refCode = sql.str(row["refCode"]),
warehouseCode = sql.str(row["warehouseCode"]),
)
}
}

open fun loadTransfers(inventoryLotId: Long): List<ItemLotTraceTransfer> {
val query = """
SELECT
COALESCE(str.startLocation, wh_from.code, '') AS fromWarehouse,
COALESCE(str.targetLocation, wh_to.code, '') AS toWarehouse,
COALESCE(str.transferredQty, 0) AS qty,
CONCAT('TR-', str.id) AS transferCode,
COALESCE(str.created, str.modified, sil.created, sil.modified, sil.receiptDate) AS timestamp
FROM stock_transfer_record str
LEFT JOIN stock_in_line sil ON str.stockInLineId = sil.id AND sil.deleted = 0
LEFT JOIN stock_out_line sol ON str.stockOutLineId = sol.id AND sol.deleted = 0
LEFT JOIN inventory_lot_line ill_from ON sol.inventoryLotLineId = ill_from.id AND ill_from.deleted = 0
LEFT JOIN warehouse wh_from ON ill_from.warehouseId = wh_from.id AND wh_from.deleted = 0
LEFT JOIN inventory_lot_line ill_to ON sil.inventoryLotLineId = ill_to.id AND ill_to.deleted = 0
LEFT JOIN warehouse wh_to ON ill_to.warehouseId = wh_to.id AND wh_to.deleted = 0
WHERE str.deleted = 0
AND (
sil.inventoryLotId = :inventoryLotId
OR ill_from.inventoryLotId = :inventoryLotId
)
ORDER BY timestamp DESC
""".trimIndent()
return jdbcDao.queryForList(query, mapOf("inventoryLotId" to inventoryLotId)).map { row ->
ItemLotTraceTransfer(
fromWarehouse = sql.str(row["fromWarehouse"]),
toWarehouse = sql.str(row["toWarehouse"]),
qty = sql.decimal(row["qty"]),
transferCode = sql.str(row["transferCode"]),
timestamp = sql.formatDateTime(row["timestamp"]),
)
}
}

open fun loadOpenMovements(inventoryLotId: Long): List<ItemLotTraceOpenMovement> {
val query = """
SELECT
sil.id AS stockInLineId,
COALESCE(sil.acceptedQty, 0) AS qty,
COALESCE(wh.code, '') AS warehouseCode,
COALESCE(si.code, '') AS refCode,
COALESCE(sil.createdBy, '') AS handledBy,
COALESCE(sil.created, sil.modified, sil.receiptDate) AS timestamp,
COALESCE(sil.remarks, '') AS remarks
FROM stock_in_line sil
INNER JOIN stock_in si ON sil.stockInId = si.id AND si.deleted = 0
LEFT JOIN inventory_lot_line ill ON sil.inventoryLotLineId = ill.id AND ill.deleted = 0
LEFT JOIN warehouse wh ON ill.warehouseId = wh.id AND wh.deleted = 0
WHERE sil.deleted = 0
AND sil.inventoryLotId = :inventoryLotId
AND UPPER(COALESCE(sil.type, '')) = 'OPEN'
ORDER BY timestamp DESC
""".trimIndent()
return jdbcDao.queryForList(query, mapOf("inventoryLotId" to inventoryLotId)).map { row ->
ItemLotTraceOpenMovement(
stockInLineId = sql.longVal(row["stockInLineId"]) ?: 0L,
qty = sql.decimal(row["qty"]),
warehouseCode = sql.str(row["warehouseCode"]),
refCode = sql.str(row["refCode"]),
handledBy = sql.str(row["handledBy"]),
timestamp = sql.formatDateTime(row["timestamp"]),
remarks = sql.str(row["remarks"]),
)
}
}
}

+ 431
- 0
src/main/java/com/ffii/fpsms/modules/stock/trace/TraceOutboundLoader.kt Datei anzeigen

@@ -0,0 +1,431 @@
package com.ffii.fpsms.modules.stock.trace

import com.ffii.core.support.JdbcDao
import com.ffii.fpsms.modules.deliveryOrder.service.DeliveryOrderService
import com.ffii.fpsms.modules.deliveryOrder.service.DoReplenishmentService
import com.ffii.fpsms.modules.stock.web.model.*
import org.springframework.stereotype.Component

@Component
open class TraceOutboundLoader(
private val jdbcDao: JdbcDao,
private val sql: TraceSqlSupport,
private val deliveryOrderService: DeliveryOrderService,
private val doReplenishmentService: DoReplenishmentService,
) {
open fun loadOutboundUsage(inventoryLotId: Long): List<ItemLotTraceOutboundUsage> {
val materialSql = """
SELECT
sol.id AS stockOutLineId,
COALESCE(sol.qty, 0) AS qty,
COALESCE(po.code, '') AS pickOrderCode,
po.id AS pickOrderId,
COALESCE(po.consoCode, '') AS consoCode,
COALESCE(jo.code, '') AS jobOrderCode,
jo.id AS jobOrderId,
'' AS deliveryOrderCode,
COALESCE(picker_user.name, jpo_handler_user.name, sol.createdBy, '') AS handler,
COALESCE(sol.endTime, sol.pickTime, sol.created) AS timestamp,
'MATERIAL' AS usageType
FROM stock_out_line sol
INNER JOIN stock_out so ON sol.stockOutId = so.id AND so.deleted = 0
AND so.type IN ('job', 'jo')
INNER JOIN inventory_lot_line ill ON sol.inventoryLotLineId = ill.id AND ill.deleted = 0
INNER JOIN inventory_lot il ON ill.inventoryLotId = il.id AND il.deleted = 0
LEFT JOIN pick_order_line pol ON sol.pickOrderLineId = pol.id AND pol.deleted = 0
LEFT JOIN pick_order po ON pol.poId = po.id AND po.deleted = 0
LEFT JOIN job_order jo ON po.joId = jo.id AND jo.deleted = 0
LEFT JOIN user picker_user ON sol.handled_by = picker_user.id AND picker_user.deleted = 0
LEFT JOIN jo_pick_order jpo ON po.id = jpo.pick_order_id AND pol.itemId = jpo.item_id AND jpo.deleted = 0
LEFT JOIN user jpo_handler_user ON jpo.handled_by = jpo_handler_user.id AND jpo_handler_user.deleted = 0
WHERE sol.deleted = 0 AND il.id = :inventoryLotId
""".trimIndent()

val fgSql = """
SELECT
sol.id AS stockOutLineId,
COALESCE(sol.qty, 0) AS qty,
COALESCE(po.code, '') AS pickOrderCode,
po.id AS pickOrderId,
COALESCE(po.consoCode, '') AS consoCode,
'' AS jobOrderCode,
NULL AS jobOrderId,
COALESCE(do_tbl.code, '') AS deliveryOrderCode,
COALESCE(picker_user.name, sol.createdBy, '') AS handler,
COALESCE(sol.endTime, sol.pickTime, sol.created) AS timestamp,
'FG_DELIVERY' AS usageType
FROM stock_out_line sol
INNER JOIN stock_out so ON sol.stockOutId = so.id AND so.deleted = 0 AND so.type = 'do'
INNER JOIN inventory_lot_line ill ON sol.inventoryLotLineId = ill.id AND ill.deleted = 0
INNER JOIN inventory_lot il ON ill.inventoryLotId = il.id AND il.deleted = 0
LEFT JOIN pick_order_line pol ON sol.pickOrderLineId = pol.id AND pol.deleted = 0
LEFT JOIN pick_order po ON pol.poId = po.id AND po.deleted = 0
LEFT JOIN delivery_order do_tbl ON po.doId = do_tbl.id AND do_tbl.deleted = 0
LEFT JOIN user picker_user ON sol.handled_by = picker_user.id AND picker_user.deleted = 0
WHERE sol.deleted = 0 AND il.id = :inventoryLotId
""".trimIndent()

val args = mapOf("inventoryLotId" to inventoryLotId)
val material = jdbcDao.queryForList(materialSql, args)
val fg = jdbcDao.queryForList(fgSql, args)
return (material + fg).map { row -> mapOutboundUsage(row) }
}

open fun mapOutboundUsage(row: Map<String, Any?>): ItemLotTraceOutboundUsage =
ItemLotTraceOutboundUsage(
stockOutLineId = sql.longVal(row["stockOutLineId"]) ?: 0L,
qty = sql.decimal(row["qty"]),
pickOrderCode = sql.str(row["pickOrderCode"]),
pickOrderId = sql.longVal(row["pickOrderId"]),
consoCode = sql.str(row["consoCode"]),
jobOrderCode = sql.str(row["jobOrderCode"]),
jobOrderId = sql.longVal(row["jobOrderId"]),
deliveryOrderCode = sql.str(row["deliveryOrderCode"]),
handler = sql.str(row["handler"]),
timestamp = sql.formatDateTime(row["timestamp"]),
usageType = sql.str(row["usageType"]),
)

private fun doDeliverySelectSql(): String = """
SELECT
sol.id AS stockOutLineId,
COALESCE(sol.qty, 0) AS qty,
COALESCE(po.code, '') AS pickOrderCode,
po.id AS pickOrderId,
COALESCE(po.consoCode, '') AS consoCode,
COALESCE(do_tbl.code, so.deliveryOrderCode, dpor.delivery_order_code, '') AS deliveryOrderCode,
do_tbl.id AS deliveryOrderId,
COALESCE(do_tbl.isExtra, 0) AS deliveryOrderIsExtra,
pol.id AS pickOrderLineId,
pol.qty AS pickOrderLineQty,
(
SELECT COALESCE(SUM(sol_all.qty), 0)
FROM stock_out_line sol_all
WHERE sol_all.pickOrderLineId = pol.id
AND sol_all.deleted = 0
) AS pickOrderLinePickedQty,
il.itemId AS itemId,
COALESCE(dop_direct.ticketNo, dop_conso.ticketNo) AS dopTicketNo,
COALESCE(dpo.ticket_no, dpor.ticket_no, '') AS legacyTicketNo,
COALESCE(
dop_direct.releaseType,
dop_conso.releaseType,
dpo.release_type,
dpor.release_type,
''
) AS releaseType,
COALESCE(dop_direct.id, dop_conso.id) AS deliveryOrderPickOrderId,
COALESCE(dop_direct.relationshipId, dop_conso.relationshipId) AS relationshipId,
COALESCE(
dop_direct.deliveryNoteCode,
dop_conso.deliveryNoteCode,
dpor.deliveryNoteCode,
''
) AS deliveryNoteCode,
dpor.id AS doPickOrderRecordId,
COALESCE(po.consoCode, '') AS pickOrderConsoCode,
COALESCE(so.consoPickOrderCode, '') AS stockOutConsoCode,
COALESCE(shop_tbl.code, dpo.ShopCode, dpor.ShopCode, '') AS shopCode,
COALESCE(shop_tbl.name, dpo.ShopName, dpor.ShopName, '') AS shopName,
COALESCE(picker_user.name, sol.createdBy, '') AS handler,
COALESCE(sol.endTime, sol.pickTime, sol.created) AS timestamp
FROM stock_out_line sol
INNER JOIN stock_out so ON sol.stockOutId = so.id AND so.deleted = 0
INNER JOIN inventory_lot_line ill ON sol.inventoryLotLineId = ill.id AND ill.deleted = 0
INNER JOIN inventory_lot il ON ill.inventoryLotId = il.id AND il.deleted = 0
LEFT JOIN pick_order_line pol ON sol.pickOrderLineId = pol.id AND pol.deleted = 0
LEFT JOIN pick_order po ON pol.poId = po.id AND po.deleted = 0
LEFT JOIN do_pick_order dpo
ON po.id = dpo.pick_order_id AND dpo.deleted = 0
LEFT JOIN do_pick_order_record dpor
ON po.id = dpor.pick_order_id AND dpor.deleted = 0
LEFT JOIN delivery_order do_tbl
ON do_tbl.deleted = 0
AND do_tbl.id = COALESCE(po.doId, dpo.do_order_id, dpor.do_order_id)
LEFT JOIN delivery_order_pick_order dop_direct
ON po.deliveryOrderPickOrderId = dop_direct.id AND dop_direct.deleted = 0
LEFT JOIN delivery_order_pick_order dop_conso
ON po.consoCode = dop_conso.ticketNo AND dop_conso.deleted = 0
LEFT JOIN shop shop_tbl ON do_tbl.shopId = shop_tbl.id AND shop_tbl.deleted = 0
LEFT JOIN user picker_user ON sol.handled_by = picker_user.id AND picker_user.deleted = 0
""".trimIndent()

/** FP-MTMS Version Checklist | Functions Ref. No. 24 | v1.0.0 | 2026-07-20 */
open fun loadDoDeliveries(inventoryLotId: Long): List<ItemLotTraceDoDelivery> {
val query = """
${doDeliverySelectSql()}
WHERE sol.deleted = 0 AND il.id = :inventoryLotId
AND (
UPPER(COALESCE(so.type, '')) = 'DO'
OR po.doId IS NOT NULL
OR dpo.id IS NOT NULL
OR dpor.id IS NOT NULL
)
ORDER BY timestamp DESC
""".trimIndent()
return mapDoDeliveryRows(
jdbcDao.queryForList(query, mapOf("inventoryLotId" to inventoryLotId)),
)
}

/**
* FP-MTMS Version Checklist | Functions Ref. No. 24 | v1.0.0 | 2026-07-20
* Fallback when lot-scoped query misses DO picks still present in movements.
*/
open fun loadDoDeliveriesByPickOrderCodes(pickOrderCodes: List<String>): List<ItemLotTraceDoDelivery> {
val codes = pickOrderCodes.map { it.trim() }.filter { it.isNotEmpty() }.distinct()
if (codes.isEmpty()) return emptyList()
val query = """
${doDeliverySelectSql()}
WHERE sol.deleted = 0
AND po.code IN (:pickOrderCodes)
AND (
UPPER(COALESCE(so.type, '')) = 'DO'
OR po.doId IS NOT NULL
OR dpo.id IS NOT NULL
OR dpor.id IS NOT NULL
)
ORDER BY timestamp DESC
""".trimIndent()
return mapDoDeliveryRows(jdbcDao.queryForList(query, mapOf("pickOrderCodes" to codes)))
}

private fun mapDoDeliveryRows(rows: List<Map<String, Any?>>): List<ItemLotTraceDoDelivery> {
val deliveryOrderIds = rows.mapNotNull { sql.longVal(it["deliveryOrderId"]) }.distinct()
val replenishIndex = doReplenishmentService.buildReplenishPdfIndex(deliveryOrderIds)
return rows.map { row -> mapDoDelivery(row, replenishIndex) }
}

/** FP-MTMS Version Checklist | Functions Ref. No. 24 | v1.0.0 | 2026-07-20 */
private fun mapDoDelivery(
row: Map<String, Any?>,
replenishIndex: DoReplenishmentService.ReplenishPdfIndex,
): ItemLotTraceDoDelivery {
val deliveryOrderId = sql.longVal(row["deliveryOrderId"])
val pickOrderLineId = sql.longVal(row["pickOrderLineId"])
val itemId = sql.longVal(row["itemId"])
val ticketNo = resolveOutboundTicketNo(
dopTicketNo = sql.str(row["dopTicketNo"]).takeIf { it.isNotBlank() },
legacyTicketNo = sql.str(row["legacyTicketNo"]).takeIf { it.isNotBlank() },
pickOrderConsoCode = sql.str(row["pickOrderConsoCode"]).takeIf { it.isNotBlank() },
stockOutConsoCode = sql.str(row["stockOutConsoCode"]).takeIf { it.isNotBlank() },
)
val releaseType = sql.str(row["releaseType"]).takeIf { it.isNotBlank() }
val deliveryOrderIsExtra = truthy(row["deliveryOrderIsExtra"])
val pickOrderConsoCode = sql.str(row["pickOrderConsoCode"]).takeIf { it.isNotBlank() }
val dopId = sql.longVal(row["deliveryOrderPickOrderId"])
val relationshipId = sql.longVal(row["relationshipId"])
val isExtra = resolveDoOutboundIsExtra(
ticketNo = ticketNo,
pickOrderConsoCode = pickOrderConsoCode,
releaseType = releaseType,
deliveryOrderIsExtra = deliveryOrderIsExtra,
dopId = dopId,
relationshipId = relationshipId,
)
val isReplenish = deliveryOrderId != null &&
replenishIndex.matches(deliveryOrderId, itemId, pickOrderLineId)
// Multi-lot picks: compare POL.qty to SUM(SOL.qty) for that line, not a single SOL.
val pickOrderLineQty = sql.decimalOrNull(row["pickOrderLineQty"])
val pickOrderLinePickedQty = sql.decimalOrNull(row["pickOrderLinePickedQty"])
val qtyChanged =
pickOrderLineId != null &&
pickOrderLineQty != null &&
pickOrderLinePickedQty != null &&
pickOrderLineQty.compareTo(pickOrderLinePickedQty) != 0
return ItemLotTraceDoDelivery(
stockOutLineId = sql.longVal(row["stockOutLineId"]) ?: 0L,
qty = sql.decimal(row["qty"]),
pickOrderCode = sql.str(row["pickOrderCode"]),
pickOrderId = sql.longVal(row["pickOrderId"]),
consoCode = sql.str(row["consoCode"]),
ticketNo = ticketNo.orEmpty(),
deliveryOrderCode = sql.str(row["deliveryOrderCode"]),
deliveryOrderId = deliveryOrderId,
shopCode = sql.str(row["shopCode"]),
shopName = sql.str(row["shopName"]),
handler = sql.str(row["handler"]),
timestamp = sql.formatDateTime(row["timestamp"]),
releaseType = releaseType,
deliveryOrderPickOrderId = dopId,
relationshipId = relationshipId,
deliveryNoteCode = sql.str(row["deliveryNoteCode"]),
doPickOrderRecordId = sql.longVal(row["doPickOrderRecordId"]),
isExtra = isExtra,
isReplenish = isReplenish,
qtyChanged = qtyChanged,
)
}

private fun truthy(v: Any?): Boolean = when (v) {
null -> false
is Boolean -> v
is Number -> v.toInt() != 0
else -> v.toString().equals("true", ignoreCase = true) || v.toString() == "1"
}

companion object {
/**
* Ticket priority: workbench [delivery_order_pick_order] → legacy GoodPick ticket
* ([do_pick_order] / [do_pick_order_record]) → TI-* conso → other conso.
*/
internal fun resolveOutboundTicketNo(
dopTicketNo: String?,
legacyTicketNo: String? = null,
pickOrderConsoCode: String?,
stockOutConsoCode: String?,
): String? {
dopTicketNo?.trim()?.takeIf { it.isNotEmpty() }?.let { return it }
legacyTicketNo?.trim()?.takeIf { it.isNotEmpty() }?.let { return it }
return sequenceOf(pickOrderConsoCode, stockOutConsoCode)
.mapNotNull { it?.trim()?.takeIf { s -> s.isNotEmpty() } }
.firstOrNull { it.startsWith("TI-", ignoreCase = true) }
?: pickOrderConsoCode?.trim()?.takeIf { it.isNotEmpty() }
?: stockOutConsoCode?.trim()?.takeIf { it.isNotEmpty() }
}

internal fun isExtraReleaseType(releaseType: String?): Boolean =
when (releaseType?.trim()?.lowercase()) {
"isextra", "isextrabatch", "isextrasingle" -> true
else -> false
}

internal fun parseTicketTypeLetter(ticketNo: String?): String? {
val parts = ticketNo?.trim()?.split("-").orEmpty()
return parts.getOrNull(1)
}

internal fun isTiETicket(ticketNo: String?): Boolean =
parseTicketTypeLetter(ticketNo)?.equals("E", ignoreCase = true) == true ||
ticketNo?.trim()?.startsWith("TI-E-", ignoreCase = true) == true

internal fun isTiMTicket(ticketNo: String?): Boolean =
ticketNo?.trim()?.startsWith("TI-M-", ignoreCase = true) == true

/**
* Aligns with [DeliveryOrderService.isExtraDeliveryTicket]:
* - TI-E / conso TI-E- → extra
* - TI-M → per DO [isExtra], conso TI-E-, or retired TI-E lineage via [relationshipId]
* - Other tickets → releaseType / DO flag
*/
internal fun resolveDoOutboundIsExtra(
ticketNo: String?,
pickOrderConsoCode: String?,
releaseType: String?,
deliveryOrderIsExtra: Boolean,
dopId: Long?,
relationshipId: Long?,
): Boolean {
val resolvedTicket = ticketNo?.trim()?.takeIf { it.isNotEmpty() }
val conso = pickOrderConsoCode?.trim()?.takeIf { it.isNotEmpty() }
val consoIsTiE = isTiETicket(conso)

if (isTiMTicket(resolvedTicket)) {
if (deliveryOrderIsExtra) return true
if (consoIsTiE) return true
if (dopId != null && relationshipId != null && relationshipId != dopId) return true
return false
}

if (isTiETicket(resolvedTicket) || consoIsTiE) return true

if (isExtraReleaseType(releaseType)) return true
return deliveryOrderIsExtra
}
}

open fun loadReplenishmentEvents(inventoryLotId: Long): List<ItemLotTraceReplenishmentEvent> {
val query = """
SELECT
dr.id AS replenishmentId,
dr.code AS replenishmentCode,
COALESCE(dr.sourceDoCode, '') AS sourceDoCode,
dr.sourceDoId AS sourceDoId,
COALESCE(dr.shopCode, '') AS shopCode,
COALESCE(dr.shopName, '') AS shopName,
COALESCE(dr.itemNo, '') AS itemNo,
COALESCE(dr.itemName, '') AS itemName,
dr.itemId AS itemId,
COALESCE(dr.reason, '') AS reason,
COALESCE(dr.replenishQty, 0) AS replenishQty,
dr.pickOrderLineId AS pickOrderLineId,
dr.created AS timestamp,
COALESCE(dr.createdBy, '') AS handler,
sol.id AS stockOutLineId
FROM stock_out_line sol
INNER JOIN inventory_lot_line ill ON sol.inventoryLotLineId = ill.id AND ill.deleted = 0
INNER JOIN inventory_lot il ON ill.inventoryLotId = il.id AND il.deleted = 0
INNER JOIN pick_order_line pol ON sol.pickOrderLineId = pol.id AND pol.deleted = 0
INNER JOIN do_replenishment dr ON dr.pickOrderLineId = pol.id AND dr.deleted = 0
AND dr.itemId = il.itemId
WHERE sol.deleted = 0 AND il.id = :inventoryLotId
ORDER BY dr.created DESC
""".trimIndent()
return jdbcDao.queryForList(query, mapOf("inventoryLotId" to inventoryLotId))
.map { row -> mapReplenishmentEvent(row) }
}

private fun mapReplenishmentEvent(row: Map<String, Any?>): ItemLotTraceReplenishmentEvent =
ItemLotTraceReplenishmentEvent(
replenishmentId = sql.longVal(row["replenishmentId"]) ?: 0L,
replenishmentCode = sql.str(row["replenishmentCode"]),
sourceDoCode = sql.str(row["sourceDoCode"]),
sourceDoId = sql.longVal(row["sourceDoId"]),
shopCode = sql.str(row["shopCode"]),
shopName = sql.str(row["shopName"]),
itemNo = sql.str(row["itemNo"]),
itemName = sql.str(row["itemName"]),
reason = sql.str(row["reason"]),
replenishQty = sql.decimal(row["replenishQty"]),
handler = sql.str(row["handler"]),
timestamp = sql.formatDateTime(row["timestamp"]),
pickOrderLineId = sql.longVal(row["pickOrderLineId"]),
stockOutLineId = sql.longVal(row["stockOutLineId"]),
)

open fun loadReturnEvents(inventoryLotId: Long, lotLineIds: List<Long>): List<ItemLotTraceReturnEvent> {
if (lotLineIds.isEmpty()) return emptyList()
val query = """
SELECT
sol.id AS stockOutLineId,
COALESCE(sol.qty, 0) AS qty,
COALESCE(so.consoPickOrderCode, so.deliveryOrderCode, '') AS refCode,
COALESCE(UPPER(so.type), UPPER(sol.type), 'RETURN') AS movementType,
COALESCE(wh.code, '') AS warehouseCode,
COALESCE(picker_user.name, sol.createdBy, '') AS handler,
COALESCE(sol.endTime, sol.pickTime, sol.created) AS timestamp,
COALESCE(so.remarks, '') AS remarks
FROM stock_out_line sol
INNER JOIN stock_out so ON sol.stockOutId = so.id AND so.deleted = 0
INNER JOIN inventory_lot_line ill ON sol.inventoryLotLineId = ill.id AND ill.deleted = 0
LEFT JOIN warehouse wh ON ill.warehouseId = wh.id AND wh.deleted = 0
LEFT JOIN user picker_user ON sol.handled_by = picker_user.id AND picker_user.deleted = 0
WHERE sol.deleted = 0
AND ill.inventoryLotId = :inventoryLotId
AND sol.inventoryLotLineId IN (:lotLineIds)
AND (
UPPER(COALESCE(so.type, '')) IN ('RETURN', 'RTN', 'SUPPLIER_RETURN')
OR UPPER(COALESCE(sol.type, '')) IN ('RETURN', 'RTN')
OR COALESCE(so.remarks, '') LIKE '%退貨%'
OR COALESCE(so.remarks, '') LIKE '%退货%'
OR COALESCE(so.remarks, '') LIKE '%return%'
)
ORDER BY timestamp DESC
""".trimIndent()
return jdbcDao.queryForList(
query,
mapOf("inventoryLotId" to inventoryLotId, "lotLineIds" to lotLineIds),
).map { row ->
ItemLotTraceReturnEvent(
stockOutLineId = sql.longVal(row["stockOutLineId"]) ?: 0L,
qty = sql.decimal(row["qty"]),
refCode = sql.str(row["refCode"]),
movementType = sql.str(row["movementType"]),
warehouseCode = sql.str(row["warehouseCode"]),
handler = sql.str(row["handler"]),
timestamp = sql.formatDateTime(row["timestamp"]),
remarks = sql.str(row["remarks"]),
)
}
}
}

+ 120
- 0
src/main/java/com/ffii/fpsms/modules/stock/trace/TraceProductionLoader.kt Datei anzeigen

@@ -0,0 +1,120 @@
package com.ffii.fpsms.modules.stock.trace

import com.ffii.core.support.JdbcDao
import com.ffii.fpsms.modules.stock.web.model.*
import org.springframework.stereotype.Component

@Component
open class TraceProductionLoader(
private val jdbcDao: JdbcDao,
private val sql: TraceSqlSupport,
private val bomLoader: TraceBomLoader,
) {
open fun loadProductionSteps(jobOrderId: Long): List<ItemLotTraceProductionStep> {
val stepMaterials = bomLoader.loadStepMaterialsByJobOrder(jobOrderId)
val query = """
SELECT
ppl.id AS processLineId,
ppl.bomProcessId AS bomProcessId,
COALESCE(ppl.name, '') AS stepName,
COALESCE(ppl.description, '') AS description,
COALESCE(e.code, '') AS equipmentCode,
COALESCE(e.name, ppl.equipment_name, '') AS equipmentName,
COALESCE(u.name, '') AS operatorName,
ppl.startTime AS startTime,
ppl.endTime AS endTime,
COALESCE(ppl.outputFromProcessQty, 0) AS outputQty,
COALESCE(ppl.scrapQty, 0) AS scrapQty,
(
COALESCE(ppl.defectQty, 0)
+ COALESCE(ppl.defectQty2, 0)
+ COALESCE(ppl.defectQty3, 0)
) AS defectQty,
COALESCE(ppl.status, '') AS status,
ppl.seqNo AS seqNo
FROM productprocess pp
INNER JOIN productprocessline ppl ON ppl.productprocessid = pp.id AND ppl.deleted = 0
LEFT JOIN equipment e ON ppl.equipmentId = e.id AND e.deleted = 0
LEFT JOIN user u ON ppl.operatorId = u.id AND u.deleted = 0
WHERE pp.jobOrderId = :jobOrderId AND pp.deleted = 0
ORDER BY ppl.seqNo, ppl.id
""".trimIndent()
return jdbcDao.queryForList(query, mapOf("jobOrderId" to jobOrderId)).map { row ->
val bomProcessId = sql.longVal(row["bomProcessId"])
ItemLotTraceProductionStep(
processLineId = sql.longVal(row["processLineId"]) ?: 0L,
stepName = sql.str(row["stepName"]),
description = sql.str(row["description"]),
equipmentCode = sql.str(row["equipmentCode"]),
equipmentName = sql.str(row["equipmentName"]),
operatorName = sql.str(row["operatorName"]),
startTime = sql.formatDateTime(row["startTime"]),
endTime = sql.formatDateTime(row["endTime"]),
outputQty = sql.decimal(row["outputQty"]),
scrapQty = sql.decimal(row["scrapQty"]),
defectQty = sql.decimal(row["defectQty"]),
status = sql.str(row["status"]),
seqNo = sql.longVal(row["seqNo"]),
bomProcessId = bomProcessId,
stepMaterials = bomProcessId?.let { stepMaterials[it].orEmpty() } ?: emptyList(),
)
}
}

open fun loadByproductLots(jobOrderId: Long, currentItemId: Long): List<ItemLotTraceByproductLot> {
val query = """
SELECT
il.id AS inventoryLotId,
sil.id AS stockInLineId,
COALESCE(il.lotNo, '') AS lotNo,
COALESCE(bp_it.code, '') AS itemCode,
COALESCE(bp_it.name, '') AS itemName,
COALESCE(sil.acceptedQty, 0) AS qty,
COALESCE(uc.udfudesc, '') AS uom,
COALESCE(ppl.name, '') AS processStepName,
COALESCE(sil.created, sil.modified, sil.receiptDate) AS producedAt,
COALESCE(jo.code, '') AS jobOrderCode,
jo.id AS jobOrderId
FROM job_order jo
INNER JOIN stock_in_line sil ON sil.jobOrderId = jo.id AND sil.deleted = 0
INNER JOIN inventory_lot il ON sil.inventoryLotId = il.id AND il.deleted = 0
INNER JOIN items bp_it ON il.itemId = bp_it.id AND bp_it.deleted = 0
LEFT JOIN item_uom iu ON bp_it.id = iu.itemId AND iu.stockUnit = 1
LEFT JOIN uom_conversion uc ON iu.uomId = uc.id
LEFT JOIN productprocess pp ON pp.jobOrderId = jo.id AND pp.deleted = 0
LEFT JOIN productprocessline ppl ON ppl.productprocessid = pp.id
AND ppl.deleted = 0
AND ppl.byproductId = bp_it.id
WHERE jo.id = :jobOrderId
AND jo.deleted = 0
AND il.itemId <> :currentItemId
AND EXISTS (
SELECT 1
FROM productprocess pp2
INNER JOIN productprocessline ppl2 ON ppl2.productprocessid = pp2.id AND ppl2.deleted = 0
WHERE pp2.jobOrderId = jo.id
AND pp2.deleted = 0
AND ppl2.byproductId = il.itemId
)
ORDER BY producedAt, il.lotNo
""".trimIndent()
return jdbcDao.queryForList(
query,
mapOf("jobOrderId" to jobOrderId, "currentItemId" to currentItemId),
).map { row ->
ItemLotTraceByproductLot(
inventoryLotId = sql.longVal(row["inventoryLotId"]),
stockInLineId = sql.longVal(row["stockInLineId"]),
lotNo = sql.str(row["lotNo"]),
itemCode = sql.str(row["itemCode"]),
itemName = sql.str(row["itemName"]),
qty = sql.decimal(row["qty"]),
uom = sql.str(row["uom"]),
processStepName = sql.str(row["processStepName"]),
producedAt = sql.formatDateTime(row["producedAt"]),
jobOrderCode = sql.str(row["jobOrderCode"]),
jobOrderId = sql.longVal(row["jobOrderId"]),
)
}.distinctBy { "${it.itemCode}:${it.lotNo}" }
}
}

+ 61
- 0
src/main/java/com/ffii/fpsms/modules/stock/trace/TraceSqlSupport.kt Datei anzeigen

@@ -0,0 +1,61 @@
package com.ffii.fpsms.modules.stock.trace

import org.springframework.stereotype.Component
import java.math.BigDecimal
import java.time.LocalDate
import java.time.LocalDateTime
import java.time.format.DateTimeFormatter

@Component
open class TraceSqlSupport {
val dateFmt: DateTimeFormatter = DateTimeFormatter.ISO_LOCAL_DATE
val dateTimeFmt: DateTimeFormatter = DateTimeFormatter.ofPattern("yyyy-MM-dd HH:mm:ss")

open fun str(v: Any?): String = v?.toString()?.trim() ?: ""

open fun strOrNull(v: Any?): String? = v?.toString()?.trim()?.ifBlank { null }

open fun longVal(v: Any?): Long? = when (v) {
null -> null
is Number -> v.toLong()
else -> v.toString().toLongOrNull()
}

open fun intVal(v: Any?): Int? = when (v) {
null -> null
is Number -> v.toInt()
else -> v.toString().toIntOrNull()
}

open fun decimal(v: Any?): BigDecimal = when (v) {
null -> BigDecimal.ZERO
is BigDecimal -> v
is Number -> BigDecimal.valueOf(v.toDouble())
else -> v.toString().toBigDecimalOrNull() ?: BigDecimal.ZERO
}

open fun decimalOrNull(v: Any?): BigDecimal? = when (v) {
null -> null
is BigDecimal -> v
is Number -> BigDecimal.valueOf(v.toDouble())
else -> v.toString().toBigDecimalOrNull()
}

open fun formatDate(v: Any?): String? = when (v) {
null -> null
is LocalDate -> v.format(dateFmt)
is java.sql.Date -> v.toLocalDate().format(dateFmt)
is LocalDateTime -> v.toLocalDate().format(dateFmt)
is java.sql.Timestamp -> v.toLocalDateTime().toLocalDate().format(dateFmt)
else -> v.toString().takeIf { it.isNotBlank() }
}

open fun formatDateTime(v: Any?): String? = when (v) {
null -> null
is LocalDateTime -> v.format(dateTimeFmt)
is java.sql.Timestamp -> v.toLocalDateTime().format(dateTimeFmt)
is LocalDate -> v.atStartOfDay().format(dateTimeFmt)
is java.sql.Date -> v.toLocalDate().atStartOfDay().format(dateTimeFmt)
else -> v.toString().takeIf { it.isNotBlank() }
}
}

+ 246
- 0
src/main/java/com/ffii/fpsms/modules/stock/trace/TraceUpstreamLoader.kt Datei anzeigen

@@ -0,0 +1,246 @@
package com.ffii.fpsms.modules.stock.trace

import com.ffii.core.support.JdbcDao
import org.springframework.stereotype.Component

/**
* Resolves upstream inventory lots for trace merge after warehouse transfer / lot split.
* Merges PO, JO, QC, putaway history from source lots into the traced (destination) lot view
* via [ItemLotTraceOrchestrator] calling [resolveUpstreamLotIds] (siblings with the same
* lotNo are left to alternateLocations / locationBlocks).
*/
@Component
open class TraceUpstreamLoader(
private val jdbcDao: JdbcDao,
private val sql: TraceSqlSupport,
) {
data class JoProvenance(
val jobOrderId: Long,
val joStockInLineId: Long,
val upstreamLotIds: List<Long>,
)

/**
* Inbound transfer sources for [inventoryLotId] (this lot received qty via transfer).
* Mirrors [TraceMovementLoader.loadTransfers] lot matching (sil + ill_to on dest, ill_from on source).
*/
open fun findInboundTransferSourceLotIds(inventoryLotId: Long): List<Long> {
val query = """
SELECT ill_from.inventoryLotId AS sourceInventoryLotId
FROM stock_transfer_record str
LEFT JOIN stock_in_line sil ON str.stockInLineId = sil.id AND sil.deleted = 0
LEFT JOIN stock_out_line sol ON str.stockOutLineId = sol.id AND sol.deleted = 0
LEFT JOIN inventory_lot_line ill_from ON sol.inventoryLotLineId = ill_from.id AND ill_from.deleted = 0
LEFT JOIN inventory_lot_line ill_to ON sil.inventoryLotLineId = ill_to.id AND ill_to.deleted = 0
WHERE str.deleted = 0
AND ill_from.inventoryLotId IS NOT NULL
AND ill_from.inventoryLotId <> :inventoryLotId
AND (
sil.inventoryLotId = :inventoryLotId
OR ill_to.inventoryLotId = :inventoryLotId
)
GROUP BY ill_from.inventoryLotId
ORDER BY MIN(str.id) ASC
""".trimIndent()
return jdbcDao.queryForList(query, mapOf("inventoryLotId" to inventoryLotId))
.mapNotNull { sql.longVal(it["sourceInventoryLotId"]) }
.distinct()
}

/** Walk inbound transfer chain and collect every source lot (PO / JO / any origin). */
open fun collectTransferUpstreamLotIds(tracedLotId: Long, maxDepth: Int = 12): List<Long> {
val found = linkedSetOf<Long>()
var frontier = listOf(tracedLotId)
val visited = mutableSetOf(tracedLotId)
repeat(maxDepth) {
val sources = frontier.flatMap { lotId -> findInboundTransferSourceLotIds(lotId) }
.filter { it !in visited }
if (sources.isEmpty()) return found.toList()
sources.forEach { id ->
found.add(id)
visited.add(id)
}
frontier = sources
}
return found.toList()
}

/** Other inventory lots with the same lotNo+itemId (transfer split / multi-warehouse). */
open fun findSiblingLotIdsBySameLotNo(
itemId: Long,
lotNo: String,
excludeInventoryLotId: Long,
): List<Long> {
if (lotNo.isBlank()) return emptyList()
val query = """
SELECT il.id AS inventoryLotId
FROM inventory_lot il
WHERE il.deleted = 0
AND il.itemId = :itemId
AND il.lotNo = :lotNo
AND il.id <> :excludeInventoryLotId
ORDER BY il.stockInDate ASC, il.id ASC
""".trimIndent()
return jdbcDao.queryForList(
query,
mapOf("itemId" to itemId, "lotNo" to lotNo, "excludeInventoryLotId" to excludeInventoryLotId),
).mapNotNull { sql.longVal(it["inventoryLotId"]) }.distinct()
}

/** JO receipt lots with same lotNo (legacy helper for JO-only provenance). */
open fun findJoSourceLotIdsBySameLotNo(
itemId: Long,
lotNo: String,
excludeInventoryLotId: Long,
): List<Long> {
if (lotNo.isBlank()) return emptyList()
val query = """
SELECT il.id AS inventoryLotId
FROM inventory_lot il
INNER JOIN stock_in_line sil ON sil.inventoryLotId = il.id AND sil.deleted = 0
WHERE il.deleted = 0
AND il.itemId = :itemId
AND il.lotNo = :lotNo
AND il.id <> :excludeInventoryLotId
AND sil.jobOrderId IS NOT NULL
GROUP BY il.id
ORDER BY MIN(sil.receiptDate) ASC, il.id ASC
""".trimIndent()
return jdbcDao.queryForList(
query,
mapOf("itemId" to itemId, "lotNo" to lotNo, "excludeInventoryLotId" to excludeInventoryLotId),
).mapNotNull { sql.longVal(it["inventoryLotId"]) }.distinct()
}

open fun findJoSourceLotIdsByProductLotNo(tracedInventoryLotId: Long): List<Long> {
val query = """
SELECT il_jo.id AS inventoryLotId
FROM stock_in_line sil_trace
INNER JOIN inventory_lot il_trace ON sil_trace.inventoryLotId = il_trace.id AND il_trace.deleted = 0
INNER JOIN stock_in_line sil_jo ON sil_jo.deleted = 0
AND sil_jo.jobOrderId IS NOT NULL
AND sil_jo.productLotNo IS NOT NULL
AND TRIM(sil_jo.productLotNo) <> ''
AND sil_jo.productLotNo = sil_trace.productLotNo
INNER JOIN inventory_lot il_jo ON sil_jo.inventoryLotId = il_jo.id AND il_jo.deleted = 0
WHERE sil_trace.deleted = 0
AND sil_trace.inventoryLotId = :tracedInventoryLotId
AND il_jo.id <> :tracedInventoryLotId
GROUP BY il_jo.id
ORDER BY MIN(sil_jo.receiptDate) ASC, il_jo.id ASC
""".trimIndent()
val fromSil = jdbcDao.queryForList(query, mapOf("tracedInventoryLotId" to tracedInventoryLotId))
.mapNotNull { sql.longVal(it["inventoryLotId"]) }
if (fromSil.isNotEmpty()) return fromSil.distinct()

val viaLotLine = """
SELECT il_jo.id AS inventoryLotId
FROM inventory_lot_line ill_trace
INNER JOIN stock_in_line sil_trace ON sil_trace.inventoryLotLineId = ill_trace.id AND sil_trace.deleted = 0
INNER JOIN stock_in_line sil_jo ON sil_jo.deleted = 0
AND sil_jo.jobOrderId IS NOT NULL
AND sil_jo.productLotNo IS NOT NULL
AND TRIM(sil_jo.productLotNo) <> ''
AND sil_jo.productLotNo = sil_trace.productLotNo
INNER JOIN inventory_lot il_jo ON sil_jo.inventoryLotId = il_jo.id AND il_jo.deleted = 0
WHERE ill_trace.deleted = 0
AND ill_trace.inventoryLotId = :tracedInventoryLotId
AND il_jo.id <> :tracedInventoryLotId
GROUP BY il_jo.id
ORDER BY MIN(sil_jo.receiptDate) ASC, il_jo.id ASC
""".trimIndent()
return jdbcDao.queryForList(viaLotLine, mapOf("tracedInventoryLotId" to tracedInventoryLotId))
.mapNotNull { sql.longVal(it["inventoryLotId"]) }
.distinct()
}

/**
* All upstream lots whose inbound history should be merged into the trace response.
*/
open fun resolveUpstreamLotIds(tracedLotId: Long, itemId: Long, lotNo: String): List<Long> {
val ids = linkedSetOf<Long>()
collectTransferUpstreamLotIds(tracedLotId).forEach { ids.add(it) }
findSiblingLotIdsBySameLotNo(itemId, lotNo, tracedLotId).forEach { ids.add(it) }
findJoSourceLotIdsByProductLotNo(tracedLotId).forEach { ids.add(it) }
return ids.filter { it != tracedLotId }
}

open fun findJoStockInLineId(inventoryLotId: Long): Long? {
val query = """
SELECT sil.id AS stockInLineId
FROM stock_in_line sil
LEFT JOIN inventory_lot_line ill ON sil.inventoryLotLineId = ill.id AND ill.deleted = 0
WHERE sil.deleted = 0
AND sil.jobOrderId IS NOT NULL
AND (
sil.inventoryLotId = :inventoryLotId
OR ill.inventoryLotId = :inventoryLotId
)
ORDER BY sil.receiptDate DESC, sil.id DESC
LIMIT 1
""".trimIndent()
return jdbcDao.queryForList(query, mapOf("inventoryLotId" to inventoryLotId))
.firstOrNull()?.let { sql.longVal(it["stockInLineId"]) }
}

open fun findJobOrderIdForStockInLine(stockInLineId: Long): Long? {
val query = """
SELECT jo.id AS jobOrderId
FROM stock_in_line sil
INNER JOIN job_order jo ON sil.jobOrderId = jo.id AND jo.deleted = 0
WHERE sil.id = :stockInLineId AND sil.deleted = 0
""".trimIndent()
return jdbcDao.queryForList(query, mapOf("stockInLineId" to stockInLineId))
.firstOrNull()?.let { sql.longVal(it["jobOrderId"]) }
}

private fun provenanceFromJoLot(joLotId: Long, tracedLotId: Long): JoProvenance? {
val joSil = findJoStockInLineId(joLotId) ?: return null
val joId = findJobOrderIdForStockInLine(joSil) ?: return null
val upstream = if (joLotId == tracedLotId) emptyList() else listOf(joLotId)
return JoProvenance(jobOrderId = joId, joStockInLineId = joSil, upstreamLotIds = upstream)
}

private fun walkTransferChainForJo(tracedLotId: Long, maxDepth: Int): JoProvenance? {
val pathFromTraced = mutableListOf<Long>()
var current = tracedLotId
val visited = mutableSetOf<Long>()

while (current !in visited && pathFromTraced.size < maxDepth) {
visited.add(current)
val joSil = findJoStockInLineId(current)
if (joSil != null) {
val joId = findJobOrderIdForStockInLine(joSil) ?: return null
val upstream = if (current == tracedLotId) {
emptyList()
} else {
(pathFromTraced + current).distinct().filter { it != tracedLotId }
}
return JoProvenance(jobOrderId = joId, joStockInLineId = joSil, upstreamLotIds = upstream)
}
val sources = findInboundTransferSourceLotIds(current).filter { it !in visited }
val next = sources.firstOrNull() ?: break
pathFromTraced.add(next)
current = next
}
return null
}

open fun resolveJoProvenance(
tracedLotId: Long,
itemId: Long? = null,
lotNo: String? = null,
maxDepth: Int = 12,
): JoProvenance? {
walkTransferChainForJo(tracedLotId, maxDepth)?.let { return it }
if (itemId != null && !lotNo.isNullOrBlank()) {
findJoSourceLotIdsBySameLotNo(itemId, lotNo, tracedLotId)
.firstOrNull()
?.let { return provenanceFromJoLot(it, tracedLotId) }
}
findJoSourceLotIdsByProductLotNo(tracedLotId)
.firstOrNull()
?.let { return provenanceFromJoLot(it, tracedLotId) }
return null
}
}

Einige Dateien werden nicht angezeigt, da zu viele Dateien in diesem Diff geändert wurden.

Laden…
Abbrechen
Speichern