Compare commits

...

144 Commits

Author SHA1 Message Date
  CANCERYS\kw093 a1ca36811b 庫存調整權限 盤點卡片統計 「未按規劃完成」移到 tab 列最後 12 hours ago
  CANCERYS\kw093 2df1b5c3e8 權限 + 未按規劃完成 tab + lookback 對齊 + 報表檔名 14 hours ago
  CANCERYS\kw093 6b6a75f4ed revert version comment 16 hours ago
  CANCERYS\kw093 85c3f9b0a1 ssueTypes 改名:跳過提料(使用「已完成」)/多提/少提/擅自換批/揀錯批;Excel「問題類型」→「偏離類型」 16 hours ago
  CANCERYS\kw093 0c1cb0d4f3 actualPickLotLineId + COALESCE 讀取 20 hours ago
  Fai Luk 6e2722f1e8 added scripts for generating guide/test plan for commit(s) in word 4 days ago
  CANCERYS\kw093 b12b9a499f isextra truck X ticket fix 1 week ago
  Fai Luk 5f628db30c no message 1 week ago
  Fai Luk 93c3e9318f no message 1 week ago
  Fai Luk 18a4d2dbff no message 1 week ago
  Fai Luk a81c6f1cf6 added onpack2030 for pp2404 1 week ago
  CANCERYS\kw093 173e6ce580 WorkbenchGoodPickExecutionDetail UI 大改 2 weeks ago
  CANCERYS\kw093 3ebf46e304 建議批號 UOM 檢核/不符則擋、標籤列印只顯示同 UOM 2 weeks ago
  tommy 94dbc8db9a no message 2 weeks ago
  tommy c63f8dfa8c item trace , drink produce dashboard 2 weeks ago
  CANCERYS\kw093 898d6f685f inventory search update 2 weeks ago
  tommy dcb4df4c3e add ref no 2 weeks ago
  kelvin.yau 66672ba404 updated ARCHIVE 2 weeks ago
  kelvin.yau a64e158bc0 Updated archive function 2 weeks ago
  tommy 7ef9ef8272 item trace 2 weeks ago
  tommy ae81949e46 no message 3 weeks ago
  CANCERYS\kw093 ef7bcca120 Cancel Pick Order 3 weeks ago
  CANCERYS\kw093 ee4a80ccc2 do ticket select fix 3 weeks ago
  Fai Luk 1fb5200e16 no message 3 weeks ago
  [email protected] 88136da489 no message 3 weeks ago
  [email protected] c23898e31a python job order not to show cancelled job orders 3 weeks ago
  [email protected] 70f620959a fixing the GRN duplicated item bug for same PO 4 weeks ago
  CANCERYS\kw093 fd3a0f00fa 補貨 search fix 1 month ago
  CANCERYS\kw093 8ff5c9e902 補貨 FIX 1 month ago
  tommy 84dddf9fba no message 1 month ago
  tommy b632df1d91 no message 1 month ago
  CANCERYS\kw093 7f62902a62 po detail ui add deleted button and ui update 1 month ago
  CANCERYS\kw093 3814cd4c79 jo pick order resuggest reject W402 warehosue 1 month ago
  CANCERYS\kw093 42c3ada0df if pol sync fail, will send email to tell pol have sync fail 1 month ago
  B.E.N.S.O.N 97a26b1a3b QC添加溫度/濕度儲存 1 month ago
  CANCERYS\kw093 789666aad8 do pdf fix 1 month ago
  tommy 7ac98d950b DN PDF 單號顯示問題 1 month ago
  tommy 46f7d39f7b 調整庫存權限 1 month ago
  [email protected] 2e437a831b no message 1 month ago
  tommy 8aa202052b label printer tracking update 1 month ago
  tommy 76ff1cd22f label printer 1 month ago
  tommy ac238e7ba3 stocktake report update 1 month ago
  [email protected] 7db4286368 no message 1 month ago
  [email protected] 1035482880 no message 1 month ago
  [email protected] 4399ac6d22 no message 1 month ago
  [email protected] a7d3f156a5 no message 1 month ago
  [email protected] 5eba1a42f2 no message 1 month ago
  [email protected] f08f06da0f try to fix the bom item error 1 month ago
  CANCERYS\kw093 44646254f6 do skip isfee item 1 month ago
  [email protected] 4d38dcff74 no message 1 month ago
  B.E.N.S.O.N 172d32f613 成品出倉執貨時 標籤列印時頁數顯示空白 1 month ago
  [email protected] a111a80bf6 handle the laser printer that failed to print 1175 full name with (uom) 1 month ago
  [email protected] 193a1824a2 added a report to check bom sync history 1 month ago
  [email protected] 4d01f1d84f fixing the non-unique vendor result of bom finding 1 month ago
  [email protected] 506d39c7f3 try to update the m18 bom with a new version number 1 month ago
  kelvin.yau b705a8acb6 replenishment update 1 month ago
  CANCERYS\kw093 a0b3a58d65 report fix 2 1 month ago
  CANCERYS\kw093 76a2ace7f9 rpeort fix 1 month ago
  CANCERYS\kw093 88e36de88c 補貨V1 1 month ago
  [email protected] cdbfb157ca no message 1 month ago
  [email protected] 13d646af6e Merge branch 'production' of https://git.2fi-solutions.com/jason/FPSMS-backend into production 1 month ago
  [email protected] 1c1eadc71d alter for the BOM sync finding PP purchase order supplier and also others for supplier id by code 1 month ago
  tommy 22e982fea1 補貨 + truck scheduler update 1 month ago
  [email protected] 306a8474c6 updates for some items missing the () uom of job order list which using by python 1 month ago
  [email protected] 064d379229 Bag3 1 month ago
  kelvin.yau f74dc566ee replenishment setup 1 month ago
  tommy 5f816fcc92 re-schedule truck 1 month ago
  tommy 2987847917 add precon 1 month ago
  tommy 66f05c1e75 report permission 1 month ago
  tommy e438ae1d29 Merge branch 'production' of https://git.2fi-solutions.com/jason/FPSMS-backend into production 1 month ago
  tommy 0bc0e86933 DeliveryNotePdf update 1 month ago
  kelvin.yau e1ecc73f33 pick record download + precondition checking 1 month ago
  CANCERYS\kw093 076ff1c555 ti-M merge 1 month ago
  CANCERYS\kw093 b02f176e08 job order bom status 1 month ago
  CANCERYS\kw093 9bbd43aade update do is extra same order 1 month ago
  tommy 65ed99ba39 translate and route schedule 2 months ago
  CANCERYS\kw093 51bb08e2f2 improt bom fix 2 months ago
  CANCERYS\kw093 9331b7ebdd bom import fix 2 months ago
  CANCERYS\kw093 1bd3af87b1 do search fix 2 months ago
  CANCERYS\kw093 46b5b9f8c8 do PDF fix 2 months ago
  CANCERYS\kw093 3b7199cd90 stock take batch save fix 2 months ago
  CANCERYS\kw093 76cab93f57 s 2 months ago
  CANCERYS\kw093 e67440f56f search stock take fix 2 months ago
  CANCERYS\kw093 9b787159ef update stock take 2 months ago
  CANCERYS\kw093 7bafe58558 update stock tkae fix 2 months ago
  CANCERYS\kw093 2383b62ad0 stock take update 2 months ago
  CANCERYS\kw093 03663cc801 update new stock take 2 months ago
  CANCERYS\kw093 9ef208fd75 bom combo fix 2 months ago
  [email protected] edc2f8c0f2 no message 2 months ago
  [email protected] f2fbc5d7dc no message 2 months ago
  [email protected] 9dbd06e338 added for bom sync to m18 2 months ago
  CANCERYS\kw093 6c095b3a29 product process fix 2 months ago
  B.E.N.S.O.N b180b73c97 膠茜數目使用數量 Update 2 months ago
  [email protected] 6dc9d30292 no message 2 months ago
  [email protected] a951485df2 no message 2 months ago
  [email protected] 02811c401b no message 2 months ago
  CANCERYS\kw093 cf8f1564d1 Merge remote-tracking branch 'origin/production' into production 2 months ago
  CANCERYS\kw093 95f20378ea jo dashbaord update 2 months ago
  [email protected] 74312d84bd don't overwrite the purchase order when sync from m18 if the status is not "pending" anymore 2 months ago
  CANCERYS\kw093 4cd0e5479a Merge remote-tracking branch 'origin/production' into production 2 months ago
  CANCERYS\kw093 c57bec2f74 update TRF fix 2 months ago
  [email protected] 3f70bc475c refining the device monitoring page 2 months ago
  [email protected] 9cdb1f71b8 added export do qty in /ps for daily delivery qty; added monitor page for production use 2 months ago
  CANCERYS\kw093 b8c608c5b4 chart improve 2 months ago
  kelvin.yau c5ea9b5f05 merge fix 2 months ago
  kelvin.yau 5b61294a53 Merge branch 'production' of https://git.2fi-solutions.com/derek/FPSMS-backend into production 2 months ago
  tommy e9f1f48edb routeboard 2 months ago
  CANCERYS\kw093 e78971d7b2 job order auto cancel and delay 2 months ago
  CANCERYS\kw093 f1a9d63a99 Merge remote-tracking branch 'origin/production' into production 2 months ago
  CANCERYS\kw093 1a6cb36897 inventory search fix 2 months ago
  tommy 57ab57dd65 routeboard 2 months ago
  CANCERYS\kw093 7141c0f6b4 chart sql improt 2 months ago
  CANCERYS\kw093 1d971256c4 fix bag lot line function slow query 2 months ago
  CANCERYS\kw093 870fbca20e new supplier 2 months ago
  CANCERYS\kw093 9dd08d6a70 update Report and stock ledger search 2 months ago
  kelvin.yau d4dc79d6fa UPDATE CODE FOR ENSON (DO MARK COMPLETED) 2 months ago
  [email protected] dd348f36ae adding for bom sync 2 months ago
  [email protected] 4b83633f28 updated Bag3, adding m18 BOM syn, delete the DO2_EXTRA syn, move the DO2 sync to 1pm 2 months ago
  B.E.N.S.O.N 891a929e1f User Page Update 2 months ago
  CANCERYS\kw093 078965686f Merge remote-tracking branch 'origin/production' into production 2 months ago
  CANCERYS\kw093 1b1f23c283 update pick order complete logci and shortner Transactional 2 months ago
  kelvin.yau 042828d96f fix 2 months ago
  CANCERYS\kw093 9491f0f32c update bom import 2 months ago
  [email protected] 1472a05830 added isExtra and sync extra DO from m18 2 months ago
  tommy 5cb1989ad6 truck dashboard update 3 months ago
  CANCERYS\kw093 d4af229304 update do search and jo bom name coe 3 months ago
  CANCERYS\kw093 b5af7aad05 update do finish jump page, 3 months ago
  kelvin.yau 5be61f895d Stock Adj fix 3 months ago
  CANCERYS\kw093 a10f069a4f update index again 3 months ago
  CANCERYS\kw093 2d738e9714 added index for delivery_order_pick_order 3 months ago
  [email protected] 24ee1d8f11 revert the index 3 months ago
  [email protected] e1902f3b0e added index to hep querying 3 months ago
  CANCERYS\kw093 15c961d543 update truck X and singal relesae 3 months ago
  CANCERYS\kw093 d5b94751e7 update scan pick reject list 3 months ago
  CANCERYS\kw093 ee2b4d255a update 2F assign by lance 3 months ago
  tommy 66df3b1db6 add logistic table , chnaged district reference type 3 months ago
  CANCERYS\kw093 292ae22a7e update do 4F assign by lance 3 months ago
  B.E.N.S.O.N 0a992c381d Merge remote-tracking branch 'origin/production' into production 3 months ago
  B.E.N.S.O.N 8002b6d621 QR Code Printing Update 3 months ago
  CANCERYS\kw093 777d962f12 update stock take batch handle efficient 3 months ago
  CANCERYS\kw093 31abe1b05a update stock take and stock take report and improved checkAndCompletePickOrderByConsoCode 3 months ago
  CANCERYS\kw093 d04e864323 fix worknbench floor problem 3 months ago
  DESKTOP-064TTA1\Fai LUK 4cb8d0d6de Merge branch 'master' into production 3 months ago
  kelvin.yau 59757eeacf New PO Workbench, Improve loading speed. 3 months ago
100 changed files with 12494 additions and 1180 deletions
Split View
  1. +11
    -0
      .cursor/commands/gen-commit-test-plans.md
  2. +48
    -0
      .cursor/rules/deploy-test-plan.mdc
  3. +33
    -0
      .cursor/rules/frontend-prevent-duplicate-api-calls.mdc
  4. +28
    -0
      .cursor/rules/gen-commit-test-plan.mdc
  5. +785
    -0
      .cursor/skills/fp-mtms-version-checklist/SKILL.md
  6. +5
    -1
      .gitignore
  7. +32
    -0
      AGENTS.md
  8. +236
    -0
      docs/MTMS_M18_DATA_MAPPING.md
  9. +33
    -0
      docs/deploy/20260727_isextra_truck_x_ticket_fix.md
  10. +175
    -0
      docs/deploy/HOW_TO_GENERATE_COMMIT_TEST_PLANS.md
  11. +46
    -0
      docs/deploy/TEMPLATE.md
  12. +15
    -0
      docs/deploy/commit-plans/2026-07-20_94dbc8db_no_message.docx
  13. +16
    -0
      docs/deploy/commit-plans/2026-07-22_173e6ce5_workbenchgoodpickexecutiondetail_ui.docx
  14. +30
    -0
      docs/deploy/commit-plans/2026-07-22_3ebf46e3_uom.docx
  15. +11
    -0
      docs/deploy/commit-plans/2026-07-23_18a4d2db_no_message.docx
  16. +13
    -0
      docs/deploy/commit-plans/2026-07-23_a81c6f1c_added_onpack2030_for_pp2404.docx
  17. +14
    -0
      docs/deploy/commit-plans/2026-07-24_5f628db3_no_message.docx
  18. +11
    -0
      docs/deploy/commit-plans/2026-07-24_93c3e931_no_message.docx
  19. +14
    -0
      docs/deploy/commit-plans/2026-07-27_b12b9a49_isextra_truck_ticket_fix.docx
  20. +14
    -0
      docs/deploy/commit-plans/_index.md
  21. +106
    -0
      docs/exports/MTMS_BOM_UserGuide.docx
  22. +149
    -0
      docs/exports/MTMS_DO_Shipping_UserGuide.docx
  23. +105
    -0
      docs/exports/MTMS_JO_Pick_Production_PutAway_UserGuide.docx
  24. +67
    -0
      docs/exports/MTMS_M18_DATA_MAPPING.docx
  25. BIN
      docs/exports/MTMS_M18_DATA_MAPPING.xlsx
  26. +177
    -0
      docs/exports/MTMS_Schedule_JobOrder_UserGuide.docx
  27. +60
    -0
      docs/generated/m18-item-type-mapping.md
  28. +17
    -0
      docs/generated/m18-stsearch-types.md
  29. +256
    -0
      docs/user-guides/MTMS_BOM_使用說明.md
  30. +259
    -0
      docs/user-guides/MTMS_工單提料報工上架_使用說明.md
  31. +459
    -0
      docs/user-guides/MTMS_排程與工單_使用說明.md
  32. +375
    -0
      docs/user-guides/MTMS_送貨訂單與出貨_使用說明.md
  33. +387
    -173
      python/Bag3.py
  34. BIN
      python/__pycache__/Bag3.cpython-313.pyc
  35. +32
    -2
      python/installAndExe.txt
  36. +345
    -0
      scripts/export_m18_mapping_office.py
  37. +193
    -0
      scripts/export_user_guide_office.py
  38. +505
    -0
      scripts/generate_commit_test_plans_docx.py
  39. +120
    -0
      scripts/generate_deploy_test_plan.py
  40. +239
    -0
      scripts/generate_m18_mapping_docs.py
  41. +37
    -0
      src/main/java/com/ffii/fpsms/config/security/SecurityConfig.java
  42. +48
    -0
      src/main/java/com/ffii/fpsms/m18/entity/M18BomShopSyncLog.kt
  43. +49
    -0
      src/main/java/com/ffii/fpsms/m18/entity/M18BomShopSyncLogRepository.kt
  44. +7
    -0
      src/main/java/com/ffii/fpsms/m18/entity/M18DataLogRepository.kt
  45. +7
    -1
      src/main/java/com/ffii/fpsms/m18/entity/SchedulerSyncLogRepository.kt
  46. +14
    -0
      src/main/java/com/ffii/fpsms/m18/model/M18BomForShopSaveAttemptResult.kt
  47. +96
    -0
      src/main/java/com/ffii/fpsms/m18/model/M18BomForShopSaveRequest.kt
  48. +18
    -0
      src/main/java/com/ffii/fpsms/m18/model/M18BomShopBatchSyncSummary.kt
  49. +14
    -0
      src/main/java/com/ffii/fpsms/m18/model/M18BomShopSyncTriggerResult.kt
  50. +714
    -0
      src/main/java/com/ffii/fpsms/m18/service/M18BomForShopService.kt
  51. +41
    -0
      src/main/java/com/ffii/fpsms/m18/service/M18BomHeaderLookupService.kt
  52. +107
    -25
      src/main/java/com/ffii/fpsms/m18/service/M18DeliveryOrderService.kt
  53. +32
    -8
      src/main/java/com/ffii/fpsms/m18/service/M18MasterDataService.kt
  54. +32
    -9
      src/main/java/com/ffii/fpsms/m18/service/M18PurchaseOrderService.kt
  55. +43
    -0
      src/main/java/com/ffii/fpsms/m18/service/M18VendorLookupService.kt
  56. +19
    -2
      src/main/java/com/ffii/fpsms/m18/web/M18TestController.kt
  57. +1
    -1
      src/main/java/com/ffii/fpsms/modules/bag/service/bagService.kt
  58. +13
    -27
      src/main/java/com/ffii/fpsms/modules/bag/web/bagController.kt
  59. +187
    -117
      src/main/java/com/ffii/fpsms/modules/chart/service/ChartService.kt
  60. +11
    -5
      src/main/java/com/ffii/fpsms/modules/chart/web/ChartController.kt
  61. +4
    -5
      src/main/java/com/ffii/fpsms/modules/common/MailSMTP.kt
  62. +15
    -0
      src/main/java/com/ffii/fpsms/modules/common/SettingNames.java
  63. +18
    -0
      src/main/java/com/ffii/fpsms/modules/common/alert/LoggingSmsSender.kt
  64. +27
    -0
      src/main/java/com/ffii/fpsms/modules/common/alert/PoLineFailureAlertSupport.kt
  65. +73
    -0
      src/main/java/com/ffii/fpsms/modules/common/alert/SchedulerSyncAlertProperties.kt
  66. +504
    -0
      src/main/java/com/ffii/fpsms/modules/common/alert/SchedulerSyncAlertService.kt
  67. +27
    -0
      src/main/java/com/ffii/fpsms/modules/common/alert/SmsAlertConfig.kt
  68. +11
    -0
      src/main/java/com/ffii/fpsms/modules/common/alert/SmsSender.kt
  69. +68
    -0
      src/main/java/com/ffii/fpsms/modules/common/alert/SyncAlertEmailSender.kt
  70. +138
    -0
      src/main/java/com/ffii/fpsms/modules/common/alert/TwilioSmsSender.kt
  71. +7
    -4
      src/main/java/com/ffii/fpsms/modules/common/mail/service/MailSenderService.kt
  72. +20
    -3
      src/main/java/com/ffii/fpsms/modules/common/mail/service/MailTemplateService.kt
  73. +350
    -24
      src/main/java/com/ffii/fpsms/modules/common/scheduler/service/SchedulerService.kt
  74. +55
    -0
      src/main/java/com/ffii/fpsms/modules/common/scheduler/web/SchedulerController.kt
  75. +4
    -0
      src/main/java/com/ffii/fpsms/modules/deliveryOrder/entity/DeliveryOrder.kt
  76. +4
    -0
      src/main/java/com/ffii/fpsms/modules/deliveryOrder/entity/DeliveryOrderPickOrder.kt
  77. +6
    -0
      src/main/java/com/ffii/fpsms/modules/deliveryOrder/entity/DeliveryOrderRepository.kt
  78. +2
    -0
      src/main/java/com/ffii/fpsms/modules/deliveryOrder/entity/DoPickOrderRecordRepository.kt
  79. +2
    -0
      src/main/java/com/ffii/fpsms/modules/deliveryOrder/entity/DoPickOrderRepository.kt
  80. +106
    -0
      src/main/java/com/ffii/fpsms/modules/deliveryOrder/entity/DoReplenishment.kt
  81. +89
    -0
      src/main/java/com/ffii/fpsms/modules/deliveryOrder/entity/DoReplenishmentRepository.kt
  82. +5
    -1
      src/main/java/com/ffii/fpsms/modules/deliveryOrder/entity/models/DeliveryOrderInfo.kt
  83. +2
    -0
      src/main/java/com/ffii/fpsms/modules/deliveryOrder/entity/models/DeliveryOrderLineInfo.kt
  84. +767
    -278
      src/main/java/com/ffii/fpsms/modules/deliveryOrder/service/DeliveryOrderService.kt
  85. +117
    -0
      src/main/java/com/ffii/fpsms/modules/deliveryOrder/service/DoFloorSupplierSettingsService.kt
  86. +80
    -129
      src/main/java/com/ffii/fpsms/modules/deliveryOrder/service/DoPickOrderService.kt
  87. +41
    -23
      src/main/java/com/ffii/fpsms/modules/deliveryOrder/service/DoReleaseCoordinatorService.kt
  88. +583
    -0
      src/main/java/com/ffii/fpsms/modules/deliveryOrder/service/DoReplenishmentService.kt
  89. +77
    -9
      src/main/java/com/ffii/fpsms/modules/deliveryOrder/service/DoWorkbenchDopoAssignmentService.kt
  90. +867
    -220
      src/main/java/com/ffii/fpsms/modules/deliveryOrder/service/DoWorkbenchMainService.kt
  91. +1139
    -98
      src/main/java/com/ffii/fpsms/modules/deliveryOrder/service/DoWorkbenchReleaseService.kt
  92. +55
    -0
      src/main/java/com/ffii/fpsms/modules/deliveryOrder/service/TruckLaneSearchSpec.kt
  93. +75
    -0
      src/main/java/com/ffii/fpsms/modules/deliveryOrder/service/WorkbenchReleaseTypeSupport.kt
  94. +52
    -2
      src/main/java/com/ffii/fpsms/modules/deliveryOrder/web/DeliveryOrderController.kt
  95. +91
    -8
      src/main/java/com/ffii/fpsms/modules/deliveryOrder/web/DoWorkbenchController.kt
  96. +30
    -2
      src/main/java/com/ffii/fpsms/modules/deliveryOrder/web/models/DoDetailResponse.kt
  97. +62
    -0
      src/main/java/com/ffii/fpsms/modules/deliveryOrder/web/models/DoReplenishmentModels.kt
  98. +1
    -0
      src/main/java/com/ffii/fpsms/modules/deliveryOrder/web/models/ExportDNLabelsRequest.kt
  99. +2
    -1
      src/main/java/com/ffii/fpsms/modules/deliveryOrder/web/models/PrintDNLabelsRequest.kt
  100. +7
    -2
      src/main/java/com/ffii/fpsms/modules/deliveryOrder/web/models/ReleaseDoRequest.kt

+ 11
- 0
.cursor/commands/gen-commit-test-plans.md View File

@@ -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 View File

@@ -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.

+ 33
- 0
.cursor/rules/frontend-prevent-duplicate-api-calls.mdc View File

@@ -0,0 +1,33 @@
---
description: Prevent double-click / duplicate API calls from frontend UI
alwaysApply: true
---

# Prevent duplicated API calls (frontend)

When wiring UI actions (buttons, row actions, dialogs) to backend APIs, **always prevent double submission**. Relying on `setState` + `disabled` alone is not sufficient because rapid double-click can fire twice before React re-renders.

- **Must**: add an **in-flight lock** (e.g. `useRef(false)`) and early-return if already running.
- **Must**: keep the UI disabled/loading (`disabled={isLoading}`) for user feedback.
- **Must**: clear the lock in `finally` so it always releases.
- **Should**: if the same endpoint can be triggered from multiple places, consider a shared “single-flight” helper (dedupe by `method+url+body` key).

Example pattern:

```tsx
const inFlightRef = useRef(false);
const [isSaving, setIsSaving] = useState(false);

const onSave = async () => {
if (inFlightRef.current) return;
inFlightRef.current = true;
setIsSaving(true);
try {
await doRequest();
} finally {
setIsSaving(false);
inFlightRef.current = false;
}
};
```


+ 28
- 0
.cursor/rules/gen-commit-test-plan.mdc View File

@@ -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`

+ 785
- 0
.cursor/skills/fp-mtms-version-checklist/SKILL.md View File

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

# FP-MTMS Version Checklist Excel

## Purpose

Maintain the team **FP-MTMS System Version Control Checklist**: page/program
level and function-level version history in a shared SharePoint Excel file.

## Language

Keep Excel **column headers** exactly as in the file (English).

### 中英對照 (required for cell content)

All written cell values must be **Traditional Chinese + English**
(中英對照), using i18n labels where they exist.

**Exception — do NOT bilingualize:**

- `Functions in Page&Program` column **G — Name of Function within
Program** (English symbol + source path only; see Column G rule)
- Version & Date fields (e.g. `v1.0.0 2026-07-14`)
- Developed By (person name as-is)
- Ref. No. (number)

### Format

**Short labels** (subsystem / page names):

```text
批號追溯 / Item Tracing
倉庫管理 / Store Management
```

**Longer prose** (purpose, highlights, major changes): Chinese first,
then English on the **next line**. For usage/呼叫來源, keep it on its
own line(s) as well:

```text
工單提料執行頁面(/jodetail)。
Job Order Pick Execution page (/jodetail).
使用/呼叫來源:批號追溯(單據深連結開啟已完成提料紀錄)。
Used / called from: Item Tracing (doc deep-link to open completed pick record).
```

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

Prefer EN/ZH pairs from `src/i18n/en/*` and `src/i18n/zh/*` for menu and
feature terms.

## Excel formatting (required on every write)

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

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

## Excel path (per developer)

Path varies by Windows user / OneDrive sync root. Resolve in this order:

1. If the user provides a full path, use it.
2. Else try under the user profile home:

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

3. If missing, search under `{USERPROFILE}` for
`FP-MTMS Version Programs and Functions Checklist v0.1.xlsx` (real `.xlsx` only).
4. **Always confirm the resolved path with the user before writing.**

### Archive Excel path

Same folder as the main checklist. Default:

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

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

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

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

**Never** ask the user one question per cell / column.

Instead:

1. Infer all planned adds/updates from conversation context, git diff, PR,
feature description, and existing Excel rows.
2. Before any write, show a **full preview** of every row that will be added or
changed (including history shifts and any Archive long-table appends).
3. Ask **one** confirmation: whether this preview is correct (yes / no / what
to fix). Prefer the Ask Questions tool when available; otherwise ask once
in chat.
4. Only after explicit approval, write with `openpyxl` and save.

If the preview is wrong, adjust and show a **revised preview**; do not write
until approved.

If critical facts are truly unknown (e.g. version number or developer name
cannot be inferred), ask at most a short batch of missing items — never walk
the sheet column by column.

## Pre-check: matching program / feature must exist

Before drafting or writing any row:

1. Open the workbook and search both sheets for the target
**System Page / Program Name** (and for Functions sheet, also
**Name of Function within Program**).
2. Match on existing rows when the program/page already exists.
3. If updating a function: the parent page/program should already exist on
`Page&Program Name` (or be included in the same preview as a new page row).
Set **Page Ref. No. (B)** to that page row’s Ref. No.
4. If nothing matches and the user intends a **new** program or function, say so
clearly in the preview as `NEW ROW`.
5. Do **not** invent duplicate rows for the same page + same function.
6. Different **功能** (distinct functions) → **different Ref. No. rows**, even
under the same System Page / Program Name.

## History shift (required on every update)

Each page or function keeps **Latest** plus up to **3 previous** history slots
(1st, 2nd, 3rd). On every new change to an existing row, **push history
rightward** before writing the new Latest:

| Before write | After shift |
|--------------|-------------|
| Latest | → 1st previous |
| 1st previous | → 2nd previous |
| 2nd previous | → 3rd previous |
| 3rd previous | → **Archive Excel** (append long-table row; then clear 3rd on main) |

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

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

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

Treat each “slot” as a triple:

- **Latest**: D (Version & Date), F (Major Changes), G (Developed By)
- **1st**: H, I, J
- **2nd**: K, L, M
- **3rd**: N, O, P

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

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

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

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

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

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

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

Rules:

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

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

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

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

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

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

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

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

### Sheet: `Functions in Page&Program` (archive)

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

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

### Archive write rules

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

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

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

- **Page&Program Name** may still advance Latest / history for a page-level
release (e.g. backend-only fix under the same page).
- **Functions in Page&Program**: shift I→L→O→R **only** when this row’s
function(s) actually changed (diff touches the verified G symbol(s) / paths).
- If a commit updates the **page** but not this function:
- You may refresh column **E** (Latest Page Version) to match the page row.
- Leave **I/J/K** and previous function history **unchanged**.
- Do **not** write filler Latest/history text such as「頁面版本對齊;本功能無程式變更」
/ “Page version align; no UI change this release”.
- When mapping several commits into history for a **new** function row: include
only commits that changed that function; leave unused 1st/2nd/3rd slots empty.
Different function rows under the same page may have **different** Latest
versions and history depths.

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

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

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

**Required content (中英對照 for prose; paths/lines stay as-is):**

1. **Summary** — short ZH then EN (what the release did).
2. **Per changed file** — repo-relative path.
3. **Line range(s)** — current file line numbers after the change (prefer
`start–end`; single line OK). Re-resolve with `git show` / Read / Grep;
do not guess.
4. **Short explanation by line/range** — what that hunk does (ZH then EN, or
one ZH+EN pair per bullet).

**Cell layout example:**

```text
轉倉出庫批次 ledger 餘額鏈結修正。
TRF stock-out batch ledger balance chaining fix.
StockOutLineService.kt
L1518: ledger 查詢改為 findFirstByItemIdAndDeletedFalseOrderByDateDescIdDesc。
L1518: ledger lookup → findFirstByItemIdAndDeletedFalseOrderByDateDescIdDesc.
L2318–2347 (createStockOutBatch): 同 batch 多行依 runningLedgerBalance 扣帳,不再每次用 onHandQty。
L2318–2347 (createStockOutBatch): chain per-item running ledger balance within a batch instead of onHandQty each line.
StockInLineService.kt
L269 / L312: assignLotNo / assignLotNoForJo 改為 open fun(無邏輯變更)。
L269 / L312: assignLotNo / assignLotNoForJo marked open (no logic change).
```

**Rules:**

- Fact-check path + line numbers against the repo at write time (lines drift;
re-check before save).
- Group by file; under each file, one bullet per meaningful hunk / line range.
- Prefer the member / symbol name in the explanation when helpful
(e.g. `createStockOutBatch`, `assignLotNo`).
- If many files: keep bullets short; still list each touched path that belongs
to this row (Functions row → only files/symbols in column G, plus closely
related hunks in those files for this change).
- Page-sheet Major Changes may summarize all files for that page release;
Functions-sheet Major Changes stay scoped to that function row’s G symbols
and their files.
- Show the same file/line bullets in the **preview** before write.
- Pure i18n-only or one-liner UI copy changes: still cite file + line(s)
(e.g. `InventoryLotLineTable.tsx L508–518: success message by API code`).

## Batch updates

Many rows may change in one invocation (multiple pages and/or functions).

- Build **one combined preview** covering all affected rows.
- One approval covers the whole batch.
- Still one Ref. No. row per distinct 功能 / function.

## Sheets and columns

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

### Sheet: `Page&Program Name`

| Col | Header |
|-----|--------|
| A | Ref. No. |
| B | Name of Subsystem / Module / Menu Selection |
| C | System Page / Program Name |
| D | Latest Page Version & Date |
| E | Page Purpose Highlights |
| F | Major Changes Highlights |
| G | Developed By |
| H | 1st Previous Page Version & Date |
| I | 1st Major Changes Highlights |
| J | Developed By |
| K | 2nd Previous Page Version & Date |
| L | 2nd Major Changes Highlights |
| M | Developed By |
| N | 3rd Previous Page Version & Date |
| O | 3rd Major Changes Highlights |
| P | Developed By |

### Sheet: `Functions in Page&Program`

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

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

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

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

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

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

Format:

```text
<EnglishSymbol> — <repo-relative path>
```

Examples:

- `ItemTracingScanBar — src/components/ItemTracing/ItemTracingScanBar.tsx`
- `GoodPickExecutionWorkbenchRecord — src/components/DoWorkbench/GoodPickExecutionWorkbenchRecord.tsx`

If one checklist function spans multiple primary files, put **each**
symbol + path on its **own new line** (do not join with `; ` on one line):

```text
ItemTracingSummary — src/components/ItemTracing/ItemTracingSummary.tsx
ItemTracingLocations — src/components/ItemTracing/ItemTracingLocations.tsx
```
##### Service / controller / class files — also list member functions

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

Put **each class + its methods + its path on its own line** (Excel `\n`,
wrap text). Do not squeeze multiple files onto one line with `; `.

Format (one file per line):

```text
<ClassName>.<fun1> / .<fun2> — <path-to-that-file>
```

Multi-file example:

```text
ItemLotTraceService.trace / .traceLocation — src/main/java/com/ffii/fpsms/modules/stock/service/ItemLotTraceService.kt
InventoryLotLineController.traceLot / .traceLocation — src/main/java/com/ffii/fpsms/modules/stock/web/InventoryLotLineController.kt
```

```text
PickOrderLifecycleController.getLifecycle / .getLifecycleByCode — src/main/java/.../PickOrderLifecycleController.kt
PickOrderLifecycleService.getLifecycle / .getLifecycleByCode — src/main/java/.../PickOrderLifecycleService.kt
```

For multi-file **component** rows, also one path per line:

```text
ItemTracingSummary — src/components/ItemTracing/ItemTracingSummary.tsx
ItemTracingLocations — src/components/ItemTracing/ItemTracingLocations.tsx
```

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

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

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

1. The owning page purpose (what page C is for), **and**
2. **Where this function is used / called from** — especially when the
caller is a **different** page than column C.

Owning page (C) = where the code/feature primarily lives.
Usage location = which page(s) invoke, deep-link into, or depend on it.

Examples:

- Function lives on `工單提料` but is opened from Item Tracing doc links:

```text
工單提料執行頁面(/jodetail)。
Job Order Pick Execution page (/jodetail).
使用/呼叫來源:批號追溯(單據深連結開啟已完成提料紀錄)。
Used / called from: Item Tracing (doc deep-link to open completed pick record).
```

- Function lives on `提料單` (lifecycle API) but is consumed for tracing:

```text
提料單管理。
Pick Order management.
使用/呼叫來源:批號追溯(追溯流程/單據生命週期查詢)。
Used / called from: Item Tracing (trace flow / document lifecycle query).
```

- Function lives on `成品出倉` but is deep-linked from tracing:

```text
成品出倉(DO Workbench)揀貨與紀錄作業。
DO Workbench pick execution and records.
使用/呼叫來源:批號追溯(單據深連結開啟成品出倉紀錄)。
Used / called from: Item Tracing (doc deep-link to open DO Workbench record).
```

- Function lives on and is only used by the same page (e.g. 批號追溯):

```text
…頁面目的(中文)…
…page purpose (English)…
使用/呼叫來源:本頁(批號追溯 / Item Tracing)。
Used / called from: this page (批號追溯 / Item Tracing).
```

Put **使用/呼叫來源** (and its English line) on **new lines** after the
page-purpose ZH/EN pair. Do not run usage on the same line as purpose.

When inferring usage, fact-check callers (imports, links, API consumers)
from the change/diff. Do not invent a caller. If usage is only the owning
page, say `本頁(<page>)`. If multiple callers, list them (i18n names).

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

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

Never invent a symbol or path. For **every** Functions-sheet row in the
preview (new or updated), verify against the real repos:

1. **Path exists** — each repo-relative path resolves under the correct
workspace (`FPSMS-frontend` or `FPSMS-backend`). Prefer `Glob` / `Read`
/ `Grep`; do not guess.
2. **Symbol is in that file** — the English symbol must actually be defined
or exported in the cited file (e.g. `const ItemTracingScanBar`,
`export default ItemTracingScanBar`, `open class ItemLotTraceService`,
`fun traceLot`, `class PickOrderLifecycleController`). Match casing
exactly as in code (`CompleteJobOrderRecord`, not `completeJobOrderRecord`).
For service/controller rows, also verify each listed **member function**
(e.g. `trace`, `traceLocation`, `getLifecycle`) exists in that file.
3. **Multi-path rows** — each symbol + path must be on its **own line**:
- Every line’s symbol must be defined in that line’s file.
- Example OK:

```text
ItemTracingSummary — …/ItemTracingSummary.tsx
ItemTracingLocations — …/ItemTracingLocations.tsx
```

- Do not join multiple files with `; ` on one line.
4. **Wrong location** — if the symbol lives elsewhere, correct the path to
the real file; do not keep a convenient-but-false path.
5. **Not found** — if the file or symbol cannot be verified, do **not** put
it in G. Omit the row or ask the user; never write an unverified G.

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

## Preview format (required before save)

Show a clear preview, for example:

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

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

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

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

Confirm: Is this preview correct? [Yes / No — tell me what to change]
```

For page-sheet rows, use the same style with D/F/G and H–P history.

## Workflow

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

## Code comments on every updated function (required)

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

### Comment contents (required fields)

Must include:

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

### Canonical format

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

**TypeScript / TSX** (JSDoc immediately above the component / export /
function):

```ts
/** FP-MTMS Version Checklist | Functions Ref. No. 1 | v1.0.0 | 2026-07-14 */
const ItemTracingScanBar: React.FC<ScanBarProps> = (...) => {
```

**Kotlin** (KDoc immediately above the `fun` / class member):

```kotlin
/** FP-MTMS Version Checklist | Functions Ref. No. 7 | v1.0.0 | 2026-07-14 */
open fun trace(...): ItemLotTraceResponse = ...
```

If a KDoc/JSDoc already exists, **prepend or merge** this checklist line
into it (do not delete useful existing documentation). Prefer keeping the
checklist line as the first line of the block:

```kotlin
/**
* FP-MTMS Version Checklist | Functions Ref. No. 7 | v1.0.0 | 2026-07-14
* Lazy-load: returns the full location-scoped trace block...
*/
```

### Placement rules

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

### Preview

The Excel preview must also list planned comment updates, e.g.:

```text
Code comments to add/update:
Ref 7 → ItemLotTraceService.trace / .traceLocation (v1.0.0 | 2026-07-14)
Ref 1 → ItemTracingScanBar (v1.0.0 | 2026-07-14)
```

Only edit comments after preview approval (same gate as Excel write), unless
the user explicitly asks to sync comments only.

## Inference hints (for filling the preview)

- **Version & Date**: next patch/semver from conversation/git if known; else
`vX.Y.Z YYYY-MM-DD` with today’s date.
- **Developed By**: `git config user.name` or chat context.
- **Purpose / Changes / Function highlight**: 中英對照; short bullets from
recent PR/diff; prefer i18n terms.
- **Major Changes (Page F / Functions J and history slots)**: summary ZH/EN
**plus** each changed file, line range(s), and short per-hunk explanation
(see Major Changes: file, lines, explanation). Resolve lines from git diff
+ current file; do not omit locations.
- **Subsystem / Page names**: `中文 / English` from i18n EN+ZH.
- **Function name (col G)**: English code symbol + source file path only
(no 中英對照). **Fact-check** symbol+path before preview.
- **Page Purpose (F)**: 中英對照 + usage/呼叫來源 lines (ZH then EN).
- **Page Ref. No. (B)**: parent Page sheet Ref. No. for column D’s page.
- **Ref. No.**: for new rows, next integer after the last used Ref. No. on that sheet.
- Prefer updating an existing row over creating a duplicate when page +
English function symbol (and file) match.

## Do not

- Ask the user to fill cells one column at a time
- Save without an approved preview
- Skip history shift when updating an existing Latest
- Drop a filled **3rd previous** without appending it to the Archive long table
- Add or fill an Archive **Source** column (removed; do not reintroduce)
- Overwrite or edit existing Archive rows (append only)
- Skip Excel formatting (wrap text / newlines) on multi-line cells you write
- Push or invent **function** history (I–T) for a commit that did not change
that row’s column G symbols — no “page align / no code change” filler slots
- Write Major Changes as summary-only without **file path + line range(s) +
short per-hunk explanation** (Page F / Functions J and matching history cols)
- Write Chinese-only or English-only prose in bilingual-required columns
(everything except Function name G, versions, Developed By, Ref. No., Page Ref. No., and
the path/line tokens inside Major Changes)
- Put only a Chinese UI label in **Name of Function within Program** without
the English symbol and source file
- Bilingualize column G (keep symbol + path English-only)
- Omit usage/呼叫來源 from **Page Purpose Highlights (F)** when the function
is invoked from another page (e.g. 批號追溯 deep-link into 工單提料)
- Cite a `*Service` / `*Controller` class in G without its relevant member
function names (e.g. write `ItemLotTraceService.trace / .traceLocation`,
not only `ItemLotTraceService`)
- Write a G value whose symbol or path was not verified in the codebase
- Leave **Page Ref. No. (B)** blank or pointing at a non-existent Page row
- Guess file paths or rename symbols to “look right” without opening the file
- Put two different 功能 on the same Ref. No. row
- Create a function row when the parent program/page was neither found nor
included in the same approved preview as a new page row
- Clear history columns without showing that shift/archive in the preview
- Skip adding/updating FP-MTMS checklist comments on code for Functions rows
that were just written (must include Ref. No., version, and update date)
- Commit the Excel file to git
- Edit cloud URLs / `.url` files as if they were workbooks

+ 5
- 1
.gitignore View File

@@ -35,6 +35,10 @@ out/

### VS Code ###
.vscode/

### Cursor (local-only rules) ###
.cursor/rules/local/
package-lock.json
python/Bag3.spec
python/dist/Bag3.exe
python/dist


+ 32
- 0
AGENTS.md View File

@@ -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 View File

@@ -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 View File

@@ -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 View File

@@ -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 View File

@@ -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 View File

@@ -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 View File

@@ -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 View File

@@ -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 View File

@@ -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 View File

@@ -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 View File

@@ -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 View File

@@ -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 View File

@@ -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 View File

@@ -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 View File

@@ -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 View File

@@ -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 View File

@@ -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 View File

@@ -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 View File


+ 177
- 0
docs/exports/MTMS_Schedule_JobOrder_UserGuide.docx View File

@@ -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 View File

@@ -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 View File

@@ -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 View File

@@ -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 View File

@@ -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 View File

@@ -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 View File

@@ -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,以實機為準並回報補譯 |

+ 387
- 173
python/Bag3.py View File

@@ -16,6 +16,7 @@ Bag2 is kept as a separate legacy v2.x line; do not assume Bag2 matches Bag3.
Run: python Bag3.py
"""

import errno
import json
import os
import select
@@ -25,6 +26,7 @@ import tempfile
import threading
import time
import tkinter as tk
from dataclasses import dataclass
from datetime import date, datetime, timedelta
from tkinter import messagebox, ttk
from typing import Callable, Optional, Tuple
@@ -344,6 +346,25 @@ DATAFLEX_UI_PROGRESS_EVERY = max(
DATAFLEX_SINGLE_TCP_JOB = _dataflex_bool_env(
"FPSMS_DATAFLEX_SINGLE_TCP_JOB", False
)
# Link-OS SGD: raw-ZPL job shows this host id instead of a generic name (e.g. "ZPL. EMULATION").
# Same id when the same job order is printed again. Disable: FPSMS_DATAFLEX_HOST_IDENTIFICATION_SGD=0
DATAFLEX_HOST_IDENTIFICATION_SGD = _dataflex_bool_env(
"FPSMS_DATAFLEX_HOST_IDENTIFICATION_SGD", True
)
# Bag ZPL size (dots). ^PW700 matched little content (mostly vertical ^A@R), so previews showed a wide strip with empty right margin.
DATAFLEX_LABEL_PW = max(
280,
_dataflex_int_env("FPSMS_DATAFLEX_LABEL_PW", 400),
)
DATAFLEX_LABEL_LL = max(
200,
_dataflex_int_env("FPSMS_DATAFLEX_LABEL_LL", 500),
)
# Some Zebra/DataFlex units RST the socket on host half-close; Windows surfaces WinError 10054.
# Set FPSMS_DATAFLEX_SKIP_SHUTDOWN_WR=1 to omit shutdown(SHUT_WR) and only close() (often avoids RST).
DATAFLEX_SKIP_SHUTDOWN_WR = _dataflex_bool_env(
"FPSMS_DATAFLEX_SKIP_SHUTDOWN_WR", False
)
# Full recovery (~JR soft reset) — used by「打袋重設」only; longer delay for firmware
DATAFLEX_POST_FULL_RECOVERY_DELAY_SEC = 1.2
# Zebra ~RO only (used when FPSMS_DATAFLEX_NO_JR is set for full recovery)
@@ -364,12 +385,56 @@ def _zpl_escape(s: str) -> str:
return s.replace("\\", "\\\\").replace("^", "\\^")


def _dataflex_host_identification_sgd_prefix(job_order_id: Optional[int]) -> str:
"""
Optional ASCII prefix before ^XA: set zpl.host_identification so the printer lists the job
under the job order id instead of a generic raw-ZPL label.
"""
if not DATAFLEX_HOST_IDENTIFICATION_SGD or job_order_id is None:
return ""
try:
jid = str(int(job_order_id))
except (TypeError, ValueError):
return ""
if not jid.isdigit():
return ""
return f'! U1 setvar "zpl.host_identification" "{jid}"\r\n'


def _dataflex_zpl_bytes(zpl: str) -> bytes:
"""UTF-8 ZPL with one trailing CRLF so the printer sees a clear job boundary."""
s = (zpl or "").rstrip("\r\n")
return (s + "\r\n").encode("utf-8")


def _dataflex_is_benign_tcp_reset(err: BaseException) -> bool:
"""True when peer closed with RST/FIN in a way that is normal for raw printer TCP (Windows 10054)."""
if isinstance(err, (BrokenPipeError, ConnectionResetError, ConnectionAbortedError)):
return True
if isinstance(err, OSError):
if getattr(err, "winerror", None) == 10054: # WSAECONNRESET
return True
if err.errno in (
errno.ECONNRESET,
errno.EPIPE,
errno.ECONNABORTED,
):
return True
return False


def _dataflex_shutdown_write_maybe(sock: socket.socket) -> None:
"""Half-close write side; ignore printer RST (common after ZPL on port 9100-style links)."""
if DATAFLEX_SKIP_SHUTDOWN_WR:
return
try:
sock.shutdown(socket.SHUT_WR)
except OSError as e:
if _dataflex_is_benign_tcp_reset(e):
return
raise


def generate_zpl_dataflex(
batch_no: str,
item_code: str,
@@ -377,6 +442,7 @@ def generate_zpl_dataflex(
item_id: Optional[int] = None,
stock_in_line_id: Optional[int] = None,
lot_no: Optional[str] = None,
job_order_id: Optional[int] = None,
font_regular: str = "E:STXihei.ttf",
font_bold: str = "E:STXihei.ttf",
) -> str:
@@ -398,11 +464,12 @@ def generate_zpl_dataflex(
qr_value = _zpl_escape(qr_payload)
# Explicit ^PQ1: each ^XA…^XZ is exactly one bag. Avoids E1005 "over quantity" on some Zebra/DataFlex
# firmware when many labels are sent on one TCP session without a per-job quantity.
return f"""^XA
host_id = _dataflex_host_identification_sgd_prefix(job_order_id)
return host_id + f"""^XA
^PQ1,0,1,N
^CI28
^PW700
^LL500
^PW{DATAFLEX_LABEL_PW}
^LL{DATAFLEX_LABEL_LL}
^PO N
^FO10,20
^BQN,2,4^FDQA,{qr_value}^FS
@@ -447,10 +514,7 @@ def send_dataflex_preprint_reset(ip: str, port: int, *, force: bool = False) ->
sock.connect((ip, port))
sock.sendall(DATAFLEX_PREPRINT_BYTES)
time.sleep(DATAFLEX_POST_PREPRINT_DELAY_SEC)
try:
sock.shutdown(socket.SHUT_WR)
except OSError:
pass
_dataflex_shutdown_write_maybe(sock)
finally:
sock.close()

@@ -472,10 +536,7 @@ def send_dataflex_job_counter_reset(ip: str, port: int, *, force: bool = False)
sock.connect((ip, port))
sock.sendall(_dataflex_full_recovery_payload())
time.sleep(DATAFLEX_POST_FULL_RECOVERY_DELAY_SEC)
try:
sock.shutdown(socket.SHUT_WR)
except OSError:
pass
_dataflex_shutdown_write_maybe(sock)
finally:
sock.close()

@@ -527,10 +588,7 @@ def send_dataflex_reset_and_labels(
time.sleep(DATAFLEX_POST_LABEL_SETTLE_SEC)
if i < copies - 1:
time.sleep(delay_sec)
try:
sock.shutdown(socket.SHUT_WR)
except OSError:
pass
_dataflex_shutdown_write_maybe(sock)
finally:
sock.close()

@@ -879,10 +937,7 @@ def send_zpl_to_dataflex(ip: str, port: int, zpl: str) -> None:
sock.connect((ip, port))
sock.sendall(_dataflex_zpl_bytes(zpl))
time.sleep(DATAFLEX_POST_LABEL_SETTLE_SEC)
try:
sock.shutdown(socket.SHUT_WR)
except OSError:
pass
_dataflex_shutdown_write_maybe(sock)
finally:
sock.close()

@@ -907,6 +962,10 @@ def query_dataflex_host_status(ip: str, port: int) -> str:
data = sock.recv(4096)
except socket.timeout:
break
except OSError as ex:
if _dataflex_is_benign_tcp_reset(ex):
break
raise
if not data:
break
chunks.append(data)
@@ -1829,6 +1888,204 @@ def ask_bag_count(parent: tk.Tk) -> Optional[Tuple[int, bool]]:
return result[0]


@dataclass(frozen=True)
class DataflexPrintSession:
"""
Snapshot taken when the user starts DataFlex print (especially C 連續印).
The worker must use only this object — not grid row index, scroll position, or selection.
"""

job_order_id: Optional[int]
job_code: str
item_code: str
item_name: str
label_text: str
zpl: str
printer_ip: str
printer_port: int
batch_display: str


def build_dataflex_print_session(
jo: dict,
batch: str,
zpl: str,
label_text: str,
printer_ip: str,
printer_port: int,
) -> DataflexPrintSession:
jo_id = jo.get("id")
jo_code = (jo.get("code") or "").strip()
if not jo_code and jo_id is not None:
jo_code = f"#{jo_id}"
elif not jo_code:
jo_code = "—"
return DataflexPrintSession(
job_order_id=int(jo_id) if jo_id is not None else None,
job_code=jo_code,
item_code=(jo.get("itemCode") or "—").strip(),
item_name=(jo.get("itemName") or "—").strip(),
label_text=label_text,
zpl=zpl,
printer_ip=printer_ip,
printer_port=printer_port,
batch_display=(batch or "—").strip(),
)


def run_dataflex_continuous_thread(
root: tk.Tk,
session: DataflexPrintSession,
stop_event: threading.Event,
stop_win: tk.Toplevel,
dataflex_lock: threading.Lock,
dataflex_busy_ref: list,
dataflex_stop_win_ref: list,
active_session_ref: list,
base_url: str,
set_status_message: Callable[[str, bool], None],
on_recorded: Callable[[], None],
) -> None:
"""Send bags in a loop until stop_event; all payload comes from session (in-memory snapshot)."""

def worker() -> None:
with dataflex_lock:
if dataflex_busy_ref[0]:
active_session_ref[0] = None

def _abort_start() -> None:
messagebox.showwarning(
"打袋機",
"請等待目前列印完成或先停止連續列印。",
)
dataflex_stop_win_ref[0] = None
try:
stop_win.destroy()
except tk.TclError:
pass

root.after(0, _abort_start)
return
dataflex_busy_ref[0] = True

ip = session.printer_ip
port = session.printer_port
zpl = session.zpl
label_text = session.label_text
printed = 0
error_shown = False
try:
send_dataflex_start_job_reset(ip, port, force=True)
while not stop_event.is_set():
send_dataflex_label_with_recovery(ip, port, zpl)
printed += 1
if DATAFLEX_UI_PROGRESS_EVERY > 0 and (
printed == 1 or printed % DATAFLEX_UI_PROGRESS_EVERY == 0
):
p = printed
root.after(
0,
lambda p=p, jc=session.job_code: set_status_message(
f"連續打袋 · 工單 {jc}… 已印 {p} 張",
is_error=False,
),
)
if (
DATAFLEX_VERIFY_EVERY_LABELS > 0
and printed % DATAFLEX_VERIFY_EVERY_LABELS == 0
):
recover_dataflex_if_host_fault(ip, port)
if (
DATAFLEX_COOLDOWN_EVERY_LABELS > 0
and printed % DATAFLEX_COOLDOWN_EVERY_LABELS == 0
):
_sleep_interruptible(stop_event, max(0.0, DATAFLEX_COOLDOWN_SEC))
if (
DATAFLEX_THERMAL_REST_EVERY_LABELS > 0
and printed % DATAFLEX_THERMAL_REST_EVERY_LABELS == 0
):
_sleep_interruptible(stop_event, max(0.0, DATAFLEX_THERMAL_REST_SEC))
_sleep_interruptible(stop_event, DATAFLEX_INTER_LABEL_DELAY_SEC)
except ConnectionRefusedError:
error_shown = True
root.after(
0,
lambda: set_status_message(
f"無法連線至 {ip}:{port},請確認印表機已開機且 IP 正確。",
is_error=True,
),
)
except socket.timeout:
error_shown = True
root.after(
0,
lambda: set_status_message(
f"連線逾時 ({ip}:{port}),請檢查網路與連接埠。",
is_error=True,
),
)
except OSError as err:
error_shown = True
root.after(
0,
lambda e=err: set_status_message(f"列印失敗:{e}", is_error=True),
)
except RuntimeError as err:
error_shown = True
root.after(
0,
lambda e=err: set_status_message(f"打袋機錯誤:{e}", is_error=True),
)
except Exception as err:
error_shown = True
root.after(
0,
lambda e=err: set_status_message(f"打袋機例外:{e}", is_error=True),
)
finally:
with dataflex_lock:
dataflex_busy_ref[0] = False
active_session_ref[0] = None

def _done() -> None:
dataflex_stop_win_ref[0] = None
try:
if os.name == "nt":
stop_win.attributes("-topmost", False)
except tk.TclError:
pass
try:
stop_win.destroy()
except tk.TclError:
pass
jc = session.job_code
if printed > 0:
set_status_message(
f"連續列印結束:工單 {jc} · {label_text},已印 {printed} 張",
is_error=False,
)
if session.job_order_id is not None:
try:
submit_job_order_print_submit(
base_url,
session.job_order_id,
printed,
"DATAFLEX",
)
on_recorded()
except requests.RequestException as ex:
messagebox.showwarning(
"打袋機",
f"列印可能已完成,但伺服器記錄失敗(可再試):{ex}",
)
elif not error_shown:
set_status_message("連續列印未印出或已取消", is_error=True)

root.after(0, _done)

threading.Thread(target=worker, daemon=True).start()


def _sleep_interruptible(stop_event: threading.Event, total_sec: float) -> None:
"""Sleep up to total_sec but return early if stop_event is set."""
end = time.perf_counter() + total_sec
@@ -1845,16 +2102,18 @@ def open_dataflex_stop_window(
parent: tk.Tk,
stop_event: threading.Event,
stop_win_ref: list,
session: DataflexPrintSession,
) -> tk.Toplevel:
"""
Small window with 停止列印 for DataFlex continuous mode (non-modal so stop stays usable).

Stays above other dialogs (e.g. 標籤機 quantity) via periodic lift + optional topmost on Windows,
so switching printer and printing labels does not hide the stop control. Ref is cleared on destroy.
Job details come from the in-memory session snapshot, not the grid selection.
"""
win = tk.Toplevel(parent)
win.title("打袋機連續列印")
win.geometry("420x170")
win.geometry("480x240")
# On Windows, transient(root) can hide this Toplevel when the menubutton / printer row
# updates (e.g. switching to 激光機); keep transient only on non-Windows.
if os.name != "nt":
@@ -1869,11 +2128,28 @@ def open_dataflex_stop_window(

tk.Label(
win,
text="連續列印進行中(與上方列印機選項無關),可隨時按下方停止。",
text="連續列印進行中(內容以按下 C 時的工單為準,與列表捲動/日期無關)",
font=get_font(FONT_SIZE_META),
bg=BG_TOP,
wraplength=440,
justify=tk.CENTER,
).pack(pady=(12, 6))
detail = (
f"工單:{session.job_code}\n"
f"品號:{session.item_code}\n"
f"品名:{session.item_name}\n"
f"批次/批號:{session.label_text}"
)
tk.Label(
win,
text=detail,
font=get_font(FONT_SIZE),
bg=BG_TOP,
wraplength=400,
).pack(pady=(16, 8))
fg="#111111",
wraplength=440,
justify=tk.LEFT,
anchor=tk.W,
).pack(padx=16, pady=(0, 8), fill=tk.X)

def clear_topmost() -> None:
if os.name == "nt":
@@ -1985,6 +2261,8 @@ def main() -> None:
label_busy_ref: list = [False]
# DataFlex continuous: stop Toplevel ref so we can lift it after other dialogs
dataflex_stop_win_ref: list = [None]
# In-memory job snapshot for C 連續印 (not tied to grid row position after start)
active_dataflex_session_ref: list[Optional[DataflexPrintSession]] = [None]

def lift_dataflex_stop_if_running() -> None:
"""After closing another dialog (e.g. 標籤印數), bring the stop panel forward again."""
@@ -2417,6 +2695,20 @@ def main() -> None:
name_lbl.pack(anchor=tk.NW)

def _on_click(e, j=jo, b=batch, r=row):
if (
printer_var.get() == "打袋機 DataFlex"
and dataflex_busy_ref[0]
and active_dataflex_session_ref[0] is not None
):
s = active_dataflex_session_ref[0]
messagebox.showwarning(
"打袋機",
f"連續列印進行中,請先按「停止列印」。\n\n"
f"工單:{s.job_code}\n"
f"品號:{s.item_code}\n"
f"品名:{s.item_name}",
)
return
if selected_row_holder[0] is not None:
set_row_highlight(selected_row_holder[0], False)
set_row_highlight(r, True)
@@ -2451,161 +2743,47 @@ def main() -> None:
item_id=item_id,
stock_in_line_id=stock_in_line_id,
lot_no=lot_no,
job_order_id=j.get("id"),
)
label_text = (lot_no or b).strip()
if continuous:
if dataflex_busy_ref[0]:
messagebox.showwarning(
"打袋機",
"請等待目前列印完成或先停止連續列印。",
)
return
session = build_dataflex_print_session(
j,
b,
zpl,
label_text,
ip,
port,
)
active_dataflex_session_ref[0] = session
stop_ev = threading.Event()
stop_win = open_dataflex_stop_window(
root, stop_ev, dataflex_stop_win_ref
root,
stop_ev,
dataflex_stop_win_ref,
session,
)
run_dataflex_continuous_thread(
root=root,
session=session,
stop_event=stop_ev,
stop_win=stop_win,
dataflex_lock=dataflex_lock,
dataflex_busy_ref=dataflex_busy_ref,
dataflex_stop_win_ref=dataflex_stop_win_ref,
active_session_ref=active_dataflex_session_ref,
base_url=base_url_ref[0],
set_status_message=set_status_message,
on_recorded=lambda: load_job_orders(
from_user_date_change=False
),
)

def dflex_worker() -> None:
with dataflex_lock:
if dataflex_busy_ref[0]:
root.after(
0,
lambda: messagebox.showwarning(
"打袋機",
"請等待目前列印完成或先停止連續列印。",
),
)
return
dataflex_busy_ref[0] = True
printed = 0
error_shown = False
try:
# One TCP job per bag (not one endless stream). Persistent socket
# caused E1005 over-qty on some DataFlex units after a few labels.
send_dataflex_start_job_reset(ip, port, force=True)
while not stop_ev.is_set():
send_dataflex_label_with_recovery(ip, port, zpl)
printed += 1
if DATAFLEX_UI_PROGRESS_EVERY > 0 and (
printed == 1
or printed % DATAFLEX_UI_PROGRESS_EVERY == 0
):
p = printed
root.after(
0,
lambda p=p: set_status_message(
f"連續打袋列印中… 已印 {p} 張",
is_error=False,
),
)
if (
DATAFLEX_VERIFY_EVERY_LABELS > 0
and printed % DATAFLEX_VERIFY_EVERY_LABELS == 0
):
recover_dataflex_if_host_fault(ip, port)
if (
DATAFLEX_COOLDOWN_EVERY_LABELS > 0
and printed % DATAFLEX_COOLDOWN_EVERY_LABELS == 0
):
_sleep_interruptible(
stop_ev,
max(0.0, DATAFLEX_COOLDOWN_SEC),
)
if (
DATAFLEX_THERMAL_REST_EVERY_LABELS > 0
and printed % DATAFLEX_THERMAL_REST_EVERY_LABELS == 0
):
_sleep_interruptible(
stop_ev,
max(0.0, DATAFLEX_THERMAL_REST_SEC),
)
_sleep_interruptible(
stop_ev,
DATAFLEX_INTER_LABEL_DELAY_SEC,
)
except ConnectionRefusedError:
error_shown = True
root.after(
0,
lambda: set_status_message(
f"無法連線至 {ip}:{port},請確認印表機已開機且 IP 正確。",
is_error=True,
),
)
except socket.timeout:
error_shown = True
root.after(
0,
lambda: set_status_message(
f"連線逾時 ({ip}:{port}),請檢查網路與連接埠。",
is_error=True,
),
)
except OSError as err:
error_shown = True
root.after(
0,
lambda e=err: set_status_message(
f"列印失敗:{e}",
is_error=True,
),
)
except RuntimeError as err:
error_shown = True
root.after(
0,
lambda e=err: set_status_message(
f"打袋機錯誤:{e}",
is_error=True,
),
)
except Exception as err:
error_shown = True
root.after(
0,
lambda e=err: set_status_message(
f"打袋機例外:{e}",
is_error=True,
),
)
finally:
with dataflex_lock:
dataflex_busy_ref[0] = False

def _done() -> None:
dataflex_stop_win_ref[0] = None
try:
if os.name == "nt":
stop_win.attributes("-topmost", False)
except tk.TclError:
pass
try:
stop_win.destroy()
except tk.TclError:
pass
if printed > 0:
set_status_message(
f"連續列印結束:批次 {label_text},已印 {printed} 張",
is_error=False,
)
jo_id = j.get("id")
if jo_id is not None:
try:
submit_job_order_print_submit(
base_url_ref[0],
int(jo_id),
printed,
"DATAFLEX",
)
load_job_orders(from_user_date_change=False)
except requests.RequestException as ex:
messagebox.showwarning(
"打袋機",
f"列印可能已完成,但伺服器記錄失敗(可再試):{ex}",
)
elif not error_shown:
set_status_message(
"連續列印未印出或已取消",
is_error=True,
)

root.after(0, _done)

threading.Thread(target=dflex_worker, daemon=True).start()
else:
run_dataflex_fixed_qty_thread(
root=root,
@@ -2803,5 +2981,41 @@ def main() -> None:
root.mainloop()


def _startup_error_log_path() -> str:
if getattr(sys, "frozen", False):
base = os.path.dirname(sys.executable)
else:
base = os.path.dirname(os.path.abspath(__file__))
return os.path.join(base, "bag3_startup_error.log")


if __name__ == "__main__":
main()
try:
main()
except SystemExit:
raise
except Exception:
import traceback

log_path = _startup_error_log_path()
try:
with open(log_path, "w", encoding="utf-8") as f:
traceback.print_exc(file=f)
except OSError:
log_path = "(could not write log file)"
msg = f"Bag3 啟動失敗,詳情已寫入:\n{log_path}"
print(msg, file=sys.stderr)
traceback.print_exc()
try:
_err_root = tk.Tk()
_err_root.withdraw()
messagebox.showerror("Bag3", msg)
_err_root.destroy()
except Exception:
pass
if getattr(sys, "frozen", False):
try:
input("按 Enter 關閉…")
except (EOFError, KeyboardInterrupt):
pass
sys.exit(1)

BIN
python/__pycache__/Bag3.cpython-313.pyc View File


+ 32
- 2
python/installAndExe.txt View File

@@ -1,5 +1,35 @@
# Bag3 Windows exe build (run all commands in this python/ folder)

py -m pip install --upgrade pyinstaller
py -m pip install --upgrade pywin32
py -m pip install --upgrade Pillow "qrcode[pil]"
py -m pip install --upgrade Pillow "qrcode[pil]" requests

py -m PyInstaller --noconfirm --clean Bag3.spec

# Output: dist\Bag3\Bag3.exe plus dist\Bag3\_internal\...
# Copy the ENTIRE dist\Bag3\ folder to the client PC (not only Bag3.exe).

# --- If the client exe flashes and closes ---

1) On the client PC, open cmd in the Bag3 folder and run:
Bag3.exe
You should see the error in the console, or open bag3_startup_error.log next to Bag3.exe.

2) Compare BUILD machines (both should match):
py --version
py -m PyInstaller --version
py -m pip show pyinstaller pywin32 Pillow qrcode requests

A broken build is often caused by:
- Different Python major version (e.g. 3.13 vs 3.11)
- Incomplete tkinter on that Python (Store Python / partial install)
- Old PyInstaller missing Tcl/Tk files in the bundle

3) Rebuild on the machine that works, or reinstall Python from python.org (64-bit)
and reinstall deps above, then rebuild.

4) Bag3.spec disables UPX (upx=False) for stability; do not re-enable unless you test on the client.

5) Client needs 64-bit Windows and Microsoft VC++ Redistributable (same as your Python installer).

py -m PyInstaller --noconfirm --clean Bag3.spec
6) Antivirus may quarantine files under _internal\ — whitelist the Bag3 folder if the log mentions missing DLL.

+ 345
- 0
scripts/export_m18_mapping_office.py View File

@@ -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 View File

@@ -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 View File

@@ -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 View File

@@ -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 View File

@@ -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()

+ 37
- 0
src/main/java/com/ffii/fpsms/config/security/SecurityConfig.java View File

@@ -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 {
@@ -91,6 +96,38 @@ public class SecurityConfig {
.hasAnyAuthority("TESTING", "ADMIN", "STOCK")
.requestMatchers(HttpMethod.GET, "/product-process/Demo/Process/alerts/fg-qc-putaway")
.hasAuthority("TESTING")
.requestMatchers(HttpMethod.GET, "/device-presence/ping").authenticated()
.requestMatchers(HttpMethod.POST, "/device-presence/heartbeat").authenticated()
.requestMatchers(HttpMethod.GET, "/device-presence/active")
.hasAnyAuthority("TESTING", "ADMIN")
.requestMatchers(HttpMethod.GET, "/device-presence/history")
.hasAnyAuthority("TESTING", "ADMIN")
.requestMatchers(HttpMethod.GET, "/printer-monitor/status")
.hasAnyAuthority("TESTING", "ADMIN")
.requestMatchers(HttpMethod.GET, "/printer-monitor/history")
.hasAnyAuthority("TESTING", "ADMIN")
.requestMatchers(HttpMethod.POST, "/printer-monitor/check")
.hasAnyAuthority("TESTING", "ADMIN")
.requestMatchers(HttpMethod.GET, "/label-printer-monitor/status")
.hasAnyAuthority("TESTING", "ADMIN")
.requestMatchers(HttpMethod.POST, "/label-printer-monitor/check")
.hasAnyAuthority("TESTING", "ADMIN")
.requestMatchers(HttpMethod.GET, "/label-printer-monitor/label-stats")
.hasAnyAuthority("TESTING", "ADMIN")
.requestMatchers(HttpMethod.GET, "/label-printer-monitor/odometer-stats")
.hasAnyAuthority("TESTING", "ADMIN")
.requestMatchers(HttpMethod.POST, "/stockAdjustment/submit")
.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")))


+ 48
- 0
src/main/java/com/ffii/fpsms/m18/entity/M18BomShopSyncLog.kt View File

@@ -0,0 +1,48 @@
package com.ffii.fpsms.m18.entity

import com.ffii.core.entity.BaseEntity
import jakarta.persistence.Column
import jakarta.persistence.Entity
import jakarta.persistence.Table
import jakarta.validation.constraints.NotNull

/**
* Audit log for FPSMS → M18 udfBomForShop sync (request / response bodies).
*/
@Entity
@Table(name = "m18_bom_shop_sync_log")
open class M18BomShopSyncLog : BaseEntity<Long>() {

@NotNull
@Column(name = "bom_id", nullable = false)
open var bomId: Long? = null

@Column(name = "finished_item_code", length = 100)
open var finishedItemCode: String? = null

@Column(name = "m18_header_code", length = 200)
open var m18HeaderCode: String? = null

@Column(name = "request_fingerprint", length = 64)
open var requestFingerprint: String? = null

@Column(name = "m18_record_id")
open var m18RecordId: Long? = null

@NotNull
@Column(name = "m18_api_status", nullable = false)
open var m18ApiStatus: Boolean = false

@NotNull
@Column(name = "synced", nullable = false)
open var synced: Boolean = false

@Column(name = "message", length = 4000)
open var message: String? = null

@Column(name = "request_json", columnDefinition = "LONGTEXT")
open var requestJson: String? = null

@Column(name = "response_json", columnDefinition = "LONGTEXT")
open var responseJson: String? = null
}

+ 49
- 0
src/main/java/com/ffii/fpsms/m18/entity/M18BomShopSyncLogRepository.kt View File

@@ -0,0 +1,49 @@
package com.ffii.fpsms.m18.entity

import com.ffii.core.support.AbstractRepository
import org.springframework.data.jpa.repository.Query
import org.springframework.data.repository.query.Param

interface M18BomShopSyncLogRepository : AbstractRepository<M18BomShopSyncLog, Long> {
fun findFirstByBomIdOrderByIdDesc(bomId: Long): M18BomShopSyncLog?

fun findTop100ByBomIdOrderByIdDesc(bomId: Long): List<M18BomShopSyncLog>

/** Successful M18 udfBomForShop saves only — used for `BOM{item}Vnnn` version allocation. */
fun findTop100ByBomIdAndSyncedIsTrueOrderByIdDesc(bomId: Long): List<M18BomShopSyncLog>

fun findFirstByBomIdAndSyncedIsTrueAndRequestFingerprintOrderByIdDesc(
bomId: Long,
requestFingerprint: String,
): M18BomShopSyncLog?

fun findFirstByBomIdAndSyncedIsTrueAndM18HeaderCodeOrderByIdDesc(
bomId: Long,
m18HeaderCode: String,
): M18BomShopSyncLog?

@Query(
"""
SELECT l FROM M18BomShopSyncLog l
WHERE l.deleted = false
AND (:syncDateStart IS NULL OR l.created >= :syncDateStart)
AND (:syncDateEnd IS NULL OR l.created <= :syncDateEnd)
AND (
:finishedItemCode IS NULL OR :finishedItemCode = ''
OR LOWER(l.finishedItemCode) LIKE LOWER(CONCAT('%', :finishedItemCode, '%'))
)
AND (
:syncStatus IS NULL OR :syncStatus = '' OR :syncStatus = 'all'
OR (:syncStatus = 'success' AND l.synced = true)
OR (:syncStatus = 'failed' AND l.synced = false)
)
ORDER BY l.created DESC, l.id DESC
""",
)
fun searchForReport(
@Param("syncDateStart") syncDateStart: java.time.LocalDateTime?,
@Param("syncDateEnd") syncDateEnd: java.time.LocalDateTime?,
@Param("finishedItemCode") finishedItemCode: String?,
@Param("syncStatus") syncStatus: String?,
): List<M18BomShopSyncLog>
}

+ 7
- 0
src/main/java/com/ffii/fpsms/m18/entity/M18DataLogRepository.kt View File

@@ -3,9 +3,16 @@ package com.ffii.fpsms.m18.entity
import com.ffii.core.support.AbstractRepository
import com.ffii.fpsms.m18.enums.M18DataLogStatus
import org.springframework.stereotype.Repository
import java.time.LocalDateTime

@Repository
interface M18DataLogRepository : AbstractRepository<M18DataLog, Long> {
// find latest m18 data log by m18 id & ref type & status is true & deleted is false (order by id asc limit 1)
fun findTopByM18IdAndRefTypeAndDeletedIsFalseAndStatusOrderByIdDesc(m18Id: Long, refType: String, status: M18DataLogStatus): M18DataLog?

fun findAllByRefTypeAndStatusAndDeletedIsFalseAndCreatedGreaterThanEqualOrderByIdAsc(
refType: String,
status: M18DataLogStatus,
created: LocalDateTime,
): List<M18DataLog>
}

+ 7
- 1
src/main/java/com/ffii/fpsms/m18/entity/SchedulerSyncLogRepository.kt View File

@@ -1,10 +1,16 @@
package com.ffii.fpsms.m18.entity

import com.ffii.core.support.AbstractRepository
import com.ffii.fpsms.m18.enums.M18DataLogStatus
import org.springframework.stereotype.Repository
import java.time.LocalDateTime

@Repository
interface SchedulerSyncLogRepository : AbstractRepository<SchedulerSyncLog, Long> {
fun findTop20ByOrderByEndTimeDesc(): List<SchedulerSyncLog>

fun findFirstBySyncTypeAndStartTimeBetweenOrderByEndTimeDesc(
syncType: String,
startTime: LocalDateTime,
endTime: LocalDateTime,
): SchedulerSyncLog?
}

+ 14
- 0
src/main/java/com/ffii/fpsms/m18/model/M18BomForShopSaveAttemptResult.kt View File

@@ -0,0 +1,14 @@
package com.ffii.fpsms.m18.model

/**
* Outcome of [com.ffii.fpsms.m18.service.M18BomForShopService.saveBomForShopWithVersionRetry]
* (may differ from the initial request when header version was bumped).
*/
data class M18BomForShopSaveAttemptResult(
val request: M18BomForShopSaveRequest,
val response: GoodsReceiptNoteResponse?,
val callError: Throwable?,
val versionBumps: Int = 0,
/** True when M18 save was skipped because an identical payload was already synced successfully. */
val skippedUnchanged: Boolean = false,
)

+ 96
- 0
src/main/java/com/ffii/fpsms/m18/model/M18BomForShopSaveRequest.kt View File

@@ -0,0 +1,96 @@
package com.ffii.fpsms.m18.model

import com.fasterxml.jackson.annotation.JsonInclude
import com.fasterxml.jackson.annotation.JsonProperty

/**
* M18 save payload for Shop BOM (udfBomForShop).
* PUT /root/api/save/udfbomforshop?menuCode=udfbomforshop
*
* Same idea as GRN (`mainan` + `ant`): header and lines each wrapped as `{ "values": [ ... ] }`.
* Root keys: **`udfbomforshop`** and **`udfproduct`** (same as M18 read [M18BomData]).
* (Spelling is **udf**, not "uni".)
*/
@JsonInclude(JsonInclude.Include.NON_NULL)
data class M18BomForShopSaveRequest(
@JsonProperty("udfbomforshop")
val udfbomforshop: M18MainUdfBomForShopWrapper,
@JsonProperty("udfproduct")
val udfproduct: M18UdfProductWrapper,
)

@JsonInclude(JsonInclude.Include.NON_NULL)
data class M18MainUdfBomForShopWrapper(
val values: List<M18MainUdfBomForShopValue>,
)

/**
* Header row for udfBomForShop. Field names match M18 read/sample JSON.
*/
@JsonInclude(JsonInclude.Include.NON_NULL)
data class M18MainUdfBomForShopValue(
/**
* Existing M18 udfBomForShop header id for **update** (same as FPSMS [Bom.m18Id] after first sync).
* Omit or null for **create**. Sent as JSON string for M18 compatibility (like GRN mainan `id`).
*/
val id: String? = null,
val code: String? = null,
val beId: Int? = null,
val desc: String? = null,
@JsonProperty("desc_en")
val descEn: String? = null,
@JsonProperty("udfBOMCode")
val udfBomCode: String? = null,
val rev: String? = null,
val udfUnit: Long? = null,
/** Harvest qty: [Bom.outputQty] × pack multiple from header item stock UOM code (e.g. PACK2LB → ×2), else plain output qty. */
val udfHarvest: String? = null,
/** Trailing unit letters from that code (e.g. LB); null if code not parsed. */
val udfHarvestUnit: String? = null,
/** Epoch milliseconds (M18-style; same as read `lastModifyDate`). From FPSMS [com.ffii.core.entity.BaseEntity.created] in Asia/Hong_Kong. */
@JsonProperty("udfeffectivedate")
val udfEffectiveDate: Long? = null,
@JsonProperty("udfYieldratePP")
val udfYieldratePP: Number? = null,
val udftypeoffood: String? = null,
@JsonProperty("udfconfirmed")
val udfconfirmed: Boolean? = null,
val staffId: Int? = null,
val flowTypeId: Int? = null,
val virDeptId: Int? = null,
val status: String? = null,
)

@JsonInclude(JsonInclude.Include.NON_NULL)
data class M18UdfProductWrapper(
val values: List<M18UdfProductSaveValue>,
)

/**
* Line payload for `udfproduct.values[]`. **`udfBaseUnit`** is the FPSMS UOM **code** for the line.
* **`udfPackingUnit`** / **`udfPackingQty`** / **`udfproremark`** are not sent.
*/
@JsonInclude(JsonInclude.Include.NON_NULL)
data class M18UdfProductSaveValue(
/** Line id in M18 when updating */
val id: Long? = null,
val udfqty: Number? = null,
val udfProduct: Long? = null,
val udfIngredients: String? = null,
/** Line UOM: [com.ffii.fpsms.modules.master.entity.UomConversion.code] (same unit as [udfqty]). */
val udfBaseUnit: String? = null,
/** M18 vendor id ([StSearchType.VENDOR]) for the BOM business entity: PP → [M18Config.BEID_PP], PF → [M18Config.BEID_PF]. */
val udfSupplier: Long? = null,
/**
* M18 UOM id for price/purchase unit: from an M18-linked PO line on the **same BE** as the BOM.
* When PP/PF supplier is resolved via code from another BE's PO, unit is taken from a target-BE PO line
* (same item + supplier code), not from the foreign BE line.
*/
@JsonProperty("udfpurchaseUnit")
val udfpurchaseUnit: Long? = null,
/** Line sequence, e.g. " 1" */
val itemNo: String? = null,
val udfoptions: String? = null,
val udfoption: Number? = null,
val udfYieldRate: Number? = null,
)

+ 18
- 0
src/main/java/com/ffii/fpsms/m18/model/M18BomShopBatchSyncSummary.kt View File

@@ -0,0 +1,18 @@
package com.ffii.fpsms.m18.model

/**
* Result of scheduling job [com.ffii.fpsms.modules.master.service.BomM18ShopBulkPushService.pushAllBomsToM18ShopIfAllowed].
*/
data class M18BomShopBatchSyncSummary(
/** BOM rows with deleted=false scanned. */
val totalProcessed: Int,
val synced: Int,
/** Pushed attempted but [M18BomShopSyncTriggerResult.synced] is false (includes build/API failures). */
val notSynced: Int,
/** [SettingNames.M18_BOM_SHOP_SYNC_ENABLED] is off — no BOMs attempted. */
val skippedBecauseFeatureDisabled: Boolean = false,
) {
/** One-line summary for logs / scheduler_sync_log.query */
fun toLogQuery(): String =
"BOMShop batch: processed=$totalProcessed synced=$synced notSynced=$notSynced skippedFeatureDisabled=$skippedBecauseFeatureDisabled"
}

+ 14
- 0
src/main/java/com/ffii/fpsms/m18/model/M18BomShopSyncTriggerResult.kt View File

@@ -0,0 +1,14 @@
package com.ffii.fpsms.m18.model

/**
* Result of [com.ffii.fpsms.modules.master.service.BomService.pushBomToM18ShopIfAllowed]
* (e.g. POST /m18/test/bom-shop-sync/{bomId}).
*/
data class M18BomShopSyncTriggerResult(
val bomId: Long,
val synced: Boolean,
val skippedReason: String? = null,
val recordId: Long? = null,
val status: Boolean? = null,
val messageSummary: String? = null,
)

+ 714
- 0
src/main/java/com/ffii/fpsms/m18/service/M18BomForShopService.kt View File

@@ -0,0 +1,714 @@
package com.ffii.fpsms.m18.service

import com.fasterxml.jackson.core.JsonGenerator
import com.fasterxml.jackson.databind.ObjectMapper
import com.fasterxml.jackson.module.kotlin.jacksonObjectMapper
import com.ffii.fpsms.api.service.ApiCallerService
import com.ffii.fpsms.m18.M18Config
import com.ffii.fpsms.m18.entity.M18BomShopSyncLog
import com.ffii.fpsms.m18.entity.M18BomShopSyncLogRepository
import com.ffii.fpsms.m18.model.GoodsReceiptNoteResponse
import com.ffii.fpsms.m18.model.M18BomForShopSaveAttemptResult
import com.ffii.fpsms.m18.model.M18BomForShopSaveRequest
import com.ffii.fpsms.m18.model.M18MainUdfBomForShopValue
import com.ffii.fpsms.m18.model.M18MainUdfBomForShopWrapper
import com.ffii.fpsms.m18.model.M18UdfProductSaveValue
import com.ffii.fpsms.m18.model.M18UdfProductWrapper
import com.ffii.fpsms.modules.master.entity.Bom
import com.ffii.fpsms.modules.master.entity.BomMaterial
import com.ffii.fpsms.modules.master.service.ItemUomService
import com.ffii.fpsms.modules.master.service.ShopService
import com.ffii.fpsms.modules.purchaseOrder.entity.PurchaseOrderLine
import com.ffii.fpsms.modules.purchaseOrder.entity.PurchaseOrderLineRepository
import org.slf4j.Logger
import org.slf4j.LoggerFactory
import org.springframework.data.domain.PageRequest
import org.springframework.stereotype.Service
import org.springframework.util.LinkedMultiValueMap
import reactor.core.publisher.Mono
import java.math.BigDecimal
import java.math.RoundingMode
import java.nio.charset.StandardCharsets
import java.security.MessageDigest
import java.time.ZoneId

/**
* Push FPSMS BOM + materials to M18 udfBomForShop (similar to GRN save/an).
* PUT /root/api/save/udfbomforshop?menuCode=udfbomforshop
*/
@Service
open class M18BomForShopService(
private val m18Config: M18Config,
private val apiCallerService: ApiCallerService,
private val itemUomService: ItemUomService,
private val purchaseOrderLineRepository: PurchaseOrderLineRepository,
private val m18BomShopSyncLogRepository: M18BomShopSyncLogRepository,
private val shopService: ShopService,
private val m18VendorLookupService: M18VendorLookupService,
private val m18BomHeaderLookupService: M18BomHeaderLookupService,
) {
private val logger: Logger = LoggerFactory.getLogger(M18BomForShopService::class.java)

private val savePath = "/root/api/save/udfbomforshop"
private val menuCode = "udfbomforshop"

/** M18 business entity id for udfBomForShop header (`udfbomforshop.values[0].beId`). */
private val bomShopMainBeId: Int = 29

/**
* Stock UOM `code` on the **BOM header item** (e.g. PACK2LB = prefix + pack multiple + unit suffix).
* [udfHarvest] = [Bom.outputQty] × middle number; [udfHarvestUnit] = trailing unit (e.g. LB).
*/
private val bomItemStockUomPackCodeRegex = Regex("^([A-Za-z]+)(\\d+)([A-Za-z]+)$")

companion object {
private const val HARVEST_CALC_SCALE = 10
internal const val BOM_SHOP_HEADER_VERSION_DIGITS = 4
private val m18Tz: ZoneId = ZoneId.of("Asia/Hong_Kong")

private fun formatBomShopHeaderCode(itemCode: String, version: Int): String =
"BOM${itemCode}V${version.toString().padStart(BOM_SHOP_HEADER_VERSION_DIGITS, '0')}"

internal fun normalizedHeaderRevision(versionDigits: String): String =
versionDigits.padStart(BOM_SHOP_HEADER_VERSION_DIGITS, '0')
}

@Suppress("DEPRECATION")
private val objectMapper: ObjectMapper = jacksonObjectMapper().apply {
disable(JsonGenerator.Feature.ESCAPE_NON_ASCII)
}

/**
* Stable hash of payload **excluding** M18 header `id`, `code`, and `rev` (so version bumps do not affect equality).
* Used with [M18BomShopSyncLog] to decide V0000 vs V0001+.
*/
open fun contentFingerprint(request: M18BomForShopSaveRequest): String {
val json = objectMapper.writeValueAsString(normalizedForFingerprint(request))
return sha256Hex(json)
}

private fun normalizedForFingerprint(request: M18BomForShopSaveRequest): M18BomForShopSaveRequest {
val v = request.udfbomforshop.values.firstOrNull()
?: return request
val headerNorm = v.copy(id = null, code = null, rev = null)
val linesSorted = request.udfproduct.values.sortedWith(
compareBy({ it.itemNo }, { it.udfProduct }, { it.udfIngredients }),
)
return M18BomForShopSaveRequest(
udfbomforshop = M18MainUdfBomForShopWrapper(values = listOf(headerNorm)),
udfproduct = M18UdfProductWrapper(values = linesSorted),
)
}

private fun sha256Hex(text: String): String {
val md = MessageDigest.getInstance("SHA-256")
val bytes = md.digest(text.toByteArray(StandardCharsets.UTF_8))
return bytes.joinToString("") { "%02x".format(it) }
}

/**
* Builds M18 save body from a persisted BOM (materials loaded).
* [headerM18IdOverride] optional M18 header record id when forcing update; skips version/fingerprint logic for **id** only,
* reuses latest logged [M18BomShopSyncLog.m18HeaderCode] when possible.
* Otherwise uses [Bom.m18Id] when the normalized payload matches the latest log; on content change, bumps `BOM{item}Vnnnn`.
*/
open fun buildSaveRequest(bom: Bom, headerM18IdOverride: Long? = null): M18BomForShopSaveRequest? {
val bomId = bom.id ?: return null
val routingCode = bom.code ?: return null
val itemCode = bom.item?.code?.trim().orEmpty().ifEmpty {
logger.warn("[M18 BOM] bom.item.code missing; cannot build M18 BOM shop payload. bomId=$bomId")
return null
}

val flowTypeId = resolveFlowTypeId(routingCode)
val udfUnit = bom.uom?.m18Id?.takeIf { it > 0 } ?: return null
val outputQty = bom.outputQty ?: BigDecimal.ZERO
val (udfHarvest, udfHarvestUnit) = resolveUdfHarvestFields(bom, outputQty)
val udfEffectiveDate = bom.created?.atZone(m18Tz)?.toInstant()?.toEpochMilli()

val targetBeId = resolveTargetBeId(flowTypeId)
val supplierCache = mutableMapOf<String, Long?>()
val lines = bom.bomMaterials
.filter { it.deleted != true }
.sortedBy { it.id ?: 0L }
.mapIndexedNotNull { idx, mat ->
toProductLine(mat, idx + 1, flowTypeId, targetBeId, supplierCache)
}

if (lines.isEmpty()) {
logger.warn("[M18 BOM] BOM id=$bomId code=$routingCode has no materials; skipping M18 save")
return null
}

val (headerCode, rev, headerM18IdForRequest) = resolveHeaderCodeAndM18Id(
bomId = bomId,
itemCode = itemCode,
lines = lines,
udfUnit = udfUnit,
udfHarvest = udfHarvest,
udfHarvestUnit = udfHarvestUnit,
udfEffectiveDate = udfEffectiveDate,
bomYield = bom.yield,
bomName = bom.name,
bomDescription = bom.description,
flowTypeId = flowTypeId,
headerM18IdOverride = headerM18IdOverride,
bomM18Id = bom.m18Id?.takeIf { it > 0 },
)

val header = M18MainUdfBomForShopValue(
id = headerM18IdForRequest?.toString(),
code = headerCode,
beId = bomShopMainBeId,
desc = bom.name ?: bom.description,
descEn = bom.name ?: bom.description,
udfBomCode = itemCode,
rev = rev,
udfUnit = udfUnit,
udfHarvest = udfHarvest,
udfHarvestUnit = udfHarvestUnit,
udfEffectiveDate = udfEffectiveDate,
udfYieldratePP = bom.yield,
udftypeoffood = "半成品",
udfconfirmed = true,
staffId = 232,
flowTypeId = flowTypeId,
virDeptId = 117,
status = "Y",
)

logger.info(
"[M18 BOM] buildSaveRequest fpsmsBomId=$bomId routingCode=$routingCode itemCode=$itemCode headerCode=$headerCode " +
"mainM18Id=$headerM18IdForRequest (override=$headerM18IdOverride, bom.m18Id=${bom.m18Id})",
)

return M18BomForShopSaveRequest(
udfbomforshop = M18MainUdfBomForShopWrapper(values = listOf(header)),
udfproduct = M18UdfProductWrapper(values = lines),
)
}

@Suppress("LongParameterList")
private fun resolveHeaderCodeAndM18Id(
bomId: Long,
itemCode: String,
lines: List<M18UdfProductSaveValue>,
udfUnit: Long,
udfHarvest: String,
udfHarvestUnit: String?,
udfEffectiveDate: Long?,
bomYield: BigDecimal?,
bomName: String?,
bomDescription: String?,
flowTypeId: Int,
headerM18IdOverride: Long?,
bomM18Id: Long?,
): Triple<String, String?, Long?> {
val draftHeader = M18MainUdfBomForShopValue(
id = null,
code = null,
beId = bomShopMainBeId,
desc = bomName ?: bomDescription,
descEn = bomName ?: bomDescription,
udfBomCode = itemCode,
rev = null,
udfUnit = udfUnit,
udfHarvest = udfHarvest,
udfHarvestUnit = udfHarvestUnit,
udfEffectiveDate = udfEffectiveDate,
udfYieldratePP = bomYield,
udftypeoffood = "半成品",
udfconfirmed = true,
staffId = 232,
flowTypeId = flowTypeId,
virDeptId = 117,
status = "Y",
)
val draftRequest = M18BomForShopSaveRequest(
udfbomforshop = M18MainUdfBomForShopWrapper(values = listOf(draftHeader)),
udfproduct = M18UdfProductWrapper(values = lines),
)
val fp = contentFingerprint(draftRequest)

val forcedId = headerM18IdOverride?.takeIf { it > 0 }
if (forcedId != null) {
val latest = m18BomShopSyncLogRepository.findFirstByBomIdOrderByIdDesc(bomId)
val codeForUpdate =
latest?.m18HeaderCode?.takeIf { it.isNotBlank() }
?: formatBomShopHeaderCode(itemCode, 0)
val forcedRev = parseTrailingVersion(codeForUpdate)
?: "0".repeat(BOM_SHOP_HEADER_VERSION_DIGITS)
return Triple(codeForUpdate, forcedRev, forcedId)
}

// Identical BOM details already synced — reuse header code + M18 id (update), never allocate a new version.
findSuccessfulSyncByFingerprint(bomId, fp)?.let { match ->
val reuseCode = match.m18HeaderCode?.trim().orEmpty()
if (reuseCode.isNotEmpty()) {
val reuseId = match.m18RecordId?.takeIf { it > 0L } ?: bomM18Id
val revReuse = parseTrailingVersion(reuseCode)
?: "0".repeat(BOM_SHOP_HEADER_VERSION_DIGITS)
return Triple(reuseCode, revReuse, reuseId)
}
}

// Content changed — next version number (new M18 header code).
val maxV = maxVersionFromLogs(bomId, itemCode)
val nextV = maxV + 1
val newCode = formatBomShopHeaderCode(itemCode, nextV)
val rev = nextV.toString().padStart(BOM_SHOP_HEADER_VERSION_DIGITS, '0')
return Triple(newCode, rev, null)
}

private fun findSuccessfulSyncByFingerprint(bomId: Long, fingerprint: String): M18BomShopSyncLog? =
m18BomShopSyncLogRepository.findFirstByBomIdAndSyncedIsTrueAndRequestFingerprintOrderByIdDesc(
bomId,
fingerprint,
)

private fun maxVersionFromLogs(bomId: Long, itemCode: String): Int {
val versionPat = Regex("^BOM${Regex.escape(itemCode)}V(\\d+)$")
// Only successful syncs advance the numeric tail; failed attempts log a code but must not consume Vnnnn.
return m18BomShopSyncLogRepository.findTop100ByBomIdAndSyncedIsTrueOrderByIdDesc(bomId)
.mapNotNull { row ->
val c = row.m18HeaderCode?.trim().orEmpty().ifEmpty {
extractHeaderCodeFromJson(row.requestJson).orEmpty()
}
versionPat.find(c)?.groupValues?.get(1)?.toIntOrNull()
}
.maxOrNull() ?: -1
}

private fun extractHeaderCodeFromJson(json: String?): String? {
if (json.isNullOrBlank()) return null
return runCatching {
val node = objectMapper.readTree(json)
val text = node.path("udfbomforshop").path("values").path(0).path("code").asText()
text.trim().takeIf { it.isNotEmpty() }
}.getOrNull()
}

private fun parseTrailingVersion(headerCode: String): String? =
Regex("V(\\d+)$").find(headerCode.trim())?.groupValues?.get(1)?.let { normalizedHeaderRevision(it) }

/**
* From the **finished-good** [Bom.item] stock unit [com.ffii.fpsms.modules.master.entity.UomConversion.code]
* (pattern `LETTER_PREFIX` + `DIGITS` + `UNIT_SUFFIX`, e.g. PACK2LB): harvest qty = outputQty × digits, unit = suffix.
* Falls back to plain [outputQty] and null unit when item/stock UOM/code is missing or does not match.
*/
private fun resolveUdfHarvestFields(bom: Bom, outputQty: BigDecimal): Pair<String, String?> {
val itemId = bom.item?.id
if (itemId == null) {
logger.warn("[M18 BOM] bom.item id missing; udfHarvest=outputQty only. bomId=${bom.id}")
return outputQty.stripTrailingZeros().toPlainString() to null
}
val stockCode = itemUomService.findStockUnitByItemId(itemId)?.uom?.code?.trim().orEmpty()
if (stockCode.isEmpty()) {
logger.warn("[M18 BOM] stock UOM code missing for bom itemId=$itemId; udfHarvest=outputQty only. bomId=${bom.id}")
return outputQty.stripTrailingZeros().toPlainString() to null
}
val match = bomItemStockUomPackCodeRegex.matchEntire(stockCode)
if (match == null) {
logger.warn(
"[M18 BOM] stock UOM code '$stockCode' does not match PREFIX+NUMBER+SUFFIX; " +
"udfHarvest=outputQty only. bomId=${bom.id} itemId=$itemId",
)
return outputQty.stripTrailingZeros().toPlainString() to null
}
val mult = match.groupValues[2].toBigDecimalOrNull()
if (mult == null || mult.compareTo(BigDecimal.ZERO) <= 0) {
logger.warn(
"[M18 BOM] invalid pack multiple in stock UOM code '$stockCode'; udfHarvest=outputQty only. bomId=${bom.id}",
)
return outputQty.stripTrailingZeros().toPlainString() to null
}
val unitSuffix = match.groupValues[3]
val harvestQty = outputQty.multiply(mult).setScale(HARVEST_CALC_SCALE, RoundingMode.HALF_UP).stripTrailingZeros()
return harvestQty.toPlainString() to unitSuffix
}

private fun toProductLine(
mat: BomMaterial,
lineNo: Int,
flowTypeId: Int,
targetBeId: Long?,
supplierCache: MutableMap<String, Long?>,
): M18UdfProductSaveValue? {
val proId = mat.item?.m18Id?.takeIf { it > 0 } ?: run {
logger.warn("[M18 BOM] material item m18Id missing bomMaterialId=${mat.id} itemId=${mat.item?.id}")
return null
}
val udfBaseUnit = mat.uom?.code?.trim()?.takeIf { it.isNotEmpty() } ?: run {
logger.warn("[M18 BOM] material UOM code missing bomMaterialId=${mat.id}")
return null
}
val itemId = mat.item?.id
val poLines = itemId?.let { id ->
purchaseOrderLineRepository.findLatestLinesForBomM18ByItemId(id, PageRequest.of(0, 20))
} ?: emptyList()
val supplierAndUnit = resolvePoSupplierAndPurchaseUnit(poLines, flowTypeId, targetBeId, supplierCache)
val supplierM18Id = supplierAndUnit.supplierM18Id
val purchaseUnitM18Id = supplierAndUnit.purchaseUnitM18Id
val udfqty = (mat.qty ?: BigDecimal.ZERO).setScale(8, RoundingMode.HALF_UP).toDouble()
return M18UdfProductSaveValue(
id = mat.m18Id?.takeIf { it > 0 },
udfqty = udfqty,
udfProduct = proId,
udfIngredients = mat.itemName ?: mat.item?.name,
udfBaseUnit = udfBaseUnit,
udfSupplier = supplierM18Id,
udfpurchaseUnit = purchaseUnitM18Id,
itemNo = String.format("%6d", lineNo),
udfoptions = "",
udfoption = 0.0,
udfYieldRate = 0.0,
)
}

/** Prefer a PO line whose header [com.ffii.fpsms.modules.purchaseOrder.entity.PurchaseOrder.m18BeId] matches the BOM BE. */
private fun pickPreferredPoLine(lines: List<PurchaseOrderLine>, preferredBeId: Long?): PurchaseOrderLine? {
if (lines.isEmpty()) return null
if (preferredBeId == null) return lines.first()
return lines.firstOrNull { it.purchaseOrder?.m18BeId == preferredBeId } ?: lines.first()
}

private fun pickPoLineMatchingBeOnly(lines: List<PurchaseOrderLine>, beId: Long?): PurchaseOrderLine? {
if (lines.isEmpty() || beId == null) return null
return lines.firstOrNull { it.purchaseOrder?.m18BeId == beId }
}

/** M18 line price unit ([M18PurchaseOrderPot.unitId]): [PurchaseOrderLine.uomM18] then [PurchaseOrderLine.uom]. */
private fun purchaseUnitM18IdFromPoLine(poLine: PurchaseOrderLine?): Long? =
poLine?.uomM18?.m18Id?.takeIf { it > 0L }
?: poLine?.uom?.m18Id?.takeIf { it > 0L }

private data class PoSupplierUnitContext(
val supplierM18Id: Long?,
val purchaseUnitM18Id: Long?,
)

/**
* Supplier + purchase unit for BOM material lines.
* PF/PP BOMs: [udfpurchaseUnit] must come from a PO line on the **same business entity** (PP unit with PP supplier).
* When PP supplier is resolved via supplier code from a PF/other PO, look for a PP PO line for the same item + supplier code for the unit.
*/
private fun resolvePoSupplierAndPurchaseUnit(
poLines: List<PurchaseOrderLine>,
flowTypeId: Int,
targetBeId: Long?,
supplierCache: MutableMap<String, Long?>,
): PoSupplierUnitContext {
if (poLines.isEmpty()) {
return PoSupplierUnitContext(null, null)
}

val beMatchedLine = pickPoLineMatchingBeOnly(poLines, targetBeId)
if (beMatchedLine != null) {
return PoSupplierUnitContext(
supplierM18Id = resolveSupplierM18Id(beMatchedLine, flowTypeId, supplierCache),
purchaseUnitM18Id = purchaseUnitM18IdFromPoLine(beMatchedLine),
)
}

if (flowTypeId != 2 && flowTypeId != 3) {
val line = pickPreferredPoLine(poLines, targetBeId)
return PoSupplierUnitContext(
supplierM18Id = resolveSupplierM18Id(line, flowTypeId, supplierCache),
purchaseUnitM18Id = purchaseUnitM18IdFromPoLine(line),
)
}

val supplierSourceLine = pickPreferredPoLine(poLines, targetBeId)
val supplierCode = supplierSourceLine?.purchaseOrder?.supplier?.code?.trim()?.takeIf { it.isNotEmpty() }
val supplierM18Id = resolveSupplierM18Id(supplierSourceLine, flowTypeId, supplierCache)

val unitLine =
if (targetBeId != null && !supplierCode.isNullOrEmpty()) {
poLines.firstOrNull { pol ->
pol.purchaseOrder?.m18BeId == targetBeId &&
pol.purchaseOrder?.supplier?.code?.trim().equals(supplierCode, ignoreCase = true)
}
} else {
null
}

val purchaseUnitM18Id = purchaseUnitM18IdFromPoLine(unitLine)
if (supplierM18Id != null && purchaseUnitM18Id == null && supplierSourceLine != null) {
val beLabel = if (flowTypeId == 2) "PF" else "PP"
logger.warn(
"[M18 BOM] $beLabel supplier resolved from PO code={} supplierCode={} but no $beLabel PO line " +
"for same item+supplier — omitting udfpurchaseUnit (PF/other BE unit is invalid with $beLabel supplier)",
supplierSourceLine.purchaseOrder?.code,
supplierCode,
)
}

return PoSupplierUnitContext(supplierM18Id, purchaseUnitM18Id)
}

private fun resolveTargetBeId(flowTypeId: Int): Long? = when (flowTypeId) {
2 -> m18Config.BEID_PF.toLongOrNull()
3 -> m18Config.BEID_PP.toLongOrNull()
else -> null
}

/**
* Resolves M18 vendor id for BOM material line supplier:
* - PF BOMs: M18 search by supplier code + [M18Config.BEID_PF]
* - PP BOMs: M18 search by supplier code + [M18Config.BEID_PP] (never local [Shop.m18Id] first — duplicate codes may be PF ids)
*/
private fun resolveSupplierM18Id(
latestPoLine: PurchaseOrderLine?,
flowTypeId: Int,
cache: MutableMap<String, Long?>,
): Long? {
val po = latestPoLine?.purchaseOrder
val supplier = po?.supplier
val directM18Id = supplier?.m18Id?.takeIf { it > 0L }
val supplierCode = supplier?.code?.trim()?.takeIf { it.isNotEmpty() }
val targetBeId = resolveTargetBeId(flowTypeId)
val poBeId = po?.m18BeId

if (supplierCode == null) {
return directM18Id
}

if (flowTypeId == 2 || flowTypeId == 3) {
val cacheKey = "$supplierCode|$flowTypeId"
cache[cacheKey]?.let { return it }
val beId = if (flowTypeId == 2) m18Config.BEID_PF else m18Config.BEID_PP
val beLabel = if (flowTypeId == 2) "PF" else "PP"
val resolved =
m18VendorLookupService.findVendorM18IdByCode(supplierCode, beId)
?: directM18Id.takeIf { poBeId != null && poBeId == targetBeId }
if (resolved == null) {
logger.warn("[M18 BOM] $beLabel vendor M18 id not found for supplierCode=$supplierCode")
}
cache[cacheKey] = resolved
return resolved
}

return shopService.findVendorByCode(supplierCode)?.m18Id?.takeIf { it > 0L }
?: directM18Id
}

private fun resolveFlowTypeId(code: String): Int = when {
code.startsWith("TOA") -> 1
code.startsWith("BOMPP") || code.startsWith("PP") -> 3
code.startsWith("BOMPF") || code.startsWith("PF") || code.startsWith("PFP") -> 2
else -> 1
}

/** M18 rejects duplicate header [M18MainUdfBomForShopValue.code] on create (core_101903). */
open fun isSameCodeFoundError(resp: GoodsReceiptNoteResponse?): Boolean {
if (resp == null || resp.status) return false
return resp.messages.any { msg ->
msg.msgCode == "core_101903" ||
msg.msgDetail?.contains("Same Code found", ignoreCase = true) == true
}
}

private fun withHeaderM18Id(
request: M18BomForShopSaveRequest,
headerCode: String,
m18Id: Long,
): M18BomForShopSaveRequest {
val header = request.udfbomforshop.values.firstOrNull()
?: return request
val rev = parseTrailingVersion(headerCode) ?: header.rev
val newHeader = header.copy(
id = m18Id.toString(),
code = headerCode,
rev = rev,
)
return request.copy(
udfbomforshop = request.udfbomforshop.copy(values = listOf(newHeader)),
)
}

/**
* Increments `BOM{item}Vnnnn` tail, clears header `id` (new M18 row), updates `rev`.
* Returns null when [udfBomCode] is missing.
*/
open fun bumpHeaderVersionForRetry(request: M18BomForShopSaveRequest): M18BomForShopSaveRequest? {
val header = request.udfbomforshop.values.firstOrNull() ?: return null
val itemCode = header.udfBomCode?.trim().orEmpty().ifEmpty { return null }
val currentCode = header.code?.trim().orEmpty()
val currentV =
parseTrailingVersion(currentCode)?.toIntOrNull()
?: Regex("V(\\d+)$").find(currentCode)?.groupValues?.get(1)?.toIntOrNull()
?: -1
val nextV = currentV + 1
val newCode = formatBomShopHeaderCode(itemCode, nextV)
val newRev = nextV.toString().padStart(BOM_SHOP_HEADER_VERSION_DIGITS, '0')
val newHeader = header.copy(id = null, code = newCode, rev = newRev)
return request.copy(
udfbomforshop = request.udfbomforshop.copy(values = listOf(newHeader)),
)
}

/**
* Saves to M18. On duplicate code: update existing row when details match a prior sync or M18 lookup;
* bump version only when BOM content (fingerprint) is new.
*/
open fun saveBomForShopWithVersionRetry(
request: M18BomForShopSaveRequest,
bomId: Long,
maxSameCodeRetries: Int = 20,
): M18BomForShopSaveAttemptResult {
val fp = contentFingerprint(request)
findSuccessfulSyncByFingerprint(bomId, fp)?.let { match ->
val reuseCode = match.m18HeaderCode?.trim().orEmpty()
val reuseId = match.m18RecordId?.takeIf { it > 0L }
if (reuseCode.isNotEmpty() && reuseId != null) {
logger.info(
"[M18 BOM] Unchanged BOM details; skip new code (reuse headerCode={} m18Id={})",
reuseCode,
reuseId,
)
return M18BomForShopSaveAttemptResult(
request = withHeaderM18Id(request, reuseCode, reuseId),
response = GoodsReceiptNoteResponse(recordId = reuseId, status = true),
callError = null,
versionBumps = 0,
skippedUnchanged = true,
)
}
}

var current = request
var bumps = 0
var lastResp: GoodsReceiptNoteResponse? = null
var lastError: Throwable? = null
val attachIdAttemptedForCode = mutableSetOf<String>()
while (true) {
lastError = null
try {
lastResp = saveBomForShop(current)
} catch (e: Exception) {
lastError = e
break
}
if (
lastResp == null ||
!isSameCodeFoundError(lastResp) ||
bumps >= maxSameCodeRetries
) {
break
}

val header = current.udfbomforshop.values.firstOrNull() ?: break
val code = header.code?.trim().orEmpty()

// Same details already synced — update existing row, do not create a new version code.
val reuseFromSync = resolveReuseFromSuccessfulSync(bomId, fp)
if (reuseFromSync != null) {
val (reuseCode, reuseId) = reuseFromSync
current = withHeaderM18Id(current, reuseCode, reuseId)
logger.info(
"[M18 BOM] Same Code found; same details as prior sync — update headerCode={} m18Id={}",
reuseCode,
reuseId,
)
continue
}

// Code exists in M18 without header id — attach id and update (same code, not a new version).
if (header.id.isNullOrBlank() && code.isNotEmpty() && code !in attachIdAttemptedForCode) {
attachIdAttemptedForCode.add(code)
val m18Id = m18BomHeaderLookupService.findM18IdByHeaderCode(code)
if (m18Id != null) {
current = withHeaderM18Id(current, code, m18Id)
logger.info("[M18 BOM] Same Code found; attach M18 id={} for headerCode={}", m18Id, code)
continue
}
}

// Same code already holds this exact content in our sync log — update that row.
if (code.isNotEmpty()) {
val logAtCode =
m18BomShopSyncLogRepository.findFirstByBomIdAndSyncedIsTrueAndM18HeaderCodeOrderByIdDesc(
bomId,
code,
)
if (logAtCode?.requestFingerprint == fp && logAtCode.m18RecordId != null) {
current = withHeaderM18Id(current, code, logAtCode.m18RecordId!!)
logger.info(
"[M18 BOM] Same Code found; headerCode={} already has matching content — update m18Id={}",
code,
logAtCode.m18RecordId,
)
continue
}
}

// Content differs from existing code — allocate next version (new code for new details).
val bumped = bumpHeaderVersionForRetry(current) ?: break
val bumpedCode = bumped.udfbomforshop.values.firstOrNull()?.code?.trim().orEmpty()
if (bumpedCode.isNotEmpty()) {
val logAtBumped =
m18BomShopSyncLogRepository.findFirstByBomIdAndSyncedIsTrueAndM18HeaderCodeOrderByIdDesc(
bomId,
bumpedCode,
)
if (logAtBumped?.requestFingerprint == fp && logAtBumped.m18RecordId != null) {
current = withHeaderM18Id(bumped, bumpedCode, logAtBumped.m18RecordId!!)
logger.info(
"[M18 BOM] Version {} already synced with same details — update m18Id={}",
bumpedCode,
logAtBumped.m18RecordId,
)
continue
}
}
current = bumped
bumps++
logger.info(
"[M18 BOM] Same Code found; content changed — bump version (#$bumps) headerCode={}",
bumpedCode,
)
}
return M18BomForShopSaveAttemptResult(
request = current,
response = lastResp,
callError = lastError,
versionBumps = bumps,
)
}

private fun resolveReuseFromSuccessfulSync(bomId: Long, fingerprint: String): Pair<String, Long>? {
val match = findSuccessfulSyncByFingerprint(bomId, fingerprint) ?: return null
val reuseCode = match.m18HeaderCode?.trim().orEmpty().ifEmpty { return null }
val reuseId = match.m18RecordId?.takeIf { it > 0L } ?: return null
return reuseCode to reuseId
}

open fun toJson(request: M18BomForShopSaveRequest): String =
objectMapper.writeValueAsString(request)

open fun toJson(response: GoodsReceiptNoteResponse): String =
objectMapper.writeValueAsString(response)

open fun saveBomForShop(request: M18BomForShopSaveRequest): GoodsReceiptNoteResponse? =
saveBomForShopMono(request).block()

open fun saveBomForShopMono(request: M18BomForShopSaveRequest): Mono<GoodsReceiptNoteResponse> {
val queryParams = LinkedMultiValueMap<String, String>().apply {
add("menuCode", menuCode)
}
val qs = queryParams.entries.flatMap { (k, v) -> v.map { "$k=$it" } }.joinToString("&")
val fullUrl = "${m18Config.BASE_URL}$savePath?$qs"
val bodyJson = objectMapper.writeValueAsString(request)
logger.info("[M18 BOM udfBomForShop] PUT url=$fullUrl bodyUtf8Bytes=${bodyJson.toByteArray(StandardCharsets.UTF_8).size}")
logger.debug("[M18 BOM udfBomForShop] PUT body=$bodyJson")
return apiCallerService.putWithJsonString<GoodsReceiptNoteResponse>(
urlPath = savePath,
queryParams = queryParams,
bodyJson = bodyJson,
).doOnSuccess { r ->
logger.info("[M18 BOM udfBomForShop] response status=${r.status} recordId=${r.recordId} messages=${r.messages}")
}.doOnError { e ->
logger.error("[M18 BOM udfBomForShop] failed: ${e.message}", e)
}
}
}

+ 41
- 0
src/main/java/com/ffii/fpsms/m18/service/M18BomHeaderLookupService.kt View File

@@ -0,0 +1,41 @@
package com.ffii.fpsms.m18.service

import com.ffii.fpsms.api.service.ApiCallerService
import com.ffii.fpsms.m18.model.M18BomListResponse
import com.ffii.fpsms.m18.model.M18CommonListRequest
import com.ffii.fpsms.m18.model.StSearchType
import org.slf4j.Logger
import org.slf4j.LoggerFactory
import org.springframework.stereotype.Service

/**
* M18 udfBomForShop header lookup by [code] — isolated from [M18MasterDataService] / [M18BomForShopService] cycles.
*/
@Service
open class M18BomHeaderLookupService(
private val apiCallerService: ApiCallerService,
) {
private val logger: Logger = LoggerFactory.getLogger(M18BomHeaderLookupService::class.java)

private val fetchListApi = "/search/search"

open fun findM18IdByHeaderCode(headerCode: String): Long? {
val trimmed = headerCode.trim()
if (trimmed.isEmpty()) return null
val conds = "(code=equal=$trimmed)"
val listResponse = try {
apiCallerService.get<M18BomListResponse, M18CommonListRequest>(
fetchListApi,
M18CommonListRequest(
stSearch = StSearchType.BOM.value,
params = null,
conds = conds,
),
).block()
} catch (e: Exception) {
logger.warn("(findM18IdByHeaderCode) M18 search failed code=$trimmed: ${e.message}")
null
}
return listResponse?.values?.firstOrNull()?.id?.takeIf { it > 0L }
}
}

+ 107
- 25
src/main/java/com/ffii/fpsms/m18/service/M18DeliveryOrderService.kt View File

@@ -12,6 +12,7 @@ import com.ffii.fpsms.modules.deliveryOrder.enums.DeliveryOrderLineStatus
import com.ffii.fpsms.modules.deliveryOrder.enums.DeliveryOrderStatus
import com.ffii.fpsms.modules.deliveryOrder.service.DeliveryOrderLineService
import com.ffii.fpsms.modules.deliveryOrder.service.DeliveryOrderService
import com.ffii.fpsms.modules.deliveryOrder.entity.DeliveryOrderRepository
import com.ffii.fpsms.modules.deliveryOrder.web.models.SaveDeliveryOrderLineRequest
import com.ffii.fpsms.modules.deliveryOrder.web.models.SaveDeliveryOrderRequest
import com.ffii.fpsms.modules.master.entity.ItemUom
@@ -23,7 +24,6 @@ import com.ffii.fpsms.modules.purchaseOrder.enums.PurchaseOrderType
import org.slf4j.Logger
import org.slf4j.LoggerFactory
import org.springframework.stereotype.Service
import java.sql.SQLException
import java.time.LocalDateTime
import java.time.format.DateTimeFormatter
import kotlin.reflect.full.memberProperties
@@ -35,6 +35,7 @@ open class M18DeliveryOrderService(
val apiCallerService: ApiCallerService,
val m18DataLogService: M18DataLogService,
val deliveryOrderService: DeliveryOrderService,
val deliveryOrderRepository: DeliveryOrderRepository,
val deliveryOrderLineService: DeliveryOrderLineService,
val itemsService: ItemsService,
val shopService: ShopService,
@@ -106,7 +107,6 @@ open class M18DeliveryOrderService(
if (request.dDateEqual != null) {
shopPoConds += "=and=(${dDateEqualConds})"
}

logger.info("shopPoConds: ${shopPoConds}")
val shopPoParams = M18PurchaseOrderListRequest(
@@ -151,20 +151,41 @@ open class M18DeliveryOrderService(
return deliveryOrder
}

open fun saveDeliveryOrders(request: M18CommonRequest): SyncResult {
open fun saveDeliveryOrders(request: M18CommonRequest, skipExistingDo: Boolean = false): SyncResult {
val deliveryOrdersWithType = getDeliveryOrdersWithType(request)
return saveDeliveryOrdersWithPreparedList(deliveryOrdersWithType)
return saveDeliveryOrdersWithPreparedList(
deliveryOrdersWithType,
syncisExtra = false,
skipExistingDo = skipExistingDo,
)
}

/**
* Sync a single M18 shop PO / delivery order by document [code], same search pattern as
* [com.ffii.fpsms.m18.service.M18PurchaseOrderService.savePurchaseOrderByCode].
*
* @param isExtraSync when true, persist local `delivery_order.isExtra=true` (manual DO(加單) sync).
* No M18-side "加單" filtering is used.
* @param newOnly when true, skip if a non-deleted local DO already exists with the same `code`.
*/
open fun saveDeliveryOrderByCode(code: String): SyncResult {
open fun saveDeliveryOrderByCode(
code: String,
isExtraSync: Boolean = false,
newOnly: Boolean = false,
): SyncResult {
if (newOnly && deliveryOrderRepository.existsByCodeAndDeletedIsFalse(code)) {
return SyncResult(
totalProcessed = 1,
totalSuccess = 0,
totalFail = 0,
query = "skipped (newOnly=true): delivery_order.code already exists: $code",
)
}
val conds = "(code=equal=$code)"
val searchRequest = M18PurchaseOrderListRequest(
stSearch = "po",
params = null,
conds = "(code=equal=$code)"
conds = conds
)
val doListResponse = try {
apiCallerService.get<M18PurchaseOrderListResponse, M18PurchaseOrderListRequest>(
@@ -183,30 +204,36 @@ open class M18DeliveryOrderService(
totalProcessed = 1,
totalSuccess = 0,
totalFail = 1,
query = "code=equal=$code"
query = conds
)
}

val prepared = M18PurchaseOrderListResponseWithType(
valuesWithType = mutableListOf(Pair(PurchaseOrderType.SHOP, doListResponse)),
query = "code=equal=$code"
query = conds
)

return saveDeliveryOrdersWithPreparedList(prepared)
return saveDeliveryOrdersWithPreparedList(prepared, syncisExtra = isExtraSync, skipExistingDo = newOnly)
}

private fun saveDeliveryOrdersWithPreparedList(
deliveryOrdersWithType: M18PurchaseOrderListResponseWithType?
deliveryOrdersWithType: M18PurchaseOrderListResponseWithType?,
syncisExtra: Boolean = false,
skipExistingDo: Boolean = false,
): SyncResult {
logger.info("--------------------------------------------Start - Saving M18 Delivery Order--------------------------------------------")
if (skipExistingDo) {
logger.info("skipExistingDo=true — local delivery orders will not be updated")
}

val successList = mutableListOf<Long>()
val skippedList = mutableListOf<Long>()
val successDetailList = mutableListOf<Long>()
val failList = mutableListOf<Long>()
val failDetailList = mutableListOf<Long>()
val failItemDetailList = mutableListOf<Long>()
val uomByM18IdCache = mutableMapOf<Long, ItemUom?>()
val itemIdCache = mutableMapOf<Long, Long?>()
val itemIdCache = mutableMapOf<Long, Long>()
val stockUomIdCache = mutableMapOf<Pair<Long, Long>, Long?>()

val doRefType = "Delivery Order"
@@ -223,6 +250,22 @@ open class M18DeliveryOrderService(

if (deliveryOrdersValues != null) {
deliveryOrdersValues.forEach { deliveryOrder ->
if (skipExistingDo) {
val latestDeliveryOrderLog =
m18DataLogService.findLatestM18DataLogWithSuccess(deliveryOrder.id, doRefType)
val existingByM18 = latestDeliveryOrderLog?.id?.let {
deliveryOrderService.findByM18DataLogId(it)
}
if (existingByM18 != null && existingByM18.deleted != true) {
logger.info(
"${doRefType}: skipExistingDo — skipping M18 id=${deliveryOrder.id} " +
"code=${existingByM18.code} localId=${existingByM18.id} status=${existingByM18.status}"
)
skippedList.add(deliveryOrder.id)
return@forEach
}
}

val deliveryOrderDetail = getDeliveryOrder(deliveryOrder.id)

var deliveryOrderId: Long? = null //FP-MTMS
@@ -236,6 +279,14 @@ open class M18DeliveryOrderService(

// delivery_order + m18_data_log table
if (mainpo != null) {
if (skipExistingDo && deliveryOrderRepository.existsByCodeAndDeletedIsFalse(mainpo.code)) {
logger.info(
"${doRefType}: skipExistingDo — skipping M18 id=${deliveryOrder.id} code=${mainpo.code} (local DO exists by code)"
)
skippedList.add(deliveryOrder.id)
return@forEach
}

// Find the latest m18 data log by m18 id & type
// logger.info("${doRefType}: Finding For Latest M18 Data Log...")
val latestDeliveryOrderLog =
@@ -283,7 +334,8 @@ open class M18DeliveryOrderService(
m18DataLogId = saveM18DeliveryOrderLog.id,
handlerId = null,
m18BeId = mainpo.beId,
deleted = mainpo.udfIsVoid == true
deleted = mainpo.udfIsVoid == true,
isExtra = syncisExtra,
)

val saveDeliveryOrderResponse =
@@ -354,14 +406,10 @@ open class M18DeliveryOrderService(

// logger.info("${doLineRefType}: Saved M18 Data Log. ID: ${saveM18DeliveryOrderLineLog.id}")
// logger.info("${doLineRefType}: Finding item...")
val itemId: Long? = itemIdCache.getOrPut(line.proId) {
val item = itemsService.findByM18Id(line.proId)
if (item == null) {
m18MasterDataService.saveProduct(line.proId)?.id
} else {
item.id
val itemId: Long? = itemIdCache[line.proId]
?: m18MasterDataService.resolveLocalItemId(line.proId)?.also {
itemIdCache[line.proId] = it
}
}

val stockUomId: Long? = if (itemId != null) {
val key = line.proId to line.unitId // safe key
@@ -373,6 +421,23 @@ open class M18DeliveryOrderService(

// logger.info("${doLineRefType}: Item ID: ${itemId} | M18 Item ID: ${line.proId}")

if (itemId == null) {
failDetailList.add(line.id)
failItemDetailList.add(line.proId)
logger.error(
"${doLineRefType}: Cannot resolve local item for M18 proId=${line.proId}, skipping line ${line.id}"
)
val errorSaveM18DeliveryOrderLineLogRequest = SaveM18DataLogRequest(
id = saveM18DeliveryOrderLineLog.id,
dataLog = mutableMapOf(
"Exception Message" to "Cannot resolve local item for M18 proId=${line.proId}"
),
statusEnum = M18DataLogStatus.FAIL
)
m18DataLogService.saveM18DataLog(errorSaveM18DeliveryOrderLineLogRequest)
return@forEach
}

try {
// Find the delivery_order_line if exist
// logger.info("${doLineRefType}: Finding exising delivery order line...")
@@ -387,14 +452,27 @@ open class M18DeliveryOrderService(
itemUomService.findByM18Id(line.unitId)
}

val m18UomId = itemUom?.uom?.id
val sourceQty = line.qty
val stockQty =
if (itemId != null && m18UomId != null && m18UomId == stockUomId) {
// M18 line unit is already the stock unit — skip ratio conversion
// (avoids bad qty when item_uom ratioN/ratioD hold spec numbers like 350g).
sourceQty
} else if (itemId != null && m18UomId != null) {
itemUomService.convertQtyToStockQty(itemId, m18UomId, sourceQty)
} else {
sourceQty
}

val saveDeliveryOrderLineRequest = SaveDeliveryOrderLineRequest(
id = existingDeliveryOrderLine?.id,
itemId = itemId,
uomIdM18 = itemUom?.uom?.id,
uomIdM18 = m18UomId,
uomId= stockUomId,
deliveryOrderId = deliveryOrderId,
qtyM18 = line.qty,
qty = itemUomService.convertQtyToStockQty(itemId?:0, itemUom?.uom?.id?: 0, line.qty),
qtyM18 = sourceQty,
qty = stockQty,
up = line.up,
price = line.amt,
// m18CurrencyId = mainpo.curId,
@@ -421,7 +499,7 @@ open class M18DeliveryOrderService(
successDetailList.add(line.id)
// logger.info("${doLineRefType}: Delivery order ID: ${deliveryOrderId} | M18 ID: ${deliveryOrder.id}")
//logger.info("${doLineRefType}: Saved delivery order line. ID: ${saveDeliveryOrderLineResponse.id} | M18 Line ID: ${line.id} | Delivery order ID: ${deliveryOrderId} | M18 ID: ${deliveryOrder.id}")
} catch (e: SQLException) {
} catch (e: Exception) {
failDetailList.add(line.id)
failItemDetailList.add(line.proId)
// logger.error("${doLineRefType}: Saving Failure!")
@@ -528,6 +606,9 @@ open class M18DeliveryOrderService(
// End of save. Check result
logger.info("Total Success (${doRefType}) (${successList.size})")
logger.error("Total Fail (${doRefType}) (${failList.size}): $failList")
if (skippedList.isNotEmpty()) {
logger.info("Total Skipped (${doRefType}) (${skippedList.size}): $skippedList")
}

logger.info("Total Success (${doLineRefType}) (${successDetailList.size})")
logger.error("Total Fail (${doLineRefType}) (${failDetailList.size}): $failDetailList")
@@ -540,11 +621,12 @@ open class M18DeliveryOrderService(

logger.info("--------------------------------------------End - Saving M18 Delivery Order--------------------------------------------")

val skippedSuffix = if (skippedList.isNotEmpty()) " | skipped=${skippedList.size}" else ""
return SyncResult(
totalProcessed = successList.size + failList.size,
totalProcessed = successList.size + failList.size + skippedList.size,
totalSuccess = successList.size,
totalFail = failList.size,
query = deliveryOrdersWithType?.query ?: ""
query = (deliveryOrdersWithType?.query ?: "") + skippedSuffix,
)
}
}

+ 32
- 8
src/main/java/com/ffii/fpsms/m18/service/M18MasterDataService.kt View File

@@ -188,6 +188,13 @@ open class M18MasterDataService(
)
}

/** Resolve local items.id for an M18 product id; sync from M18 when missing. */
open fun resolveLocalItemId(m18ItemId: Long): Long? {
itemsService.findByM18Id(m18ItemId)?.id?.let { return it }
saveProduct(m18ItemId)?.id?.let { return it }
return itemsService.findByM18Id(m18ItemId)?.id
}

open fun saveProduct(id: Long): MessageResponse? {
try {
ensureCunitSeededForAllIfEmpty()
@@ -231,9 +238,18 @@ open class M18MasterDataService(
)

val savedItem = itemsService.saveItem(saveItemRequest)
val localItemId = savedItem.id
if (localItemId == null) {
logger.error("saveItem returned null id for M18 item $id (code=${pro.code}): ${savedItem.message}")
return null
}
if (savedItem.errorPosition == "code") {
logger.error("saveItem duplicate code for M18 item $id (code=${pro.code}): ${savedItem.message}")
return null
}
logger.info("Processing item uom...")
// Find the item uom that ready to delete (not in m18)
val existingItemUoms = savedItem.id?.let { itemUomService.findAllByItemsId(it) }
val existingItemUoms = itemUomService.findAllByItemsId(localItemId)
val m18ItemUomIds = price?.map { it.id } ?: listOf()

// Delete the item uom
@@ -267,7 +283,7 @@ open class M18MasterDataService(
)
val itemUomRequest = ItemUomRequest(
m18UomId = it.unitId,
itemId = savedItem.id,
itemId = localItemId,
baseUnit = it.basicUnit,
stockUnit = it.stkUnit,
pickingUnit = it.pickUnit,
@@ -284,12 +300,11 @@ open class M18MasterDataService(
deleted = it.expired || endInstant.isBefore(now)
)

// logger.info("saved item id: ${savedItem.id}")
itemUomService.saveItemUom(itemUomRequest)
}

logger.info("Success (M18 Item): ${id} | ${pro.code} | ${pro.desc}")
return savedItem
return savedItem.copy(id = localItemId)
} else {
logger.error("Fail Message: ${itemDetail?.messages?.get(0)?.msgDetail}")
logger.error("Fail: Item ID - ${id} Not Found")
@@ -404,11 +419,20 @@ open class M18MasterDataService(
)

val savedItem = itemsService.saveItem(saveItemRequest)
val localItemId = savedItem.id
if (localItemId == null) {
failList.add(item.id)
logger.error("saveItem returned null id for M18 item ${item.id} (code=${pro.code}): ${savedItem.message}")
return@forEach
}
if (savedItem.errorPosition == "code") {
failList.add(item.id)
logger.error("saveItem duplicate code for M18 item ${item.id} (code=${pro.code}): ${savedItem.message}")
return@forEach
}
logger.info("Processing item uom...")

// Optional: cache findAllByItemsId if you think it might be called multiple times
// (usually not needed here because each savedItem.id is unique)
val existingItemUoms = savedItem.id?.let { itemUomService.findAllByItemsId(it) }
val existingItemUoms = itemUomService.findAllByItemsId(localItemId)

val m18ItemUomIds = price?.map { it.id } ?: listOf()

@@ -442,7 +466,7 @@ open class M18MasterDataService(

val itemUomRequest = ItemUomRequest(
m18UomId = it.unitId,
itemId = savedItem.id,
itemId = localItemId,
baseUnit = it.basicUnit,
stockUnit = it.stkUnit,
pickingUnit = it.pickUnit,


+ 32
- 9
src/main/java/com/ffii/fpsms/m18/service/M18PurchaseOrderService.kt View File

@@ -315,6 +315,19 @@ open class M18PurchaseOrderService(
val latestPurchaseOrderLog =
m18DataLogService.findLatestM18DataLogWithSuccess(m18PurchaseOrderId, poRefType)

val existingPurchaseOrderForSync =
latestPurchaseOrderLog?.id?.let { purchaseOrderService.findByM18DataLogId(it) }
if (existingPurchaseOrderForSync != null &&
existingPurchaseOrderForSync.status != PurchaseOrderStatus.PENDING
) {
logger.info(
"${poRefType}: Skipping M18 sync — local PO id=${existingPurchaseOrderForSync.id} " +
"code=${existingPurchaseOrderForSync.code} status=${existingPurchaseOrderForSync.status?.value} " +
"(only pending may be overwritten). M18 ID: $m18PurchaseOrderId"
)
return@forEach
}

// logger.info(latestPurchaseOrderLog.toString())
// Save to m18_data_log table
// logger.info("${poRefType}: Saving for M18 Data Log...")
@@ -336,10 +349,9 @@ open class M18PurchaseOrderService(
// logger.info("${poRefType}: Saved M18 Data Log. ID: ${saveM18PurchaseOrderLog.id}")

try {
// Find the purchase_order if exist
// Find the purchase_order if exist (re-use lookup from pending guard above)
// logger.info("${poRefType}: Finding exising purchase order...")
val existingPurchaseOrder =
latestPurchaseOrderLog?.id?.let { purchaseOrderService.findByM18DataLogId(it) }
val existingPurchaseOrder = existingPurchaseOrderForSync
// logger.info("${poRefType}: Exising purchase order ID: ${existingPurchaseOrder?.id}")

// Save to purchase_order table
@@ -427,14 +439,25 @@ open class M18PurchaseOrderService(

// logger.info("${poLineRefType}: Saved M18 Data Log. ID: ${saveM18PurchaseOrderLineLog.id}")
// logger.info("${poLineRefType}: Finding item...")
val item = itemsService.findByM18Id(line.proId)
val itemId: Long? = if (item == null) {
m18MasterDataService.saveProduct(line.proId)?.id
} else {
item.id
}
val itemId: Long? = m18MasterDataService.resolveLocalItemId(line.proId)
logger.info("${poLineRefType}: Item ID: ${itemId} | M18 Item ID: ${line.proId}")

if (itemId == null) {
failDetailList.add(line.id)
logger.error(
"${poLineRefType}: PO ${mainpo.code}: Cannot resolve local item for M18 proId=${line.proId}, skipping line ${line.id}"
)
val errorSaveM18PurchaseOrderLineLogRequest = SaveM18DataLogRequest(
id = saveM18PurchaseOrderLineLog.id,
dataLog = mutableMapOf(
"Exception Message" to "PO ${mainpo.code} Cannot resolve local item for M18 proId=${line.proId}"
),
statusEnum = M18DataLogStatus.FAIL
)
m18DataLogService.saveM18DataLog(errorSaveM18PurchaseOrderLineLogRequest)
return@forEach
}

try {
// Find the purchase_order_line if exist (stable key: PO + M18 line id)
// logger.info("${poLineRefType}: Finding exising purchase order line...")


+ 43
- 0
src/main/java/com/ffii/fpsms/m18/service/M18VendorLookupService.kt View File

@@ -0,0 +1,43 @@
package com.ffii.fpsms.m18.service

import com.ffii.fpsms.api.service.ApiCallerService
import com.ffii.fpsms.m18.model.M18CommonListRequest
import com.ffii.fpsms.m18.model.M18VendorListResponse
import com.ffii.fpsms.m18.model.StSearchType
import org.slf4j.Logger
import org.slf4j.LoggerFactory
import org.springframework.stereotype.Service

/**
* Lightweight M18 vendor search — kept separate from [M18MasterDataService] to avoid a Spring cycle
* ([M18BomForShopService] → [M18MasterDataService] → [com.ffii.fpsms.modules.master.service.BomService] → [M18BomForShopService]).
*/
@Service
open class M18VendorLookupService(
private val apiCallerService: ApiCallerService,
) {
private val logger: Logger = LoggerFactory.getLogger(M18VendorLookupService::class.java)

private val fetchListApi = "/search/search"

/** M18 vendor id for [code] scoped to [beId] (e.g. PF vs PP business entity). */
open fun findVendorM18IdByCode(code: String, beId: String): Long? {
val trimmed = code.trim()
if (trimmed.isEmpty() || beId.isBlank()) return null
val conds = "(code=equal=$trimmed)=and=(beId=equal=$beId)"
val listResponse = try {
apiCallerService.get<M18VendorListResponse, M18CommonListRequest>(
fetchListApi,
M18CommonListRequest(
stSearch = StSearchType.VENDOR.value,
params = null,
conds = conds,
),
).block()
} catch (e: Exception) {
logger.warn("(findVendorM18IdByCode) M18 search failed code=$trimmed beId=$beId: ${e.message}")
null
}
return listResponse?.values?.firstOrNull()?.id?.takeIf { it > 0L }
}
}

+ 19
- 2
src/main/java/com/ffii/fpsms/m18/web/M18TestController.kt View File

@@ -4,8 +4,9 @@ import com.ffii.core.utils.JwtTokenUtil
import com.ffii.fpsms.m18.M18Config
import com.ffii.fpsms.m18.model.SyncResult
import com.ffii.fpsms.m18.service.*
import com.ffii.fpsms.m18.model.M18BomShopSyncTriggerResult
import com.ffii.fpsms.m18.web.models.M18CommonRequest
import com.ffii.fpsms.modules.common.SettingNames
import com.ffii.fpsms.modules.master.service.BomService
import com.ffii.fpsms.modules.common.scheduler.service.SchedulerService
import com.ffii.fpsms.modules.master.entity.ItemUom
import com.ffii.fpsms.modules.master.entity.Items
@@ -35,6 +36,7 @@ class M18TestController (
private val m18DeliveryOrderService: M18DeliveryOrderService,
val schedulerService: SchedulerService,
private val settingsService: SettingsService,
private val bomService: BomService,
) {
var logger: Logger = LoggerFactory.getLogger(JwtTokenUtil::class.java)

@@ -65,6 +67,14 @@ class M18TestController (
return schedulerService.getM18Pos();
}

@PostMapping("/test/bom-shop-sync/{bomId}")
fun testBomShopSync(
@PathVariable bomId: Long,
@RequestParam(required = false) m18HeaderId: Long?,
): M18BomShopSyncTriggerResult {
return bomService.pushBomToM18ShopIfAllowed(bomId, m18HeaderId)
}

@GetMapping("/test/po-by-code")
fun testSyncPoByCode(@RequestParam code: String): SyncResult {
return m18PurchaseOrderService.savePurchaseOrderByCode(code)
@@ -72,7 +82,14 @@ class M18TestController (

@GetMapping("/test/do-by-code")
fun testSyncDoByCode(@RequestParam code: String): SyncResult {
return m18DeliveryOrderService.saveDeliveryOrderByCode(code)
return m18DeliveryOrderService.saveDeliveryOrderByCode(code, isExtraSync = false)
}

/** DO(加單):手動按 code 同步,並寫入本地 [DeliveryOrder.isExtra]=true(不做 M18 端加單條件過濾) */
@GetMapping("/test/do-by-code-extra")
fun testSyncDoByCodeExtra(@RequestParam code: String): SyncResult {
// 加單 tab: only sync when it's a NEW order (not existing in local system)
return m18DeliveryOrderService.saveDeliveryOrderByCode(code, isExtraSync = true, newOnly = true)
}

@GetMapping("/test/product-by-code")


+ 1
- 1
src/main/java/com/ffii/fpsms/modules/bag/service/bagService.kt View File

@@ -29,7 +29,7 @@ open class BagService(
) {
open fun createBagLotLinesByBagId(request: CreateBagLotLineRequest): MessageResponse {
val bag = bagRepository.findById(request.bagId).orElse(null)
val lot = inventoryLotRepository.findByLotNoAndItemId(request.lotNo, request.itemId)
val lot = inventoryLotRepository.findByIdAndDeletedFalse(request.lotId)
val BaseUnitOfMeasure= itemUomRepository.findByItemIdAndStockUnitIsTrueAndDeletedIsFalse(request.itemId)
val baseRatioN = BaseUnitOfMeasure?.ratioN ?: BigDecimal.ONE
println("baseRatioN: $baseRatioN")


+ 13
- 27
src/main/java/com/ffii/fpsms/modules/bag/web/bagController.kt View File

@@ -1,38 +1,21 @@
package com.ffii.fpsms.modules.bag.web

import com.ffii.core.response.RecordsRes
import com.ffii.fpsms.modules.bag.service.BagService
import jakarta.validation.Valid
import com.ffii.fpsms.modules.bag.web.model.BagConsumptionResponse
import com.ffii.fpsms.modules.bag.web.model.BagInfo
import com.ffii.fpsms.modules.bag.web.model.BagLotLineResponse
import com.ffii.fpsms.modules.bag.web.model.BagSummaryResponse
import com.ffii.fpsms.modules.bag.web.model.BagUsageRecordResponse
import com.ffii.fpsms.modules.bag.web.model.CreateJoBagConsumptionRequest
import com.ffii.fpsms.modules.master.web.models.MessageResponse
import org.springframework.web.bind.annotation.GetMapping
import org.springframework.web.bind.annotation.ModelAttribute
import org.springframework.web.bind.annotation.PathVariable
import org.springframework.web.bind.annotation.PostMapping
import org.springframework.web.bind.annotation.PutMapping
import org.springframework.web.bind.annotation.RequestBody
import org.springframework.web.bind.annotation.RequestMapping
import org.springframework.web.bind.annotation.RestController
import com.ffii.fpsms.modules.jobOrder.service.JoPickOrderService
import com.ffii.fpsms.modules.productProcess.service.ProductProcessService
import com.ffii.fpsms.modules.jobOrder.web.model.*
import com.ffii.fpsms.modules.jobOrder.web.model.ExportPickRecordRequest
import com.ffii.fpsms.modules.jobOrder.web.model.PrintPickRecordRequest
import com.ffii.fpsms.modules.jobOrder.web.model.SecondScanSubmitRequest
import com.ffii.fpsms.modules.jobOrder.web.model.SecondScanIssueRequest
import jakarta.servlet.http.HttpServletResponse
import net.sf.jasperreports.engine.JasperExportManager
import net.sf.jasperreports.engine.JasperPrint
import org.aspectj.weaver.tools.UnsupportedPointcutPrimitiveException
import org.springframework.context.NoSuchMessageException
import java.io.OutputStream
import java.io.UnsupportedEncodingException
import java.text.ParseException
import org.springframework.web.bind.annotation.*
import org.springframework.web.bind.annotation.RequestParam
import com.ffii.fpsms.modules.jobOrder.web.model.UpdateJoPickOrderHandledByRequest
import com.ffii.fpsms.modules.jobOrder.entity.projections.JobOrderInfo
import com.ffii.fpsms.modules.jobOrder.entity.projections.JobOrderInfoWithTypeName
import com.ffii.fpsms.modules.jobOrder.web.model.ExportFGStockInLabelRequest
import com.ffii.fpsms.modules.bag.web.model.*
import com.ffii.fpsms.modules.master.web.models.MessageResponse

@RestController
@RequestMapping("/bag")
class BagController(
@@ -43,14 +26,17 @@ class BagController(
fun getBagInfo(): List<BagInfo> {
return bagService.getAllBagInfo()
}

@PostMapping("/createJoBagConsumption")
fun createJoBagConsumption(@RequestBody request: CreateJoBagConsumptionRequest): MessageResponse {
return bagService.createJoBagConsumption(request)
}

@GetMapping("/bagUsageRecords")
fun getBagUsageRecords(): List<BagUsageRecordResponse> {
return bagService.getAllBagUsageRecords()
}

@GetMapping("/bags")
fun getBags(): List<BagSummaryResponse> =
bagService.getBagSummaries()
@@ -66,4 +52,4 @@ class BagController(
@PutMapping("/by-item/{itemId}/soft-delete")
fun softDeleteBagByItemId(@PathVariable itemId: Long): MessageResponse =
bagService.softDeleteBagByItemId(itemId)
}
}

+ 187
- 117
src/main/java/com/ffii/fpsms/modules/chart/service/ChartService.kt View File

@@ -3,6 +3,7 @@ package com.ffii.fpsms.modules.chart.service
import com.ffii.core.support.JdbcDao
import org.springframework.stereotype.Service
import java.time.LocalDate
import java.time.LocalDateTime

@Service
open class ChartService(
@@ -15,52 +16,40 @@ open class ChartService(
*/
fun getStockTransactionsByDate(startDate: LocalDate?, endDate: LocalDate?): List<Map<String, Any>> {
val args = mutableMapOf<String, Any>()
val startSql = if (startDate != null) {
args["startDate"] = startDate.toString()
"AND DATE(sl.date) >= :startDate"
} else ""
val endSql = if (endDate != null) {
args["endDate"] = endDate.toString()
"AND DATE(sl.date) <= :endDate"
} else ""
val rangeSql = ledgerDateTimeRangeSql(args, "sl.date", startDate, endDate)
val sql = """
SELECT
DATE_FORMAT(sl.date, '%Y-%m-%d') AS date,
COALESCE(SUM(sl.inQty), 0) AS inQty,
COALESCE(SUM(sl.outQty), 0) AS outQty,
COALESCE(SUM(COALESCE(sl.inQty, 0) + COALESCE(sl.outQty, 0)), 0) AS totalQty
FROM stock_ledger sl
FROM stock_ledger sl FORCE INDEX (idx_sl_deleted_date)
WHERE sl.deleted = 0 AND sl.date IS NOT NULL
$startSql $endSql
GROUP BY sl.date
ORDER BY sl.date
$rangeSql
GROUP BY DATE_FORMAT(sl.date, '%Y-%m-%d')
ORDER BY date
""".trimIndent()
return jdbcDao.queryForList(sql, args)
}

/**
* Delivery orders: order count and total line qty by date.
* Uses delivery_order.completeDate or estimatedArrivalDate for date.
* X-axis date: [delivery_order.estimatedArrivalDate] only (no completeDate/orderDate fallback).
* Rows without estimatedArrivalDate are excluded.
*/
fun getDeliveryOrderByDate(startDate: LocalDate?, endDate: LocalDate?): List<Map<String, Any>> {
val args = mutableMapOf<String, Any>()
val startSql = if (startDate != null) {
args["startDate"] = startDate.toString()
"AND DATE(COALESCE(do.completeDate, do.estimatedArrivalDate, do.orderDate)) >= :startDate"
} else ""
val endSql = if (endDate != null) {
args["endDate"] = endDate.toString()
"AND DATE(COALESCE(do.completeDate, do.estimatedArrivalDate, do.orderDate)) <= :endDate"
} else ""
val rangeSql = localDateRangeSql(args, "do.estimatedArrivalDate", startDate, endDate)
val sql = """
SELECT
DATE_FORMAT(COALESCE(do.completeDate, do.estimatedArrivalDate, do.orderDate), '%Y-%m-%d') AS date,
DATE_FORMAT(do.estimatedArrivalDate, '%Y-%m-%d') AS date,
COUNT(DISTINCT do.id) AS orderCount,
COALESCE(SUM(dol.qty), 0) AS totalQty
FROM delivery_order do
LEFT JOIN delivery_order_line dol ON dol.deliveryOrderId = do.id AND dol.deleted = 0
WHERE do.deleted = 0 $startSql $endSql
GROUP BY DATE(COALESCE(do.completeDate, do.estimatedArrivalDate, do.orderDate))
WHERE do.deleted = 0 AND do.estimatedArrivalDate IS NOT NULL
$rangeSql
GROUP BY DATE_FORMAT(do.estimatedArrivalDate, '%Y-%m-%d')
ORDER BY date
""".trimIndent()
return jdbcDao.queryForList(sql, args)
@@ -529,37 +518,45 @@ open class ChartService(
* Stock in vs stock out by date.
* Stock in: stock_in_line.acceptedQty, date from stock_in.completeDate or receiptDate/created.
* Stock out: stock_out_line.qty, date from stock_out.completeDate or created.
*
* Date range is applied inside each UNION branch (predicate pushdown) so we do not aggregate
* all history before filtering. Reads filtered headers first via STRAIGHT_JOIN (si/so then lines).
*/
fun getStockInOutByDate(startDate: LocalDate?, endDate: LocalDate?): List<Map<String, Any>> {
val args = mutableMapOf<String, Any>()
val startSql = if (startDate != null) {
args["startDate"] = startDate.toString()
"AND u.dt >= :startDate"
} else ""
val endSql = if (endDate != null) {
args["endDate"] = endDate.toString()
"AND u.dt <= :endDate"
} else ""
val rangeStart = startDate?.atStartOfDay()
val rangeEndExclusive = endDate?.plusDays(1)?.atStartOfDay()
if (rangeStart != null) args["inOutRangeStart"] = rangeStart
if (rangeEndExclusive != null) args["inOutRangeEndExclusive"] = rangeEndExclusive
val inDateFilter = stockInOutCoalescedDateRangeSql(
"COALESCE(si.completeDate, sil.receiptDate, si.created)",
rangeStart,
rangeEndExclusive,
)
val outDateFilter = stockInOutCoalescedDateRangeSql(
"COALESCE(so.completeDate, so.created)",
rangeStart,
rangeEndExclusive,
)
val sql = """
SELECT DATE_FORMAT(u.dt, '%Y-%m-%d') AS date,
SELECT u.dt AS date,
COALESCE(SUM(u.inQty), 0) AS inQty,
COALESCE(SUM(u.outQty), 0) AS outQty
FROM (
SELECT DATE(COALESCE(si.completeDate, sil.receiptDate, si.created)) AS dt,
SELECT DATE_FORMAT(COALESCE(si.completeDate, sil.receiptDate, si.created), '%Y-%m-%d') AS dt,
SUM(COALESCE(sil.acceptedQty, 0)) AS inQty, 0 AS outQty
FROM stock_in_line sil
INNER JOIN stock_in si ON sil.stockInId = si.id AND si.deleted = 0
WHERE sil.deleted = 0
GROUP BY DATE(COALESCE(si.completeDate, sil.receiptDate, si.created))
FROM stock_in si
STRAIGHT_JOIN stock_in_line sil ON sil.stockInId = si.id AND sil.deleted = 0
WHERE si.deleted = 0$inDateFilter
GROUP BY DATE_FORMAT(COALESCE(si.completeDate, sil.receiptDate, si.created), '%Y-%m-%d')
UNION ALL
SELECT DATE(COALESCE(so.completeDate, so.created)) AS dt,
SELECT DATE_FORMAT(COALESCE(so.completeDate, so.created), '%Y-%m-%d') AS dt,
0 AS inQty, SUM(COALESCE(sol.qty, 0)) AS outQty
FROM stock_out_line sol
INNER JOIN stock_out so ON sol.stockOutId = so.id AND so.deleted = 0
WHERE sol.deleted = 0
GROUP BY DATE(COALESCE(so.completeDate, so.created))
FROM stock_out so
STRAIGHT_JOIN stock_out_line sol ON sol.stockOutId = so.id AND sol.deleted = 0
WHERE so.deleted = 0$outDateFilter
GROUP BY DATE_FORMAT(COALESCE(so.completeDate, so.created), '%Y-%m-%d')
) u
WHERE 1=1 $startSql $endSql
GROUP BY u.dt
ORDER BY u.dt
""".trimIndent()
@@ -568,23 +565,19 @@ open class ChartService(

/**
* Distinct items that appear in delivery_order_line in the period (for multi-select options).
* Period filter: [delivery_order.estimatedArrivalDate] only; null ETA excluded.
* Uses STRAIGHT_JOIN so MySQL reads filtered `delivery_order` first (avoids full scan on `delivery_order_line`).
*/
fun getTopDeliveryItemsItemOptions(startDate: LocalDate?, endDate: LocalDate?): List<Map<String, Any>> {
val args = mutableMapOf<String, Any>()
val startSql = if (startDate != null) {
args["startDate"] = startDate.toString()
"AND DATE(COALESCE(do.completeDate, do.estimatedArrivalDate, do.orderDate)) >= :startDate"
} else ""
val endSql = if (endDate != null) {
args["endDate"] = endDate.toString()
"AND DATE(COALESCE(do.completeDate, do.estimatedArrivalDate, do.orderDate)) <= :endDate"
} else ""
val rangeSql = localDateRangeSql(args, "do.estimatedArrivalDate", startDate, endDate)
val sql = """
SELECT DISTINCT it.code AS itemCode, COALESCE(it.name, '') AS itemName
FROM delivery_order_line dol
INNER JOIN delivery_order do ON dol.deliveryOrderId = do.id AND do.deleted = 0
INNER JOIN items it ON dol.itemId = it.id AND it.deleted = 0
WHERE dol.deleted = 0 $startSql $endSql
FROM delivery_order do
STRAIGHT_JOIN delivery_order_line dol ON dol.deliveryOrderId = do.id AND dol.deleted = 0
STRAIGHT_JOIN items it ON it.id = dol.itemId AND it.deleted = 0
WHERE do.deleted = 0 AND do.estimatedArrivalDate IS NOT NULL
$rangeSql
ORDER BY it.code
""".trimIndent()
return jdbcDao.queryForList(sql, args)
@@ -592,6 +585,8 @@ open class ChartService(

/**
* Top delivery items by total qty in the period. When itemCodes is non-empty, only those items (still ordered by totalQty, limit applied).
* Period filter: [delivery_order.estimatedArrivalDate] only; null ETA excluded.
* Uses STRAIGHT_JOIN so MySQL reads filtered `delivery_order` first (avoids full scan on `delivery_order_line`).
*/
fun getTopDeliveryItems(
startDate: LocalDate?,
@@ -600,14 +595,7 @@ open class ChartService(
itemCodes: List<String>?
): List<Map<String, Any>> {
val args = mutableMapOf<String, Any>("limit" to limit)
val startSql = if (startDate != null) {
args["startDate"] = startDate.toString()
"AND DATE(COALESCE(do.completeDate, do.estimatedArrivalDate, do.orderDate)) >= :startDate"
} else ""
val endSql = if (endDate != null) {
args["endDate"] = endDate.toString()
"AND DATE(COALESCE(do.completeDate, do.estimatedArrivalDate, do.orderDate)) <= :endDate"
} else ""
val rangeSql = localDateRangeSql(args, "do.estimatedArrivalDate", startDate, endDate)
val itemSql = if (!itemCodes.isNullOrEmpty()) {
val codes = itemCodes.map { it.trim() }.filter { it.isNotBlank() }
if (codes.isEmpty()) "" else {
@@ -620,10 +608,11 @@ open class ChartService(
it.code AS itemCode,
it.name AS itemName,
SUM(COALESCE(dol.qty, 0)) AS totalQty
FROM delivery_order_line dol
INNER JOIN delivery_order do ON dol.deliveryOrderId = do.id AND do.deleted = 0
INNER JOIN items it ON dol.itemId = it.id AND it.deleted = 0
WHERE dol.deleted = 0 $startSql $endSql $itemSql
FROM delivery_order do
STRAIGHT_JOIN delivery_order_line dol ON dol.deliveryOrderId = do.id AND dol.deleted = 0
STRAIGHT_JOIN items it ON it.id = dol.itemId AND it.deleted = 0
WHERE do.deleted = 0 AND do.estimatedArrivalDate IS NOT NULL
$rangeSql $itemSql
GROUP BY dol.itemId, it.code, it.name
ORDER BY totalQty DESC
LIMIT :limit
@@ -641,26 +630,26 @@ open class ChartService(
itemCode: String?
): List<Map<String, Any>> {
val args = mutableMapOf<String, Any>()
val startSql = if (startDate != null) {
args["startDate"] = startDate.toString()
"AND sl.date >= :startDate"
} else ""
val endSql = if (endDate != null) {
args["endDate"] = endDate.toString()
"AND sl.date <= :endDate"
} else ""
val itemSql = if (!itemCode.isNullOrBlank()) {
val rangeSql = ledgerDateTimeRangeSql(args, "sl.date", startDate, endDate)
val hasItemFilter = !itemCode.isNullOrBlank()
if (hasItemFilter) {
args["itemCode"] = "%$itemCode%"
"AND sl.itemCode LIKE :itemCode"
} else ""
}
val itemSql = if (hasItemFilter) "AND sl.itemCode LIKE :itemCode" else ""
val fromClause = if (hasItemFilter) {
"FROM stock_ledger sl"
} else {
"FROM stock_ledger sl FORCE INDEX (idx_sl_deleted_date)"
}
val sql = """
SELECT
DATE_FORMAT(sl.date, '%Y-%m-%d') AS date,
COALESCE(SUM(sl.balance), 0) AS balance
FROM stock_ledger sl
WHERE sl.deleted = 0 AND sl.date IS NOT NULL $startSql $endSql $itemSql
GROUP BY sl.date
ORDER BY sl.date
$fromClause
WHERE sl.deleted = 0 AND sl.date IS NOT NULL
$rangeSql $itemSql
GROUP BY DATE_FORMAT(sl.date, '%Y-%m-%d')
ORDER BY date
""".trimIndent()
return jdbcDao.queryForList(sql, args)
}
@@ -677,27 +666,35 @@ open class ChartService(
): List<Map<String, Any>> {
val args = mutableMapOf<String, Any>()
val yearSql = if (year != null) {
args["year"] = year
"AND YEAR(sl.date) = :year"
} else ""
val startSql = if (startDate != null) {
args["startDate"] = startDate.toString()
"AND sl.date >= :startDate"
} else ""
val endSql = if (endDate != null) {
args["endDate"] = endDate.toString()
"AND sl.date <= :endDate"
args["consumptionYearStart"] = LocalDate.of(year, 1, 1).atStartOfDay()
args["consumptionYearEndExclusive"] = LocalDate.of(year + 1, 1, 1).atStartOfDay()
"AND sl.date >= :consumptionYearStart AND sl.date < :consumptionYearEndExclusive"
} else ""
val itemSql = if (!itemCode.isNullOrBlank()) {
val rangeSql = ledgerDateTimeRangeSql(
args,
"sl.date",
startDate,
endDate,
startArg = "consumptionRangeStart",
endArg = "consumptionRangeEndExclusive",
)
val hasItemFilter = !itemCode.isNullOrBlank()
if (hasItemFilter) {
args["itemCode"] = "%$itemCode%"
"AND sl.itemCode LIKE :itemCode"
} else ""
}
val itemSql = if (hasItemFilter) "AND sl.itemCode LIKE :itemCode" else ""
val fromClause = if (hasItemFilter) {
"FROM stock_ledger sl"
} else {
"FROM stock_ledger sl FORCE INDEX (idx_sl_deleted_date)"
}
val sql = """
SELECT
DATE_FORMAT(sl.date, '%Y-%m') AS month,
COALESCE(SUM(sl.outQty), 0) AS outQty
FROM stock_ledger sl
WHERE sl.deleted = 0 AND sl.date IS NOT NULL $yearSql $startSql $endSql $itemSql
$fromClause
WHERE sl.deleted = 0 AND sl.date IS NOT NULL
$yearSql $rangeSql $itemSql
GROUP BY DATE_FORMAT(sl.date, '%Y-%m')
ORDER BY month
""".trimIndent()
@@ -721,23 +718,29 @@ open class ChartService(

/**
* Staff delivery performance: daily pick ticket count and total time per staff.
* Uses do_pick_order_record (handler = handledBy); time = sum of (ticketCompleteDateTime - ticketReleaseTime) per record.
* Optionally use do_pick_order_line_record for line count; here orderCount = number of completed pick tickets.
* Uses delivery_order_pick_order (handler = handledBy); time = sum of
* (ticketCompleteDateTime - ticketReleaseTime) per completed ticket.
* staffNos: when non-empty, filter to these staff by user.staffNo (multi-select).
* storeIdNull: when true, only rows with dop.storeId IS NULL (takes precedence over storeId).
* storeId: when non-blank and storeIdNull is not true, filter dop.storeId equality (trimmed).
* When no store filter, FORCE INDEX (idx_dopo_staff_perf_complete) so the optimizer uses a
* ticketCompleteDateTime range scan instead of a less selective store composite index.
*/
fun getStaffDeliveryPerformance(
startDate: LocalDate?,
endDate: LocalDate?,
staffNos: List<String>?
staffNos: List<String>?,
storeId: String?,
storeIdNull: Boolean?,
): List<Map<String, Any>> {
val args = mutableMapOf<String, Any>()
val startSql = if (startDate != null) {
args["startDate"] = startDate.toString()
"AND DATE(dpor.ticketCompleteDateTime) >= :startDate"
args["startDate"] = startDate.atStartOfDay()
"AND dop.ticketCompleteDateTime >= :startDate"
} else ""
val endSql = if (endDate != null) {
args["endDate"] = endDate.toString()
"AND DATE(dpor.ticketCompleteDateTime) <= :endDate"
args["endExclusive"] = endDate.plusDays(1).atStartOfDay()
"AND dop.ticketCompleteDateTime < :endExclusive"
} else ""
val staffSql = if (!staffNos.isNullOrEmpty()) {
val nos = staffNos.map { it.trim() }.filter { it.isNotBlank() }
@@ -746,25 +749,40 @@ open class ChartService(
"AND u.staffNo IN (:staffNos)"
}
} else ""
val storeSql = when {
storeIdNull == true -> "AND dop.storeId IS NULL"
!storeId.isNullOrBlank() -> {
args["filterStoreId"] = storeId.trim()
"AND dop.storeId = :filterStoreId"
}
else -> ""
}
val useStoreFilter = storeIdNull == true || !storeId.isNullOrBlank()
val fromClause = if (useStoreFilter) {
"FROM delivery_order_pick_order dop"
} else {
"FROM delivery_order_pick_order dop FORCE INDEX (idx_dopo_staff_perf_complete)"
}
val sql = """
SELECT
DATE_FORMAT(dpor.ticketCompleteDateTime, '%Y-%m-%d') AS date,
COALESCE(u.name, dpor.handler_name, 'Unknown') AS staffName,
COUNT(dpor.id) AS orderCount,
DATE_FORMAT(dop.ticketCompleteDateTime, '%Y-%m-%d') AS date,
COALESCE(NULLIF(TRIM(COALESCE(u.name, '')), ''), dop.handlerName, 'Unknown') AS staffName,
COUNT(dop.id) AS orderCount,
COALESCE(SUM(
CASE
WHEN dpor.ticket_release_time IS NOT NULL AND dpor.ticketCompleteDateTime IS NOT NULL
THEN GREATEST(0, TIMESTAMPDIFF(MINUTE, dpor.ticket_release_time, dpor.ticketCompleteDateTime))
WHEN dop.ticketReleaseTime IS NOT NULL AND dop.ticketCompleteDateTime IS NOT NULL
THEN GREATEST(0, TIMESTAMPDIFF(MINUTE, dop.ticketReleaseTime, dop.ticketCompleteDateTime))
ELSE 0
END
), 0) AS totalMinutes
FROM do_pick_order_record dpor
LEFT JOIN user u ON dpor.handled_by = u.id AND u.deleted = 0
WHERE dpor.deleted = 0
AND dpor.ticket_status = 'completed'
AND dpor.ticketCompleteDateTime IS NOT NULL
$startSql $endSql $staffSql
GROUP BY DATE(dpor.ticketCompleteDateTime), dpor.handled_by, u.name, dpor.handler_name
$fromClause
LEFT JOIN user u ON dop.handledBy = u.id AND u.deleted = 0
WHERE dop.deleted = 0
AND dop.ticketStatus = 'completed'
AND dop.ticketCompleteDateTime IS NOT NULL
$startSql $endSql $staffSql $storeSql
GROUP BY DATE_FORMAT(dop.ticketCompleteDateTime, '%Y-%m-%d'),
dop.handledBy, u.name, dop.handlerName
ORDER BY date, orderCount DESC
""".trimIndent()
return jdbcDao.queryForList(sql, args)
@@ -1572,4 +1590,56 @@ open class ChartService(
""".trimIndent()
return jdbcDao.queryForList(sql, args)
}

/** Half-open [start, end+1 day) on a DATE/DATETIME column (no DATE() wrapper). */
private fun localDateRangeSql(
args: MutableMap<String, Any>,
column: String,
startDate: LocalDate?,
endDate: LocalDate?,
startArg: String = "chartRangeStart",
endArg: String = "chartRangeEndExclusive",
): String = buildString {
if (startDate != null) {
args[startArg] = startDate
append(" AND $column >= :$startArg")
}
if (endDate != null) {
args[endArg] = endDate.plusDays(1)
append(" AND $column < :$endArg")
}
}

/** Half-open range on stock_ledger.date (DATETIME). */
private fun ledgerDateTimeRangeSql(
args: MutableMap<String, Any>,
column: String,
startDate: LocalDate?,
endDate: LocalDate?,
startArg: String = "ledgerRangeStart",
endArg: String = "ledgerRangeEndExclusive",
): String = buildString {
if (startDate != null) {
args[startArg] = startDate.atStartOfDay()
append(" AND $column >= :$startArg")
}
if (endDate != null) {
args[endArg] = endDate.plusDays(1).atStartOfDay()
append(" AND $column < :$endArg")
}
}

/** COALESCE datetime expression; args [inOutRangeStart] / [inOutRangeEndExclusive] must already be in map when non-null. */
private fun stockInOutCoalescedDateRangeSql(
coalescedExpr: String,
rangeStart: LocalDateTime?,
rangeEndExclusive: LocalDateTime?,
): String = buildString {
if (rangeStart != null) {
append(" AND $coalescedExpr >= :inOutRangeStart")
}
if (rangeEndExclusive != null) {
append(" AND $coalescedExpr < :inOutRangeEndExclusive")
}
}
}

+ 11
- 5
src/main/java/com/ffii/fpsms/modules/chart/web/ChartController.kt View File

@@ -26,7 +26,7 @@ class ChartController(

/**
* GET /chart/delivery-order-by-date?startDate=&endDate=
* Returns [{ date, orderCount, totalQty }]
* Returns [{ date, orderCount, totalQty }]. Date axis: delivery_order.estimatedArrivalDate only (null ETA excluded).
*/
@GetMapping("/delivery-order-by-date")
fun getDeliveryOrderByDate(
@@ -129,7 +129,7 @@ class ChartController(

/**
* GET /chart/stock-in-out-by-date?startDate=&endDate=
* Returns [{ date, inQty, outQty }]
* Returns [{ date, inQty, outQty }]. Date range pushed into each UNION branch; si/so read before lines.
*/
@GetMapping("/stock-in-out-by-date")
fun getStockInOutByDate(
@@ -140,6 +140,7 @@ class ChartController(
/**
* GET /chart/top-delivery-items-item-options?startDate=&endDate=
* Returns [{ itemCode, itemName }] — distinct items in delivery lines in the period (for multi-select).
* Period: delivery_order.estimatedArrivalDate only (null ETA excluded).
*/
@GetMapping("/top-delivery-items-item-options")
fun getTopDeliveryItemsItemOptions(
@@ -150,6 +151,7 @@ class ChartController(
/**
* GET /chart/top-delivery-items?startDate=&endDate=&limit=20&itemCode=A&itemCode=B
* Returns [{ itemCode, itemName, totalQty }]. When itemCode present, only those items (still by totalQty, limit).
* Period: delivery_order.estimatedArrivalDate only (null ETA excluded).
*/
@GetMapping("/top-delivery-items")
fun getTopDeliveryItems(
@@ -192,16 +194,20 @@ class ChartController(
chartService.getStaffDeliveryPerformanceHandlers()

/**
* GET /chart/staff-delivery-performance?startDate=&endDate=&staffNo=A001&staffNo=A002
* Returns [{ date, staffName, orderCount, totalMinutes }]. Data from do_pick_order_record (handled_by), orderCount = completed pick tickets, totalMinutes = sum(ticketCompleteDateTime - ticketReleaseTime).
* GET /chart/staff-delivery-performance?startDate=&endDate=&staffNo=A001&staffNo=A002&storeId=2/F&storeIdNull=true
* Returns [{ date, staffName, orderCount, totalMinutes }]. Data from delivery_order_pick_order
* (handledBy), orderCount = completed pick tickets, totalMinutes = sum(ticketCompleteDateTime - ticketReleaseTime).
* Optional storeId filters delivery_order_pick_order.storeId; storeIdNull=true means IS NULL (overrides storeId).
*/
@GetMapping("/staff-delivery-performance")
fun getStaffDeliveryPerformance(
@RequestParam(required = false) @DateTimeFormat(iso = DateTimeFormat.ISO.DATE) startDate: LocalDate?,
@RequestParam(required = false) @DateTimeFormat(iso = DateTimeFormat.ISO.DATE) endDate: LocalDate?,
@RequestParam(required = false) staffNo: List<String>?,
@RequestParam(required = false) storeId: String?,
@RequestParam(required = false) storeIdNull: Boolean?,
): List<Map<String, Any>> =
chartService.getStaffDeliveryPerformance(startDate, endDate, staffNo)
chartService.getStaffDeliveryPerformance(startDate, endDate, staffNo, storeId, storeIdNull)

// ---------- Job order reports ----------



+ 4
- 5
src/main/java/com/ffii/fpsms/modules/common/MailSMTP.kt View File

@@ -23,12 +23,11 @@ open class MailSMTP(settingsService: SettingsService) {
if (other == null || other !is MailSMTP) return false

val o = other as MailSMTP
if (StringUtils.equals(
this.host,
o.host
) && this.port == o.port &&
if (StringUtils.equals(this.host, o.host) &&
this.port == o.port &&
StringUtils.equals(this.username, o.username) &&
StringUtils.equals(this.password, o.password)
StringUtils.equals(this.password, o.password) &&
this.auth == o.auth
) {
return true
}


+ 15
- 0
src/main/java/com/ffii/fpsms/modules/common/SettingNames.java View File

@@ -28,8 +28,13 @@ public abstract class SettingNames {
public static final String SCHEDULE_M18_DO1 = "SCHEDULE.m18.do1";
/** Saturday-only DO1 time (default 03:10). Mon–Fri & Sun use [SCHEDULE_M18_DO1] time via a second trigger. */
public static final String SCHEDULE_M18_DO1_SAT = "SCHEDULE.m18.do1.sat";
/** Comma-separated dDates (yyyy-MM-dd) of completed one-time DO1 catch-ups ([scheduler.do1CatchUp]). */
public static final String SCHEDULE_M18_DO1_CATCHUP_DONE_DDATE = "SCHEDULE.m18.do1.catchup.doneDDate";
public static final String SCHEDULE_M18_DO2 = "SCHEDULE.m18.do2";

/** Daily push FPSMS BOMs → M18 udfBomForShop (default 23:00; requires [M18_BOM_SHOP_SYNC_ENABLED] and scheduler.m18Sync.enabled). */
public static final String SCHEDULE_M18_BOM_SHOP = "SCHEDULE.m18.bom.shop";

public static final String SCHEDULE_M18_MASTER = "SCHEDULE.m18.master";

/** M18 unit master sync via GET /search/search?stSearch=unit (cron, e.g. "0 40 12 * * *" for 12:40 daily) */
@@ -41,6 +46,11 @@ public abstract class SettingNames {
*/
public static final String M18_UNITS_SYNC_INITIAL_FULL_SYNC_DONE = "M18.units.sync.initialFullSyncDone";

/**
* When "true", FPSMS may push BOM header + materials to M18 udfBomForShop.
*/
public static final String M18_BOM_SHOP_SYNC_ENABLED = "M18.bom.shop.sync.enabled";

/** Post completed DN and process M18 GRN (cron, e.g. "0 40 23 * * *" for 23:40 daily) */
public static final String SCHEDULE_POST_COMPLETED_DN_GRN = "SCHEDULE.postCompletedDn.grn";

@@ -52,6 +62,11 @@ public abstract class SettingNames {
public static final String SCHEDULE_PROD_ROUGH = "SCHEDULE.prod.rough";

public static final String SCHEDULE_PROD_DETAILED = "SCHEDULE.prod.detailed";

/**
* Job order plan-start overdue batch (default 00:00:15 daily): hide or reschedule JOs whose plan day was yesterday.
*/
public static final String SCHEDULE_JO_PLAN_START = "SCHEDULE.jo.planStart";
/*
* Mail settings
*/


+ 18
- 0
src/main/java/com/ffii/fpsms/modules/common/alert/LoggingSmsSender.kt View File

@@ -0,0 +1,18 @@
package com.ffii.fpsms.modules.common.alert

import org.slf4j.LoggerFactory

/** Dev / disabled mode: log SMS body instead of sending. */
class LoggingSmsSender : SmsSender {
private val logger = LoggerFactory.getLogger(LoggingSmsSender::class.java)

override fun send(
toNumbers: List<String>,
body: String,
job: String,
code: String,
detail: String,
) {
logger.warn("[SMS would send to {}] {}", toNumbers.joinToString(), body)
}
}

+ 27
- 0
src/main/java/com/ffii/fpsms/modules/common/alert/PoLineFailureAlertSupport.kt View File

@@ -0,0 +1,27 @@
package com.ffii.fpsms.modules.common.alert

/**
* Parses PO line sync failure rows written by [com.ffii.fpsms.m18.service.M18PurchaseOrderService].
* Expected format: "PO {code} Cannot resolve local item for M18 proId={id}".
*/
internal object PoLineFailureAlertSupport {
private val WITH_PO_CODE =
Regex("^PO (\\S+) Cannot resolve local item for M18 proId=(\\d+)$")

/** @return (poCode, bullet line for email) or null when the message cannot be parsed. */
fun parseExceptionMessage(raw: String?): Pair<String, String>? {
val trimmed = raw?.trim().orEmpty()
if (trimmed.isEmpty()) {
return null
}
val match = WITH_PO_CODE.matchEntire(trimmed) ?: return null
val poCode = match.groupValues[1]
val proId = match.groupValues[2]
return poCode to "Cannot resolve local item for M18 proId=$proId"
}

fun buildEmailBody(poCode: String, bulletLines: Collection<String>): String {
val detail = bulletLines.joinToString("\n") { "- $it" }
return "FPSMS PO_LINE FAIL: $poCode\n\n$detail"
}
}

+ 73
- 0
src/main/java/com/ffii/fpsms/modules/common/alert/SchedulerSyncAlertProperties.kt View File

@@ -0,0 +1,73 @@
package com.ffii.fpsms.modules.common.alert

import org.springframework.boot.context.properties.ConfigurationProperties

@ConfigurationProperties(prefix = "scheduler.sync-alert")
data class SchedulerSyncAlertProperties(
/** When false, checks still run but SMS is not sent (logged only). */
val enabled: Boolean = false,
/** Cron for the watchdog that evaluates sync health (default: every 15 minutes). */
val checkCron: String = "0 */15 * * * *",
val sms: SmsProperties = SmsProperties(),
val email: EmailAlertProperties = EmailAlertProperties(),
val do1: Do1AlertProperties = Do1AlertProperties(),
val presence: PresenceAlertProperties = PresenceAlertProperties(),
val poLine: PoLineAlertProperties = PoLineAlertProperties(),
)

data class SmsProperties(
/** When false, no SMS/WhatsApp alerts (email-only). */
val enabled: Boolean = false,
/** twilio | log (dev: log message only, no HTTP) */
val provider: String = "log",
/** sms or whatsapp (Twilio WhatsApp uses whatsapp:+E164 on To/From). */
val channel: String = "sms",
val accountSid: String = "",
val authToken: String = "",
val fromNumber: String = "",
/** Comma-separated E.164 numbers, e.g. +85291234567 (whatsapp prefix added when channel=whatsapp). */
val toNumbers: String = "",
/**
* Twilio Content Template SID (WhatsApp business-initiated messages).
* When set, sends ContentSid + ContentVariables instead of Body.
* Template should use {{1}}=job (DO1/PO/…) and {{2}}=alert detail.
*/
val contentSid: String = "",
val contentDetailMaxLength: Int = 200,
/**
* Maps to Twilio ContentVariables `"1"` (template {{1}}).
* job | date (d/M, e.g. 17/6) | time (3pm) | detail | summary (job + code + detail)
*/
val contentVar1: String = "job",
/** Maps to ContentVariables `"2"`. Same options as [contentVar1]. */
val contentVar2: String = "detail",
)

data class EmailAlertProperties(
/** Send sync alerts by email (uses DB MAIL.smtp.* — Office 365). */
val enabled: Boolean = false,
/** Comma-separated recipients, e.g. [email protected],[email protected] */
val toAddresses: String = "",
/** Subject prefix; full subject is prefix + job + code in brackets. */
val subjectPrefix: String = "FPSMS M18 sync alert",
)

data class Do1AlertProperties(
/** Warn when recordsProcessed is below this (typical full day ~800+). */
val minRecordsProcessed: Int = 400,
/** Minutes after today's DO1 schedule time before evaluating DO1 rules. */
val graceMinutesAfterSchedule: Int = 30,
val alertOnFailed: Boolean = true,
val alertOnZeroRecords: Boolean = true,
val alertOnLineFailures: Boolean = true,
)

/** PO / DO2 / master-data: alert if no SUCCESS log by schedule time + grace. */
data class PresenceAlertProperties(
val graceMinutesAfterSchedule: Int = 60,
)

/** Purchase order line sync failures from [m18_data_log] (one email per PO code per day). */
data class PoLineAlertProperties(
val enabled: Boolean = true,
)

+ 504
- 0
src/main/java/com/ffii/fpsms/modules/common/alert/SchedulerSyncAlertService.kt View File

@@ -0,0 +1,504 @@
package com.ffii.fpsms.modules.common.alert

import com.ffii.fpsms.m18.entity.M18DataLogRepository
import com.ffii.fpsms.m18.entity.SchedulerSyncLog
import com.ffii.fpsms.m18.entity.SchedulerSyncLogRepository
import com.ffii.fpsms.m18.enums.M18DataLogStatus
import com.ffii.fpsms.modules.common.SettingNames
import com.ffii.fpsms.modules.common.scheduler.service.SchedulerService
import com.ffii.fpsms.modules.settings.entity.Settings
import com.ffii.fpsms.modules.settings.service.SettingsService
import org.slf4j.LoggerFactory
import org.springframework.beans.factory.annotation.Value
import org.springframework.scheduling.support.CronExpression
import org.springframework.stereotype.Service
import org.springframework.web.reactive.function.client.WebClient
import java.time.DayOfWeek
import java.time.LocalDate
import java.time.LocalDateTime
import java.time.LocalTime
import java.time.format.DateTimeFormatter
import java.util.Locale
import kotlin.jvm.optionals.getOrNull

/**
* Watches [scheduler_sync_log] after M18 cron jobs and sends SMS when rules fail.
*
* - **DO1**: low volume (&lt; min records), FAILED status, zero records, line failures.
* - **PO / DO2 / master-data**: no SUCCESS log by schedule + grace window.
* - **PO_LINE**: [m18_data_log] purchase order line FAIL rows (one email per PO code per day).
*/
@Service
open class SchedulerSyncAlertService(
private val properties: SchedulerSyncAlertProperties,
private val smsSender: SmsSender,
private val syncAlertEmailSender: SyncAlertEmailSender,
private val schedulerSyncLogRepository: SchedulerSyncLogRepository,
private val m18DataLogRepository: M18DataLogRepository,
private val settingsService: SettingsService,
private val webClientBuilder: WebClient.Builder,
@Value("\${scheduler.m18Sync.enabled:false}") private val m18SyncEnabled: Boolean,
) {
private val logger = LoggerFactory.getLogger(SchedulerSyncAlertService::class.java)

private companion object {
const val PO_LINE_REF_TYPE = "Purchase Order Line"
const val PO_LINE_ALERT_JOB = "PO_LINE"
}

/** Master-data sub-jobs written by [SchedulerService.getM18MasterData]. */
private val masterDataSyncTypes =
listOf("Units", "Products", "Vendors", "BusinessUnits", "Currencies")

open fun runChecks(now: LocalDateTime = LocalDateTime.now()): List<String> {
val alerts = mutableListOf<Triple<String, String, String>>()
alerts += checkPoLineFailures(now)

if (!m18SyncEnabled) {
for (message in alerts) {
sendAlert(message.first, message.second, message.third)
}
if (alerts.isEmpty()) {
logger.debug("Sync alert skipped (scheduler.m18Sync.enabled=false)")
}
return alerts.map { it.third }
}

alerts += checkDo1(now)
alerts += checkPresenceSync("PO", SettingNames.SCHEDULE_M18_PO, "0 0 2 * * *", now)
alerts += checkPresenceSync("DO2", SettingNames.SCHEDULE_M18_DO2, SchedulerService.DO2_DEFAULT_CRON, now)
alerts += checkMasterDataPresence(now)

for (message in alerts) {
sendAlert(message.first, message.second, message.third)
}
return alerts.map { it.third }
}

/**
* Email when M18 PO line sync wrote FAIL rows to [m18_data_log] today.
* Dedupes via [sendAlert] using job [PO_LINE_ALERT_JOB] and PO code (one email per PO per day).
*/
private fun checkPoLineFailures(now: LocalDateTime): List<Triple<String, String, String>> {
if (!properties.poLine.enabled) {
return emptyList()
}

val dayStart = now.toLocalDate().atStartOfDay()
val logs =
m18DataLogRepository.findAllByRefTypeAndStatusAndDeletedIsFalseAndCreatedGreaterThanEqualOrderByIdAsc(
PO_LINE_REF_TYPE,
M18DataLogStatus.FAIL,
dayStart,
)
if (logs.isEmpty()) {
return emptyList()
}

val bulletsByPo = linkedMapOf<String, LinkedHashSet<String>>()
for (log in logs) {
val raw = log.dataLog?.get("Exception Message")?.toString()
val parsed = PoLineFailureAlertSupport.parseExceptionMessage(raw) ?: continue
bulletsByPo.getOrPut(parsed.first) { LinkedHashSet() }.add(parsed.second)
}
if (bulletsByPo.isEmpty()) {
return emptyList()
}

return bulletsByPo.map { (poCode, bullets) ->
Triple(
PO_LINE_ALERT_JOB,
poCode,
PoLineFailureAlertSupport.buildEmailBody(poCode, bullets),
)
}
}

private fun checkDo1(now: LocalDateTime): List<Triple<String, String, String>> {
val runDate = now.toLocalDate()
val scheduledTime = resolveDo1ScheduledTime(runDate) ?: return emptyList()
val checkAfter = scheduledTime.plusMinutes(properties.do1.graceMinutesAfterSchedule.toLong())
if (now.isBefore(checkAfter)) {
return emptyList()
}

val log = latestLogOnDay("DO1", runDate) ?: run {
return listOf(Triple("DO1", "MISSING", alertMessage("DO1", "MISSING", "No DO1 sync log today (expected after ${scheduledTime.toLocalTime()}).")))
}

val issues = mutableListOf<String>()
val cfg = properties.do1

if (cfg.alertOnFailed && log.status.equals("FAILED", ignoreCase = true)) {
issues += "status=FAILED ${log.errorMessage.orEmpty()}".trim()
}
if (cfg.alertOnZeroRecords && log.recordsProcessed == 0) {
issues += "0 orders processed (possible M18 list timeout)"
}
if (log.recordsProcessed < cfg.minRecordsProcessed) {
issues += "only ${log.recordsProcessed} processed (min ${cfg.minRecordsProcessed})"
}
if (cfg.alertOnLineFailures && log.recordsFailed > 0) {
issues += "${log.recordsFailed} line(s) failed"
}

if (issues.isEmpty()) {
return emptyList()
}
return listOf(Triple("DO1", "PROBLEM", alertMessage("DO1", "PROBLEM", issues.joinToString("; "))))
}

private fun checkMasterDataPresence(now: LocalDateTime): List<Triple<String, String, String>> {
val runDate = now.toLocalDate()
val cron =
settingsService.findByName(SettingNames.SCHEDULE_M18_MASTER).getOrNull()?.value
?: "0 0 1 * * *"
val scheduledTime = scheduledTimeToday(cron, runDate) ?: return emptyList()
val checkAfter = scheduledTime.plusMinutes(properties.presence.graceMinutesAfterSchedule.toLong())
if (now.isBefore(checkAfter)) {
return emptyList()
}

val missing = masterDataSyncTypes.filter { type ->
val log = latestLogOnDay(type, runDate)
log == null || !log.status.equals("SUCCESS", ignoreCase = true)
}
if (missing.isEmpty()) {
return emptyList()
}
return listOf(
Triple(
"MASTER",
"MISSING",
alertMessage("MASTER", "MISSING", "No SUCCESS today for: ${missing.joinToString()}"),
),
)
}

private fun checkPresenceSync(
label: String,
settingName: String,
defaultCron: String,
now: LocalDateTime,
): List<Triple<String, String, String>> {
val runDate = now.toLocalDate()
val cron = settingsService.findByName(settingName).getOrNull()?.value ?: defaultCron
val scheduledTime = scheduledTimeToday(cron, runDate) ?: return emptyList()
val checkAfter = scheduledTime.plusMinutes(properties.presence.graceMinutesAfterSchedule.toLong())
if (now.isBefore(checkAfter)) {
return emptyList()
}

val log = latestLogOnDay(label, runDate)
when {
log == null ->
return listOf(
Triple(
label,
"MISSING",
alertMessage(label, "MISSING", "No $label sync log today (expected after ${scheduledTime.toLocalTime()})."),
),
)
!log.status.equals("SUCCESS", ignoreCase = true) ->
return listOf(
Triple(
label,
log.status.uppercase(),
alertMessage(
label,
log.status.uppercase(),
"processed=${log.recordsProcessed} failed=${log.recordsFailed} ${log.errorMessage.orEmpty()}".trim(),
),
),
)
else -> return emptyList()
}
}

private fun latestLogOnDay(syncType: String, date: LocalDate): SchedulerSyncLog? {
val dayStart = date.atStartOfDay()
val dayEnd = dayStart.plusDays(1)
return schedulerSyncLogRepository.findFirstBySyncTypeAndStartTimeBetweenOrderByEndTimeDesc(
syncType,
dayStart,
dayEnd,
)
}

private fun resolveDo1ScheduledTime(date: LocalDate): LocalDateTime? {
val cron =
if (date.dayOfWeek == DayOfWeek.SATURDAY) {
settingsService.findByName(SettingNames.SCHEDULE_M18_DO1_SAT).getOrNull()?.value
?: "0 10 3 ? * SAT"
} else {
settingsService.findByName(SettingNames.SCHEDULE_M18_DO1).getOrNull()?.value
?: "0 10 19 * * *"
}
return scheduledTimeToday(cron, date)
}

/** Next fire time on [date] from a 6-field Spring cron, if that day matches the DOW field. */
internal fun scheduledTimeToday(cronExpression: String, date: LocalDate): LocalDateTime? {
if (!cronMatchesDate(cronExpression, date)) {
return null
}
val parts = cronExpression.trim().split(Regex("\\s+"))
if (parts.size != 6) {
return null
}
val second = parts[0].toIntOrNull() ?: 0
val minute = parts[1].toIntOrNull() ?: return null
val hour = parts[2].toIntOrNull() ?: return null
return date.atTime(LocalTime.of(hour, minute, second))
}

internal fun cronMatchesDate(cronExpression: String, date: LocalDate): Boolean {
val parts = cronExpression.trim().split(Regex("\\s+"))
if (parts.size != 6) {
return false
}
val dowField = parts[5].uppercase()
if (dowField == "*" || dowField == "?") {
return true
}

val day = date.dayOfWeek
val tokens = dowField.split(",")
for (token in tokens) {
val trimmed = token.trim()
when {
trimmed.contains("-") -> {
val range = trimmed.split("-")
if (range.size == 2) {
val from = parseDayOfWeek(range[0]) ?: continue
val to = parseDayOfWeek(range[1]) ?: continue
if (day.value in from.value..to.value) {
return true
}
}
}
else -> {
parseDayOfWeek(trimmed)?.let { if (day == it) return true }
}
}
}
return false
}

private fun parseDayOfWeek(token: String): DayOfWeek? =
when (token.uppercase()) {
"SUN", "0", "7" -> DayOfWeek.SUNDAY
"MON", "1" -> DayOfWeek.MONDAY
"TUE", "2" -> DayOfWeek.TUESDAY
"WED", "3" -> DayOfWeek.WEDNESDAY
"THU", "4" -> DayOfWeek.THURSDAY
"FRI", "5" -> DayOfWeek.FRIDAY
"SAT", "6" -> DayOfWeek.SATURDAY
else -> null
}

private fun alertMessage(job: String, code: String, detail: String): String =
"FPSMS $job $code: $detail"

private fun detailFromMessage(message: String): String {
val prefix = message.indexOf(": ")
return if (prefix >= 0) message.substring(prefix + 2) else message
}

private fun sendAlert(job: String, code: String, message: String) {
val dedupeKey = "SCHEDULE.syncAlert.sent.$job.$code.${LocalDate.now()}"
if (alreadySent(dedupeKey)) {
logger.debug("Sync alert already sent today: {}", message)
return
}

if (!properties.enabled) {
logger.warn("[sync-alert disabled] {}", message)
return
}

var anySent = false

if (properties.email.enabled) {
val emailTo = properties.email.toAddresses.split(",")
.map { it.trim() }
.filter { it.isNotBlank() }
if (emailTo.isEmpty()) {
logger.error("Sync alert email skipped (no scheduler.sync-alert.email.to-addresses): {}", message)
} else if (!syncAlertEmailSender.isSmtpConfigured()) {
logger.error(
"Sync alert email skipped (configure MAIL.smtp.username/password/host in settings): {}",
message,
)
} else {
try {
val subject = "${properties.email.subjectPrefix} [$job] $code"
syncAlertEmailSender.send(emailTo, subject, message)
anySent = true
logger.info("Sync alert email sent: {}", message)
} catch (e: Exception) {
logger.error("Sync alert email failed: {}", message, e)
}
}
}

val smsTo = properties.sms.toNumbers.split(",")
.map { it.trim() }
.filter { it.isNotBlank() }
val smsActive =
properties.sms.enabled &&
!properties.sms.provider.equals("log", ignoreCase = true) &&
smsTo.isNotEmpty()
if (smsActive) {
try {
smsSender.send(smsTo, message, job, code, detailFromMessage(message))
anySent = true
logger.info("Sync alert SMS sent: {}", message)
} catch (e: Exception) {
logger.error("Sync alert SMS failed: {}", message, e)
}
}

if (anySent) {
markSent(dedupeKey, message)
} else {
logger.error("Sync alert not delivered on any channel: {}", message)
}
}

private fun alreadySent(key: String): Boolean =
settingsService.findByName(key).map { it.value == Settings.VALUE_BOOLEAN_TRUE }.orElse(false)

private fun markSent(key: String, message: String) {
val existing = settingsService.findByName(key).orElse(null)
if (existing != null) {
settingsService.update(key, Settings.VALUE_BOOLEAN_TRUE)
} else {
val setting = Settings()
setting.name = key
setting.value = Settings.VALUE_BOOLEAN_TRUE
setting.category = "SCHEDULE"
setting.type = Settings.TYPE_BOOLEAN
settingsService.save(setting)
}
}

/** Validates cron at startup / schedule registration. */
fun isValidCronExpression(cronExpression: String): Boolean =
try {
CronExpression.parse(cronExpression)
true
} catch (_: IllegalArgumentException) {
false
}

/**
* Send one WhatsApp using your Twilio Content template (same shape as Twilio console sample).
* Defaults: var1=today d/M, var2=now h:mma (e.g. 17/6, 7:30pm). Pass var1=12/1&var2=3pm to match sandbox demo.
*/
open fun sendTestWhatsApp(var1: String?, var2: String?): String {
if (!properties.sms.enabled) {
return "WhatsApp/SMS sync alerts are disabled (scheduler.sync-alert.sms.enabled=false)."
}
twilioConfigIssue()?.let { return it }

val twilio = resolveTwilioSender()!!

val recipients = properties.sms.toNumbers.split(",")
.map { it.trim() }
.filter { it.isNotBlank() }

val now = LocalDateTime.now()
val v1 = var1?.trim()?.takeIf { it.isNotEmpty() }
?: now.format(DateTimeFormatter.ofPattern("d/M"))
val v2 = var2?.trim()?.takeIf { it.isNotEmpty() }
?: now.format(DateTimeFormatter.ofPattern("h:mma", Locale.ENGLISH))

twilio.sendWithTemplateVars(recipients, v1, v2)
return "WhatsApp test sent ContentVariables={\"1\":\"$v1\",\"2\":\"$v2\"} to ${recipients.joinToString()}"
}

/** Send a test email via DB SMTP (Office 365). Does not require sync-alert.enabled. */
open fun sendTestEmail(message: String? = null, subject: String? = null): String {
val emailTo = properties.email.toAddresses.split(",")
.map { it.trim() }
.filter { it.isNotBlank() }
if (emailTo.isEmpty()) {
return "Set scheduler.sync-alert.email.to-addresses (e.g. [email protected])"
}
if (!syncAlertEmailSender.isSmtpConfigured()) {
return syncAlertEmailSender.smtpConfigIssue()
?: "Configure MAIL.smtp.username, MAIL.smtp.password, MAIL.smtp.host in settings table"
}
val from = syncAlertEmailSender.resolveFromAddress() ?: "unknown"
val body =
message?.trim()?.takeIf { it.isNotEmpty() }
?: (
"FPSMS sync alert email test.\n\n" +
"If you receive this, Office 365 SMTP is working.\n" +
"From (MAIL.smtp.username): $from\n" +
"Time: ${LocalDateTime.now()}"
)
val emailSubject =
subject?.trim()?.takeIf { it.isNotEmpty() }
?: "${properties.email.subjectPrefix} [TEST]"
return try {
syncAlertEmailSender.send(emailTo, emailSubject, body)
"Test email sent from $from to ${emailTo.joinToString()}\nSubject: $emailSubject"
} catch (e: Exception) {
val hint =
when {
e.message?.contains("530", ignoreCase = true) == true ||
e.message?.contains("not authenticated", ignoreCase = true) == true ->
"\n\nOffice 365 fix checklist:\n" +
"1. UPDATE settings SET value='true' WHERE name='MAIL.smtp.auth';\n" +
"2. MAIL.smtp.username must match the mailbox ([email protected])\n" +
"3. Use an App password if MFA is on (not your normal login password)\n" +
"4. IT must enable 'Authenticated SMTP' for this mailbox in Exchange admin\n" +
"5. Restart backend after changing settings (SMTP client is cached)"
else -> ""
}
"Email send failed: ${e.message}$hint"
}
}

/** Uses injected [TwilioSmsSender] bean, or builds one when Twilio credentials are in config. */
private fun resolveTwilioSender(): TwilioSmsSender? {
if (twilioConfigIssue() != null) {
return null
}
if (smsSender is TwilioSmsSender) {
return smsSender
}
return TwilioSmsSender(properties, webClientBuilder)
}

private fun twilioConfigIssue(): String? {
val sms = properties.sms
val missing = mutableListOf<String>()
if (sms.accountSid.isBlank()) {
missing += "TWILIO_ACCOUNT_SID (or scheduler.sync-alert.sms.account-sid)"
}
if (sms.authToken.isBlank()) {
missing += "TWILIO_AUTH_TOKEN (or scheduler.sync-alert.sms.auth-token)"
}
if (sms.fromNumber.isBlank()) {
missing += "TWILIO_FROM_NUMBER"
}
if (sms.toNumbers.isBlank()) {
missing += "SYNC_ALERT_SMS_TO (or scheduler.sync-alert.sms.to-numbers)"
}
if (sms.contentSid.isBlank()) {
missing += "TWILIO_WHATSAPP_CONTENT_SID"
}
if (missing.isEmpty()) {
return null
}
return (
"Twilio not configured. Missing: ${missing.joinToString(", ")}.\n" +
"Set env vars and restart backend. Example (PowerShell before bootRun):\n" +
"\$env:TWILIO_ACCOUNT_SID='AC…'; \$env:TWILIO_AUTH_TOKEN='…'; " +
"\$env:SYNC_ALERT_SMS_PROVIDER='twilio'"
)
}
}

+ 27
- 0
src/main/java/com/ffii/fpsms/modules/common/alert/SmsAlertConfig.kt View File

@@ -0,0 +1,27 @@
package com.ffii.fpsms.modules.common.alert

import org.springframework.boot.context.properties.EnableConfigurationProperties
import org.springframework.context.annotation.Bean
import org.springframework.context.annotation.Configuration
import org.springframework.web.reactive.function.client.WebClient

@Configuration
@EnableConfigurationProperties(SchedulerSyncAlertProperties::class)
open class SmsAlertConfig(
private val properties: SchedulerSyncAlertProperties,
private val webClientBuilder: WebClient.Builder,
) {
@Bean
open fun smsSender(): SmsSender {
val sms = properties.sms
val useTwilio =
sms.enabled &&
(sms.provider.equals("twilio", ignoreCase = true) ||
(sms.accountSid.isNotBlank() && sms.authToken.isNotBlank()))
return if (useTwilio) {
TwilioSmsSender(properties, webClientBuilder)
} else {
LoggingSmsSender()
}
}
}

+ 11
- 0
src/main/java/com/ffii/fpsms/modules/common/alert/SmsSender.kt View File

@@ -0,0 +1,11 @@
package com.ffii.fpsms.modules.common.alert

interface SmsSender {
fun send(
toNumbers: List<String>,
body: String,
job: String = "",
code: String = "",
detail: String = "",
)
}

+ 68
- 0
src/main/java/com/ffii/fpsms/modules/common/alert/SyncAlertEmailSender.kt View File

@@ -0,0 +1,68 @@
package com.ffii.fpsms.modules.common.alert

import com.ffii.fpsms.modules.common.SettingNames
import com.ffii.fpsms.modules.common.mail.service.MailSenderService
import com.ffii.fpsms.modules.settings.service.SettingsService
import jakarta.mail.internet.InternetAddress
import org.slf4j.LoggerFactory
import org.springframework.mail.javamail.MimeMessageHelper
import org.springframework.stereotype.Service

/**
* Plain-text alert emails via existing Office 365 SMTP settings in DB
* ([SettingNames.MAIL_SMTP_HOST] = smtp.office365.com, port 587, etc.).
*/
@Service
open class SyncAlertEmailSender(
private val mailSenderService: MailSenderService,
private val settingsService: SettingsService,
) {
private val logger = LoggerFactory.getLogger(SyncAlertEmailSender::class.java)

open fun isSmtpConfigured(): Boolean {
return smtpConfigIssue() == null
}

/** Null if OK; otherwise a short message for UI/logs. */
open fun smtpConfigIssue(): String? {
return try {
val username = settingsService.findByName(SettingNames.MAIL_SMTP_USERNAME).orElse(null)?.value
val password = settingsService.findByName(SettingNames.MAIL_SMTP_PASSWORD).orElse(null)?.value
val host = settingsService.findByName(SettingNames.MAIL_SMTP_HOST).orElse(null)?.value
when {
host.isNullOrBlank() -> "MAIL.smtp.host is empty"
username.isNullOrBlank() -> "MAIL.smtp.username is empty"
password.isNullOrBlank() -> "MAIL.smtp.password is empty"
else -> null
}
} catch (_: Exception) {
"MAIL.smtp settings missing in settings table"
}
}

open fun resolveFromAddress(): String? =
settingsService.findByName(SettingNames.MAIL_SMTP_USERNAME).orElse(null)?.value?.trim()?.takeIf { it.isNotEmpty() }

open fun send(toAddresses: List<String>, subject: String, body: String) {
require(toAddresses.isNotEmpty()) { "No email recipients" }
smtpConfigIssue()?.let { throw IllegalStateException(it) }
val from = resolveFromAddress()
?: throw IllegalStateException("MAIL.smtp.username is not set in settings")

try {
val sender = mailSenderService.get()
val mimeMessage = sender.createMimeMessage()
val helper = MimeMessageHelper(mimeMessage, false, Charsets.UTF_8.name())
helper.setFrom(InternetAddress(from))
helper.setTo(toAddresses.toTypedArray())
helper.setSubject(subject)
helper.setText(body, false)

sender.send(mimeMessage)
logger.info("Sync alert email sent from {} to {}", from, toAddresses.joinToString())
} catch (e: Exception) {
logger.error("Sync alert email failed from {}: {}", from, e.message, e)
throw e
}
}
}

+ 138
- 0
src/main/java/com/ffii/fpsms/modules/common/alert/TwilioSmsSender.kt View File

@@ -0,0 +1,138 @@
package com.ffii.fpsms.modules.common.alert

import com.google.gson.Gson
import org.slf4j.LoggerFactory
import org.springframework.http.MediaType
import org.springframework.util.LinkedMultiValueMap
import org.springframework.web.reactive.function.BodyInserters
import org.springframework.web.reactive.function.client.WebClient
import java.time.LocalDateTime
import java.time.format.DateTimeFormatter
import java.util.Locale

/**
* Twilio Programmable Messaging (SMS or WhatsApp) — same API as:
*
* ```
* curl 'https://api.twilio.com/2010-04-01/Accounts/{AccountSid}/Messages.json' -X POST \
* --data-urlencode 'To=whatsapp:+85292710394' \
* --data-urlencode 'From=whatsapp:+14155238886' \
* --data-urlencode 'ContentSid=HXb5b62575e6e4ff6129ad7c8efe1f983e' \
* --data-urlencode 'ContentVariables={"1":"12/1","2":"3pm"}' \
* -u {AccountSid}:{AuthToken}
* ```
*/
class TwilioSmsSender(
private val properties: SchedulerSyncAlertProperties,
webClientBuilder: WebClient.Builder,
) : SmsSender {
private val logger = LoggerFactory.getLogger(TwilioSmsSender::class.java)
private val webClient = webClientBuilder.build()
private val gson = Gson()

override fun send(
toNumbers: List<String>,
body: String,
job: String,
code: String,
detail: String,
) {
sendRaw(toNumbers, body, buildContentVariables(job, code, detail, body))
}

/** Send with explicit template variables (for connectivity tests matching a Twilio sample). */
fun sendWithTemplateVars(toNumbers: List<String>, var1: String, var2: String) {
val json = gson.toJson(mapOf("1" to var1, "2" to var2))
sendRaw(toNumbers, "FPSMS test", json)
}

private fun sendRaw(toNumbers: List<String>, body: String, contentVariablesJson: String?) {
val sms = properties.sms
require(sms.accountSid.isNotBlank()) { "scheduler.sync-alert.sms.accountSid is required for Twilio" }
require(sms.authToken.isNotBlank()) { "scheduler.sync-alert.sms.authToken is required for Twilio" }
require(sms.fromNumber.isNotBlank()) { "scheduler.sync-alert.sms.fromNumber is required for Twilio" }

val url = "https://api.twilio.com/2010-04-01/Accounts/${sms.accountSid}/Messages.json"
val from = formatAddress(sms.fromNumber, sms.channel)
val useTemplate = sms.contentSid.isNotBlank()

for (to in toNumbers) {
val form = LinkedMultiValueMap<String, String>()
form.add("To", formatAddress(to, sms.channel))
form.add("From", from)
if (useTemplate) {
require(!contentVariablesJson.isNullOrBlank()) { "ContentVariables required when contentSid is set" }
form.add("ContentSid", sms.contentSid)
form.add("ContentVariables", contentVariablesJson)
logger.info("Twilio WhatsApp ContentVariables={}", contentVariablesJson)
} else {
form.add("Body", body.take(1600))
}

try {
webClient.post()
.uri(url)
.headers { it.setBasicAuth(sms.accountSid, sms.authToken) }
.contentType(MediaType.APPLICATION_FORM_URLENCODED)
.body(BodyInserters.fromFormData(form))
.retrieve()
.bodyToMono(String::class.java)
.block()
logger.info("Twilio {} sent to {}", sms.channel, to)
} catch (e: Exception) {
logger.error("Twilio {} failed for {}: {}", sms.channel, to, e.message, e)
throw e
}
}
}

private fun formatAddress(number: String, channel: String): String {
val trimmed = number.trim()
if (!channel.equals("whatsapp", ignoreCase = true)) {
return trimmed
}
return if (trimmed.startsWith("whatsapp:", ignoreCase = true)) trimmed else "whatsapp:$trimmed"
}

private fun buildContentVariables(job: String, code: String, detail: String, body: String): String {
val now = LocalDateTime.now()
val summary = buildSummary(job, code, detail, body)
val var1 = resolveContentVar(properties.sms.contentVar1, job, code, detail, body, summary, now)
val var2 = resolveContentVar(properties.sms.contentVar2, job, code, detail, body, summary, now)
return gson.toJson(mapOf("1" to var1, "2" to var2))
}

private fun buildSummary(job: String, code: String, detail: String, body: String): String {
val j = job.ifBlank { "FPSMS" }
return when {
code.isNotBlank() && detail.isNotBlank() -> "$j $code — $detail"
detail.isNotBlank() -> "$j — $detail"
body.isNotBlank() -> body.removePrefix("FPSMS ").take(properties.sms.contentDetailMaxLength)
else -> j
}.take(properties.sms.contentDetailMaxLength)
}

private fun resolveContentVar(
kind: String,
job: String,
code: String,
detail: String,
body: String,
summary: String,
now: LocalDateTime,
): String =
when (kind.lowercase()) {
"date" -> DATE_FORMAT.format(now)
"time" -> TIME_FORMAT.format(now)
"job" -> job.ifBlank { "FPSMS" }
"code" -> code.ifBlank { "ALERT" }
"detail" -> detail.take(properties.sms.contentDetailMaxLength).ifBlank { summary }
"summary" -> summary
else -> summary
}

companion object {
private val DATE_FORMAT = DateTimeFormatter.ofPattern("d/M", Locale.ENGLISH)
private val TIME_FORMAT = DateTimeFormatter.ofPattern("h:mma", Locale.ENGLISH)
}
}

+ 7
- 4
src/main/java/com/ffii/fpsms/modules/common/mail/service/MailSenderService.kt View File

@@ -22,19 +22,22 @@ open class MailSenderService(private val settingsService: SettingsService) {
val sender = JavaMailSenderImpl()

val props = Properties()
val auth = config.auth ?: false
val hasCredentials =
!config.username.isNullOrBlank() && !config.password.isNullOrBlank()
// Office 365 requires AUTH; treat as enabled when username + password are set.
val auth = (config.auth == true) || hasCredentials
if (auth) {
props["mail.smtp.timeout"] = "20000"
props["mail.smtp.connectiontimeout"] = "10000"
}
props["mail.smtp.auth"] = auth
// The below setting needs to be included when the SMTP has TLS Version
props["mail.smtp.auth"] = auth.toString()
props["mail.smtp.starttls.enable"] = "true"
props["mail.smtp.starttls.required"] = "true"
props["mail.smtp.ssl.protocols"] = "TLSv1.2"

sender.host = config.host
sender.port = config.port!!
if (auth) {
if (auth && hasCredentials) {
sender.username = config.username
sender.password = config.password
}


+ 20
- 3
src/main/java/com/ffii/fpsms/modules/common/mail/service/MailTemplateService.kt View File

@@ -5,6 +5,7 @@ import com.ffii.fpsms.modules.common.mail.entity.MailTemplateRepository
import com.ffii.fpsms.modules.common.mail.web.models.DownloadMailTemplateResponse
import com.ffii.fpsms.modules.common.mail.web.models.MailTemplateRequest
import com.ffii.fpsms.modules.qc.service.QcResultService
import com.ffii.fpsms.modules.qc.web.model.QcResultInfoResponse
import com.ffii.fpsms.modules.stock.entity.StockInLineRepository
import com.ffii.fpsms.modules.stock.service.InventoryLotService
import com.itextpdf.html2pdf.ConverterProperties
@@ -14,6 +15,7 @@ import org.jsoup.Jsoup
import org.springframework.stereotype.Service
import java.io.ByteArrayOutputStream
import java.math.BigDecimal
import java.time.LocalDateTime
import java.time.format.DateTimeFormatter
import kotlin.jvm.optionals.getOrNull

@@ -33,6 +35,19 @@ open class MailTemplateService(

val formatter = DateTimeFormatter.ofPattern("yyyy-MM-dd")

private fun formatFailedQcResultLine(result: QcResultInfoResponse): String {
val label = result.name?.takeIf { it.isNotBlank() }
?: result.code?.takeIf { it.isNotBlank() }
?: "N/A"
val criteria = result.description?.takeIf { it.isNotBlank() } ?: ""
val base = if (criteria.isNotBlank()) "$label - $criteria" else label
val measurementSuffix = result.measurementValue?.let { value ->
val unit = result.unit.orEmpty()
";實測值:$value$unit"
}.orEmpty()
return base + measurementSuffix
}

fun allMailTemplates(): List<MailTemplate> {
return mailTemplateRepository.findAllByDeletedIsFalse();
}
@@ -166,19 +181,21 @@ open class MailTemplateService(
val filteredResult = qcResults
.groupBy { Pair(it.stockInLineId, it.qcItemId) }
.mapValues { (_, group) ->
group.maxByOrNull { it.recordDate }
group.maxByOrNull { it.recordDate ?: LocalDateTime.MIN }
}
.values
.filterNotNull()
.filter { !it.qcPassed }
if (filteredResult.isNotEmpty()) {
qcDate = formatter.format(filteredResult.maxOf { it.recordDate })
filteredResult.mapNotNull { it.recordDate }.maxOrNull()?.let { latest ->
qcDate = formatter.format(latest)
}

val tempDoc = Jsoup.parse("")
val element = tempDoc.appendElement("ul")
for (result in filteredResult) {
element.appendElement("li")
.text("${result.code} - ${result.description}")
.text(formatFailedQcResultLine(result))
}
tempDoc.outerHtml()
} else {


+ 350
- 24
src/main/java/com/ffii/fpsms/modules/common/scheduler/service/SchedulerService.kt View File

@@ -1,6 +1,5 @@
package com.ffii.fpsms.modules.common.scheduler.service

import com.ffii.core.utils.JwtTokenUtil
import com.ffii.fpsms.m18.service.M18DeliveryOrderService
import com.ffii.fpsms.m18.service.M18GrnCodeSyncService
import com.ffii.fpsms.m18.service.M18MasterDataService
@@ -10,6 +9,9 @@ import com.ffii.fpsms.m18.entity.SchedulerSyncLog
import com.ffii.fpsms.m18.entity.SchedulerSyncLogRepository
import com.ffii.fpsms.m18.model.SyncResult
import com.ffii.fpsms.modules.common.SettingNames
import com.ffii.fpsms.modules.common.alert.SchedulerSyncAlertService
import com.ffii.fpsms.modules.jobOrder.service.JobOrderPlanStartAutoService
import com.ffii.fpsms.modules.master.service.BomM18ShopBulkPushService
import com.ffii.fpsms.modules.master.service.ProductionScheduleService
import com.ffii.fpsms.modules.stock.service.SearchCompletedDnService
import com.ffii.fpsms.modules.stock.service.InventoryLotLineService
@@ -25,6 +27,7 @@ import org.springframework.stereotype.Service
import java.time.DayOfWeek
import java.time.LocalDate
import java.time.LocalDateTime
import java.time.ZoneId
import java.time.format.DateTimeFormatter
import java.util.HashMap
import java.util.concurrent.ScheduledFuture
@@ -42,6 +45,15 @@ open class SchedulerService(
@Value("\${scheduler.inventoryLotExpiry.enabled:true}") val inventoryLotExpiryEnabled: Boolean,
/** When false (default), M18 PO / DO1 / DO2 / master-data cron jobs are not registered — use true in production only. */
@Value("\${scheduler.m18Sync.enabled:false}") val m18SyncEnabled: Boolean,
@Value("\${scheduler.jo.planStart.enabled:true}") val jobOrderPlanStartAutoEnabled: Boolean,
@Value("\${scheduler.do1CatchUp.enabled:false}") val do1CatchUpEnabled: Boolean,
@Value("\${scheduler.do1CatchUp.dDate:}") val do1CatchUpDDate: String,
@Value("\${scheduler.do1CatchUp.runAt:}") val do1CatchUpRunAt: String,
@Value("\${scheduler.do1CatchUp.skipExistingDo:true}") val do1CatchUpSkipExistingDo: Boolean,
@Value("\${scheduler.do1CatchUp2.enabled:false}") val do1CatchUp2Enabled: Boolean,
@Value("\${scheduler.do1CatchUp2.dDate:}") val do1CatchUp2DDate: String,
@Value("\${scheduler.do1CatchUp2.runAt:}") val do1CatchUp2RunAt: String,
@Value("\${scheduler.do1CatchUp2.skipExistingDo:true}") val do1CatchUp2SkipExistingDo: Boolean,
val settingsService: SettingsService,
/**
* Lookback window for GRN code sync: rows with `created` from **start of (today − N days)** through **now**,
@@ -56,8 +68,25 @@ open class SchedulerService(
val searchCompletedDnService: SearchCompletedDnService,
val m18GrnCodeSyncService: M18GrnCodeSyncService,
val inventoryLotLineService: InventoryLotLineService,
val jobOrderPlanStartAutoService: JobOrderPlanStartAutoService,
private val bomM18ShopBulkPushService: BomM18ShopBulkPushService,
private val schedulerSyncAlertService: SchedulerSyncAlertService,
@Value("\${scheduler.sync-alert.check-cron:0 */15 * * * *}") private val syncAlertCheckCron: String,
@Value("\${scheduler.sync-alert.enabled:false}") private val syncAlertEnabled: Boolean,
) {
var logger: Logger = LoggerFactory.getLogger(JwtTokenUtil::class.java)
companion object {
/** DO2: Spring 6-field cron default and M18 `lastModifyDate` upper bound hour (1pm local). */
const val DO2_MODIFIED_TO_HOUR: Int = 13
const val DO2_DEFAULT_CRON: String = "0 0 13 * * *"
/** Default 23:00 daily — BOM → M18 udfBomForShop for all BOMs ([SettingNames.SCHEDULE_M18_BOM_SHOP]). */
const val M18_BOM_SHOP_DEFAULT_CRON: String = "0 0 23 * * *"
/** Daily 00:00:15 — process job orders whose planStart was yesterday. */
const val JO_PLAN_START_DEFAULT_CRON: String = "15 0 0 * * *"
}

/** Class logger (was incorrectly wired to JwtTokenUtil, so all scheduler lines showed under that category). */
private val logger: Logger = LoggerFactory.getLogger(SchedulerService::class.java)

val dataStringFormat = DateTimeFormatter.ofPattern("yyyy-MM-dd")
val dateTimeStringFormat = DateTimeFormatter.ofPattern("yyyy-MM-dd HH:mm:ss")
val defaultCronExpression = "0 0 2 31 2 *";
@@ -70,6 +99,8 @@ open class SchedulerService(
var scheduledM18Do1Sat: ScheduledFuture<*>? = null
var scheduledM18Do2: ScheduledFuture<*>? = null

var scheduledM18BomShop: ScheduledFuture<*>? = null

@Volatile
var scheduledM18Master: ScheduledFuture<*>? = null

@@ -80,6 +111,13 @@ open class SchedulerService(
var scheduledGrnCodeSync: ScheduledFuture<*>? = null
var scheduledInventoryLotExpiry: ScheduledFuture<*>? = null

var scheduledJobOrderPlanStart: ScheduledFuture<*>? = null

var scheduledDo1CatchUp: ScheduledFuture<*>? = null
var scheduledDo1CatchUp2: ScheduledFuture<*>? = null

var scheduledSyncAlert: ScheduledFuture<*>? = null

//@Volatile
//var scheduledRoughProd: ScheduledFuture<*>? = null

@@ -165,14 +203,198 @@ open class SchedulerService(
scheduleM18Po();
scheduleM18Do1();
scheduleM18Do2();
scheduleM18BomShop();
scheduleM18MasterData();
schedulePostCompletedDnGrn();
scheduleGrnCodeSync();
scheduleInventoryLotExpiry();
scheduleJobOrderPlanStartAuto();
scheduleDo1CatchUpOnce();
scheduleSyncAlertWatchdog();
//scheduleRoughProd();
//scheduleDetailedProd();
}

/** Periodic check of scheduler_sync_log; sends SMS when M18 sync rules fail (production). */
fun scheduleSyncAlertWatchdog() {
scheduledSyncAlert?.cancel(false)
scheduledSyncAlert = null
if (!m18SyncEnabled || !syncAlertEnabled) {
logger.info(
"M18 sync alert watchdog disabled (m18Sync={}, sync-alert.enabled={})",
m18SyncEnabled,
syncAlertEnabled,
)
return
}
var cron = syncAlertCheckCron
if (!isValidCronExpression(cron)) {
cron = "0 */15 * * * *"
}
scheduledSyncAlert = taskScheduler.schedule(
{ schedulerSyncAlertService.runChecks() },
CronTrigger(cron),
)
logger.info("Scheduled M18 sync alert watchdog: {}", cron)
}

open fun runSyncAlertCheckNow(): List<String> = schedulerSyncAlertService.runChecks()

open fun sendSyncAlertTestWhatsApp(var1: String?, var2: String?): String =
schedulerSyncAlertService.sendTestWhatsApp(var1, var2)

open fun sendSyncAlertTestEmail(message: String?, subject: String?): String =
schedulerSyncAlertService.sendTestEmail(message, subject)

/**
* One-time DO1 catch-up jobs for fixed dDates (e.g. missed 15/6 → dDate 17/6, 16/6 → dDate 18/6).
* Requires [m18SyncEnabled] (production only). Config: scheduler.do1CatchUp / do1CatchUp2 in application-prod.yml.
* Completed dDates are stored comma-separated in [SettingNames.SCHEDULE_M18_DO1_CATCHUP_DONE_DDATE].
*/
fun scheduleDo1CatchUpOnce() {
scheduledDo1CatchUp?.cancel(false)
scheduledDo1CatchUp = null
scheduledDo1CatchUp2?.cancel(false)
scheduledDo1CatchUp2 = null

if (!m18SyncEnabled) {
logger.info("DO1 catch-up schedulers disabled (scheduler.m18Sync.enabled=false; production only)")
return
}

scheduledDo1CatchUp = scheduleOneDo1CatchUp(
scheduledDo1CatchUp,
do1CatchUpEnabled,
do1CatchUpDDate,
do1CatchUpRunAt,
do1CatchUpSkipExistingDo,
"do1CatchUp",
)
scheduledDo1CatchUp2 = scheduleOneDo1CatchUp(
scheduledDo1CatchUp2,
do1CatchUp2Enabled,
do1CatchUp2DDate,
do1CatchUp2RunAt,
do1CatchUp2SkipExistingDo,
"do1CatchUp2",
)
}

private fun scheduleOneDo1CatchUp(
existing: ScheduledFuture<*>?,
enabled: Boolean,
dDateRaw: String,
runAtRaw: String,
skipExistingDo: Boolean,
configKey: String,
): ScheduledFuture<*>? {
existing?.cancel(false)
if (!enabled) {
return null
}
val dDateStr = dDateRaw.trim()
val runAtStr = runAtRaw.trim()
if (dDateStr.isEmpty() || runAtStr.isEmpty()) {
logger.warn("{} enabled but dDate or runAt is blank — skipped", configKey)
return null
}

val dDate = try {
LocalDate.parse(dDateStr)
} catch (e: Exception) {
logger.error("Invalid scheduler.{}.dDate={}", configKey, dDateStr)
return null
}
val runAt = try {
LocalDateTime.parse(runAtStr)
} catch (e: Exception) {
logger.error("Invalid scheduler.{}.runAt={}", configKey, runAtStr)
return null
}

if (isDo1CatchUpAlreadyDone(dDate)) {
logger.info("DO1 catch-up ({}) already completed for dDate={}", configKey, dDate)
return null
}

val now = LocalDateTime.now()
if (!runAt.isAfter(now)) {
logger.warn(
"DO1 catch-up ({}) runAt={} is not in the future (now={}); use GET /scheduler/trigger/do1-catchup?dDate={}",
configKey,
runAt,
now,
dDate,
)
return null
}

val scheduled = taskScheduler.schedule(
{ runDo1CatchUp(dDate, skipExistingDo) },
runAt.atZone(ZoneId.systemDefault()).toInstant(),
)
logger.info(
"Scheduled one-time DO1 catch-up ({}) for dDate={} at {} skipExistingDo={}",
configKey,
dDate,
runAt,
skipExistingDo,
)
return scheduled
}

private fun getDo1CatchUpDoneDDateSet(): Set<String> {
val done = settingsService.findByName(SettingNames.SCHEDULE_M18_DO1_CATCHUP_DONE_DDATE).getOrNull()?.value
?: return emptySet()
return done.split(",").map { it.trim() }.filter { it.isNotEmpty() }.toSet()
}

private fun isDo1CatchUpAlreadyDone(dDate: LocalDate): Boolean {
return dDate.toString() in getDo1CatchUpDoneDDateSet()
}

private fun markDo1CatchUpDone(dDate: LocalDate) {
try {
val name = SettingNames.SCHEDULE_M18_DO1_CATCHUP_DONE_DDATE
val updated = (getDo1CatchUpDoneDDateSet() + dDate.toString()).sorted().joinToString(",")
val existing = settingsService.findByName(name).orElse(null)
if (existing != null) {
settingsService.update(name, updated)
} else {
val setting = Settings()
setting.name = name
setting.value = updated
setting.category = "SCHEDULE"
setting.type = Settings.TYPE_STRING
settingsService.save(setting)
}
} catch (e: Exception) {
logger.error("Failed to persist DO1 catch-up done marker for dDate={}: {}", dDate, e.message, e)
}
}

open fun runDo1CatchUp(dDate: LocalDate, skipExistingDo: Boolean = true) {
if (!m18SyncEnabled) {
logger.warn(
"DO1 catch-up refused for dDate={}: production only (scheduler.m18Sync.enabled=false)",
dDate,
)
return
}
if (isDo1CatchUpAlreadyDone(dDate)) {
logger.info("DO1 catch-up already completed for dDate={}", dDate)
return
}
try {
getM18Dos1ForDDate(dDate, syncType = "DO1_CATCHUP", skipExistingDo = skipExistingDo)
} catch (e: Exception) {
logger.error("DO1 catch-up sync failed for dDate={}: {}", dDate, e.message, e)
return
}
markDo1CatchUpDone(dDate)
logger.info("DO1 catch-up completed for dDate={}", dDate)
}

// Scheduler
// --------------------------- FP-MTMS --------------------------- //
//fun scheduleRoughProd() {
@@ -206,7 +428,19 @@ open class SchedulerService(
logger.info("M18 DO2 scheduler disabled (scheduler.m18Sync.enabled=false)")
return
}
scheduledM18Do2 = commonSchedule(scheduledM18Do2, SettingNames.SCHEDULE_M18_DO2, ::getM18Dos2)
scheduledM18Do2 = commonSchedule(scheduledM18Do2, SettingNames.SCHEDULE_M18_DO2, DO2_DEFAULT_CRON, ::getM18Dos2)
}

/** Daily push FPSMS BOMs → M18; cron from settings [SettingNames.SCHEDULE_M18_BOM_SHOP] ([M18_BOM_SHOP_DEFAULT_CRON]); requires scheduler.m18Sync.enabled. */
fun scheduleM18BomShop() {
if (!m18SyncEnabled) {
scheduledM18BomShop?.cancel(false)
scheduledM18BomShop = null
logger.info("M18 BOM Shop scheduler disabled (scheduler.m18Sync.enabled=false)")
return
}
scheduledM18BomShop =
commonSchedule(scheduledM18BomShop, SettingNames.SCHEDULE_M18_BOM_SHOP, M18_BOM_SHOP_DEFAULT_CRON, ::getM18BomShopPushAllBoms)
}

fun scheduleM18MasterData() {
@@ -286,6 +520,42 @@ open class SchedulerService(
)
}

/**
* Job order plan-start batch at 00:00:15 daily (yesterday plan day).
* Set scheduler.jo.planStart.enabled=false to disable.
*/
fun scheduleJobOrderPlanStartAuto() {
if (!jobOrderPlanStartAutoEnabled) {
scheduledJobOrderPlanStart?.cancel(false)
scheduledJobOrderPlanStart = null
logger.info("Job order plan-start auto scheduler disabled (scheduler.jo.planStart.enabled=false)")
return
}
scheduledJobOrderPlanStart = commonSchedule(
scheduledJobOrderPlanStart,
SettingNames.SCHEDULE_JO_PLAN_START,
JO_PLAN_START_DEFAULT_CRON,
::runJobOrderPlanStartAuto,
)
logger.info("Scheduled job order plan-start auto (default cron={})", JO_PLAN_START_DEFAULT_CRON)
}

open fun runJobOrderPlanStartAuto() {
try {
val report = jobOrderPlanStartAutoService.runAutoProcess(LocalDateTime.now())
logger.info(
"Scheduler - Job order plan-start auto: candidates={}, hidden={}, rescheduled={}, skipped={}, errors={}",
report.candidates,
report.hidden,
report.rescheduled,
report.skipped,
report.errors,
)
} catch (e: Exception) {
logger.error("Scheduler - Job order plan-start auto failed: ${e.message}", e)
}
}

/** Mark expired inventory lot lines as unavailable daily. Set scheduler.inventoryLotExpiry.enabled=false to disable. */
fun scheduleInventoryLotExpiry() {
if (!inventoryLotExpiryEnabled) {
@@ -410,24 +680,42 @@ open class SchedulerService(
open fun getM18Dos1() {
logger.info("DO Scheduler 1 - DO")
val currentTime = LocalDateTime.now()
val today = currentTime.toLocalDate().atStartOfDay()
val twoDaysLater = today.plusDays(2L)

var requestDO = M18CommonRequest(
dDateTo = twoDaysLater.format(dateTimeStringFormat),
dDateFrom = twoDaysLater.format(dateTimeStringFormat)
)
val result = m18DeliveryOrderService.saveDeliveryOrders(requestDO);
val today = LocalDateTime.now().toLocalDate().atStartOfDay()
val dDate = today.plusDays(2L).toLocalDate()
getM18Dos1ForDDate(dDate, syncType = "DO1")
}

saveSyncLog(
type = "DO1",
status = "SUCCESS",
result = result,
start = currentTime
/** DO1 sync for an explicit delivery date (normal DO1 uses run-day + 2 days). */
open fun getM18Dos1ForDDate(
dDate: LocalDate,
syncType: String = "DO1",
skipExistingDo: Boolean = syncType == "DO1_CATCHUP",
) {
logger.info("{} sync for dDate={} skipExistingDo={}", syncType, dDate, skipExistingDo)
val currentTime = LocalDateTime.now()
val dDateStart = dDate.atStartOfDay()
val requestDO = M18CommonRequest(
dDateTo = dDateStart.format(dateTimeStringFormat),
dDateFrom = dDateStart.format(dateTimeStringFormat),
)
try {
val result = m18DeliveryOrderService.saveDeliveryOrders(requestDO, skipExistingDo = skipExistingDo)
saveSyncLog(
type = syncType,
status = "SUCCESS",
result = result?.copy(query = "dDate=$dDate ${result.query}".trim()),
start = currentTime,
)
} catch (e: Exception) {
logger.error("{} sync failed for dDate={}: {}", syncType, dDate, e.message, e)
saveSyncLog(
type = syncType,
status = "FAILED",
error = e.message,
start = currentTime,
)
throw e
}
}

private fun saveSyncLog(type: String, status: String, result: SyncResult? = null, error: String? = null, start: LocalDateTime) {
@@ -455,7 +743,7 @@ open class SchedulerService(
val ysd = today.minusDays(1L)
val tmr = today.plusDays(1L)

// Default: lastModified from yesterday 19:00 (aligns with nightly DO2 expectation).
// Default: lastModified from yesterday 19:00 through today's DO2 run hour (1pm; aligns with SCHEDULE.m18.do2).
// On Sunday, yesterday is Saturday: use 03:00 instead so we include DO changed after Sat 03:10 DO1
// (otherwise Sat 03:00–18:59 would be skipped until a much later sync).
val isSundayDo2 = runDate.dayOfWeek == DayOfWeek.SUNDAY
@@ -465,21 +753,21 @@ open class SchedulerService(
ysd.withHour(19).withMinute(0).withSecond(0)
}

// Set to 11:00:00 of today
val todayEleven = today.withHour(11).withMinute(0).withSecond(0)
val modifiedDateToEnd =
today.withHour(DO2_MODIFIED_TO_HOUR).withMinute(0).withSecond(0)

logger.info(
"DO2 modifiedDateFrom={} ({}), modifiedDateTo={}",
modifiedFromStart.format(dateTimeStringFormat),
if (isSundayDo2) "Sunday window from Sat 03:00" else "from yesterday 19:00",
todayEleven.format(dateTimeStringFormat),
modifiedDateToEnd.format(dateTimeStringFormat),
)

val requestDO = M18CommonRequest(
// These will now produce "yyyy-MM-dd HH:mm:ss"
dDateTo = tmr.format(dateTimeStringFormat), // e.g. 2026-01-19 00:00:00
dDateFrom = tmr.format(dateTimeStringFormat), // e.g. 2026-01-19 00:00:00
modifiedDateTo = todayEleven.format(dateTimeStringFormat), // 2026-01-18 11:00:00
modifiedDateTo = modifiedDateToEnd.format(dateTimeStringFormat),
modifiedDateFrom = modifiedFromStart.format(dateTimeStringFormat),
)
@@ -493,6 +781,44 @@ open class SchedulerService(
)
}

open fun getM18BomShopPushAllBoms() {
val currentTime = LocalDateTime.now()
try {
val summary = bomM18ShopBulkPushService.pushAllBomsToM18ShopIfAllowed()
val status = if (summary.skippedBecauseFeatureDisabled) "SKIPPED" else "SUCCESS"
saveSyncLog(
type = "M18_BOM_SHOP",
status = status,
result =
SyncResult(
totalProcessed = summary.totalProcessed,
totalSuccess = summary.synced,
totalFail = summary.notSynced,
query = summary.toLogQuery(),
),
start = currentTime,
)
if (summary.skippedBecauseFeatureDisabled) {
logger.debug(
"M18 BOM Shop bulk skipped ({}) — set {}={} to run pushes",
summary.toLogQuery(),
SettingNames.M18_BOM_SHOP_SYNC_ENABLED,
Settings.VALUE_BOOLEAN_TRUE,
)
} else {
logger.info("M18 BOM Shop batch done: {}", summary.toLogQuery())
}
} catch (e: Exception) {
logger.error("M18 BOM Shop batch failed: ${e.message}", e)
saveSyncLog(
type = "M18_BOM_SHOP",
status = "FAILED",
error = e.message,
start = currentTime,
)
}
}

open fun getPostCompletedDnAndProcessGrn(
receiptDate: java.time.LocalDate? = null,
skipFirst: Int = 0,


+ 55
- 0
src/main/java/com/ffii/fpsms/modules/common/scheduler/web/SchedulerController.kt View File

@@ -43,12 +43,35 @@ class SchedulerController(
return "M18 DO1 Sync Triggered Successfully"
}

/** Manual DO1 catch-up for a fixed dDate (production only). Skips existing local DOs by default. */
@GetMapping("/trigger/do1-catchup")
fun triggerDo1CatchUp(
@RequestParam @DateTimeFormat(iso = DateTimeFormat.ISO.DATE) dDate: LocalDate,
@RequestParam(required = false, defaultValue = "true") skipExistingDo: Boolean = true,
): String {
schedulerService.runDo1CatchUp(dDate, skipExistingDo = skipExistingDo)
return "M18 DO1 catch-up triggered for dDate=$dDate skipExistingDo=$skipExistingDo"
}

@GetMapping("/trigger/do2")
fun triggerDo2(): String {
schedulerService.getM18Dos2()
return "M18 DO2 Sync Triggered Successfully"
}

/** Manual test: push all FPSMS BOMs to M18 udfBomForShop ([SettingNames.M18_BOM_SHOP_SYNC_ENABLED] must still be true). */
@GetMapping("/trigger/bom-shop-sync-all")
fun triggerBomShopSyncAll(): String {
schedulerService.getM18BomShopPushAllBoms()
return "M18 BOM Shop (all BOMs) sync triggered (see scheduler_sync_log type M18_BOM_SHOP)"
}

@GetMapping("/updateSetting/bomShopCron")
fun scheduleBomShop(@RequestParam @Valid newCron: String) {
settingsService.update(SettingNames.SCHEDULE_M18_BOM_SHOP, newCron)
schedulerService.scheduleM18BomShop()
}

@GetMapping("/trigger/master-data")
fun triggerMasterData(): String {
schedulerService.getM18MasterData()
@@ -88,4 +111,36 @@ class SchedulerController(
schedulerService.init()
return "Cron Schedules Refreshed from Database"
}
@GetMapping("/trigger/jo-plan-start")
fun triggerJoPlanStart(): String {
schedulerService.runJobOrderPlanStartAuto()
return "Job order plan-start auto triggered"
}

/** Run M18 sync health checks now (same as periodic SMS watchdog). Returns alert messages (empty = OK). */
@GetMapping("/trigger/sync-alert-check")
fun triggerSyncAlertCheck(): List<String> {
return schedulerService.runSyncAlertCheckNow()
}

/**
* Send a Twilio WhatsApp template test (same API as Twilio console curl).
* Example: ?var1=12/1&var2=3pm — omit params to use today's date and current time.
*/
@GetMapping("/trigger/sync-alert-test-whatsapp")
fun triggerSyncAlertTestWhatsApp(
@RequestParam(required = false) var1: String?,
@RequestParam(required = false) var2: String?,
): String {
return schedulerService.sendSyncAlertTestWhatsApp(var1, var2)
}

/** Test Office 365 / SMTP email (uses MAIL.smtp.* from settings). Optional message and subject. */
@GetMapping("/trigger/sync-alert-test-email")
fun triggerSyncAlertTestEmail(
@RequestParam(required = false) message: String?,
@RequestParam(required = false) subject: String?,
): String {
return schedulerService.sendSyncAlertTestEmail(message, subject)
}
}

+ 4
- 0
src/main/java/com/ffii/fpsms/modules/deliveryOrder/entity/DeliveryOrder.kt View File

@@ -62,4 +62,8 @@ open class DeliveryOrder: BaseEntity<Long>() {

@Column(name = "m18BeId")
open var m18BeId: Long? = null

/** 加單:由 M18「加單」專用同步標記;一般 DO 為 false */
@Column(name = "isExtra", nullable = false)
open var isExtra: Boolean = false
}

+ 4
- 0
src/main/java/com/ffii/fpsms/modules/deliveryOrder/entity/DeliveryOrderPickOrder.kt View File

@@ -61,6 +61,10 @@ class DeliveryOrderPickOrder {
@Column(name = "cartonQty")
var cartonQty: Int? = null

/** Merge lineage: equals own [id] until soft-deleted into a successor [TI-M] header. */
@Column(name = "relationshipId")
var relationshipId: Long? = null

@CreationTimestamp
@Column(name = "created")
var created: LocalDateTime? = null


+ 6
- 0
src/main/java/com/ffii/fpsms/modules/deliveryOrder/entity/DeliveryOrderRepository.kt View File

@@ -15,6 +15,8 @@ import com.ffii.fpsms.modules.deliveryOrder.web.models.*
import com.ffii.fpsms.modules.deliveryOrder.entity.models.*
@Repository
interface DeliveryOrderRepository : AbstractRepository<DeliveryOrder, Long> {
fun existsByCodeAndDeletedIsFalse(code: String): Boolean

@Query("""
select d from DeliveryOrder d
where d.deleted = false
@@ -109,6 +111,7 @@ fun searchDoLite(
and (:status is null or d.status = :status)
and (:etaStart is null or d.estimatedArrivalDate >= :etaStart)
and (:etaEnd is null or d.estimatedArrivalDate < :etaEnd)
and (:isExtra is null or d.isExtra = :isExtra)
order by d.id desc
""")
fun searchDoLitePage(
@@ -117,6 +120,7 @@ fun searchDoLitePage(
@Param("status") status: DeliveryOrderStatus?,
@Param("etaStart") etaStart: LocalDateTime?,
@Param("etaEnd") etaEnd: LocalDateTime?,
@Param("isExtra") isExtra: Boolean?,
pageable: Pageable
): Page<DeliveryOrderInfoLite>

@@ -132,6 +136,7 @@ fun searchDoLitePage(
and (:status is null or d.status = :status)
and (:etaStart is null or d.estimatedArrivalDate >= :etaStart)
and (:etaEnd is null or d.estimatedArrivalDate < :etaEnd)
and (:isExtra is null or d.isExtra = :isExtra)
and d.supplier is not null
and d.supplier.code in :allowedSupplierCodes
order by d.id desc
@@ -143,6 +148,7 @@ fun searchDoLitePageWithSupplierCodes(
@Param("status") status: DeliveryOrderStatus?,
@Param("etaStart") etaStart: LocalDateTime?,
@Param("etaEnd") etaEnd: LocalDateTime?,
@Param("isExtra") isExtra: Boolean?,
@Param("allowedSupplierCodes") allowedSupplierCodes: List<String>,
pageable: Pageable,
): Page<DeliveryOrderInfoLite>


+ 2
- 0
src/main/java/com/ffii/fpsms/modules/deliveryOrder/entity/DoPickOrderRecordRepository.kt View File

@@ -16,6 +16,8 @@ import java.time.LocalDate
@Repository
interface DoPickOrderRecordRepository : JpaRepository<DoPickOrderRecord, Long> {
fun findByPickOrderId(pickOrderId: Long): List<DoPickOrderRecord>

fun findByDoOrderIdAndDeletedFalse(doOrderId: Long): List<DoPickOrderRecord>
fun findByTicketNoStartingWith(ticketPrefix: String): List<DoPickOrderRecord>

@Query("""


+ 2
- 0
src/main/java/com/ffii/fpsms/modules/deliveryOrder/entity/DoPickOrderRepository.kt View File

@@ -21,6 +21,8 @@ interface DoPickOrderRepository : JpaRepository<DoPickOrder, Long> {
): List<DoPickOrder>
fun findByPickOrderId(pickOrderId: Long): List<DoPickOrder>

fun findByDoOrderIdAndDeletedFalse(doOrderId: Long): List<DoPickOrder>

fun findByTicketStatusIn(statuses: List<DoPickOrderStatus>): List<DoPickOrder>
// 在 DoPickOrderRepository 中添加这个方法
fun findByHandledByAndTicketStatusIn(handledBy: Long, status: List<DoPickOrderStatus>): List<DoPickOrder>


+ 106
- 0
src/main/java/com/ffii/fpsms/modules/deliveryOrder/entity/DoReplenishment.kt View File

@@ -0,0 +1,106 @@
package com.ffii.fpsms.modules.deliveryOrder.entity

import com.ffii.core.entity.BaseEntity
import jakarta.persistence.Column
import jakarta.persistence.Entity
import jakarta.persistence.Table
import jakarta.validation.constraints.NotNull
import jakarta.validation.constraints.Size
import java.math.BigDecimal
import java.time.LocalDate

@Entity
@Table(name = "do_replenishment")
open class DoReplenishment : BaseEntity<Long>() {

@Size(max = 100)
@NotNull
@Column(name = "code", nullable = false, length = 100)
open var code: String? = null

@NotNull
@Column(name = "deliveryDate", nullable = false)
open var deliveryDate: LocalDate? = null

@NotNull
@Column(name = "sourceDoId", nullable = false)
open var sourceDoId: Long? = null

@Size(max = 100)
@Column(name = "sourceDoCode", length = 100)
open var sourceDoCode: String? = null

@NotNull
@Column(name = "sourceDoLineId", nullable = false)
open var sourceDoLineId: Long? = null

@NotNull
@Column(name = "sourceM18DataLogId", nullable = false)
open var sourceM18DataLogId: Long? = null

@NotNull
@Column(name = "sourceM18Id", nullable = false)
open var sourceM18Id: Long? = null

@NotNull
@Column(name = "itemId", nullable = false)
open var itemId: Long? = null

@Size(max = 100)
@Column(name = "itemNo", length = 100)
open var itemNo: String? = null

@Size(max = 255)
@Column(name = "itemName", length = 255)
open var itemName: String? = null

@NotNull
@Column(name = "replenishQty", nullable = false, precision = 14, scale = 2)
open var replenishQty: BigDecimal? = null

@Column(name = "uomId")
open var uomId: Long? = null

@Column(name = "shopId")
open var shopId: Long? = null

@Size(max = 50)
@Column(name = "shopCode", length = 50)
open var shopCode: String? = null

@Size(max = 255)
@Column(name = "shopName", length = 255)
open var shopName: String? = null

@Size(max = 100)
@Column(name = "truckLaneCode", length = 100)
open var truckLaneCode: String? = null

@Column(name = "targetDoId")
open var targetDoId: Long? = null

@Size(max = 100)
@Column(name = "targetDoCode", length = 100)
open var targetDoCode: String? = null

@Column(name = "pickOrderLineId")
open var pickOrderLineId: Long? = null

@Column(name = "deliveryOrderPickOrderId")
open var deliveryOrderPickOrderId: Long? = null

@NotNull
@Size(max = 20)
@Column(name = "status", nullable = false, length = 20)
open var status: String = STATUS_PENDING

@Size(max = 500)
@Column(name = "reason", length = 500)
open var reason: String? = null

companion object {
const val STATUS_PENDING = "pending"
const val STATUS_PROCESSING = "processing"
const val STATUS_COMPLETED = "completed"
}
}

+ 89
- 0
src/main/java/com/ffii/fpsms/modules/deliveryOrder/entity/DoReplenishmentRepository.kt View File

@@ -0,0 +1,89 @@
package com.ffii.fpsms.modules.deliveryOrder.entity

import com.ffii.core.support.AbstractRepository
import org.springframework.data.jpa.repository.Query
import org.springframework.data.repository.query.Param
import org.springframework.stereotype.Repository
import java.time.LocalDate

@Repository
interface DoReplenishmentRepository : AbstractRepository<DoReplenishment, Long> {

fun findByCodeAndDeletedIsFalse(code: String): DoReplenishment?

fun existsBySourceDoLineIdAndStatusAndDeletedIsFalse(sourceDoLineId: Long, status: String): Boolean

fun findFirstBySourceDoLineIdAndStatusAndDeletedIsFalse(
sourceDoLineId: Long,
status: String,
): DoReplenishment?

fun findByTargetDoIdInAndDeletedIsFalse(targetDoIds: Collection<Long>): List<DoReplenishment>

fun findFirstByPickOrderLineIdAndStatusAndDeletedIsFalse(
pickOrderLineId: Long,
status: String,
): DoReplenishment?

fun findByDeliveryOrderPickOrderIdAndStatusAndDeletedIsFalse(
deliveryOrderPickOrderId: Long,
status: String,
): List<DoReplenishment>

@Query(
"""
SELECT r FROM DoReplenishment r
LEFT JOIN DeliveryOrderPickOrder dopo ON dopo.id = r.deliveryOrderPickOrderId AND dopo.deleted = false
WHERE r.deleted = false
AND (
:deliveryDate IS NULL
OR (
dopo.id IS NOT NULL
AND dopo.requiredDeliveryDate = :deliveryDate
)
OR (
dopo.id IS NULL
AND r.deliveryDate = :deliveryDate
)
)
AND (:status IS NULL OR r.status = :status)
ORDER BY r.created DESC, r.id DESC
""",
)
fun search(
@Param("deliveryDate") deliveryDate: LocalDate?,
@Param("status") status: String?,
): List<DoReplenishment>

@Query(
"""
SELECT r FROM DoReplenishment r
WHERE r.deleted = false
AND r.status = :status
AND (
:truckLaneCode IS NULL OR :truckLaneCode = ''
OR LOWER(COALESCE(r.truckLaneCode, '')) LIKE LOWER(CONCAT('%', :truckLaneCode, '%'))
)
AND (
:shopName IS NULL OR :shopName = ''
OR LOWER(COALESCE(r.shopName, '')) LIKE LOWER(CONCAT('%', :shopName, '%'))
OR LOWER(COALESCE(r.shopCode, '')) LIKE LOWER(CONCAT('%', :shopName, '%'))
)
ORDER BY r.shopName, r.shopCode, r.code
""",
)
fun searchForBatchRelease(
@Param("status") status: String,
@Param("truckLaneCode") truckLaneCode: String?,
@Param("shopName") shopName: String?,
): List<DoReplenishment>

@Query(
"""
SELECT r.code FROM DoReplenishment r
WHERE r.deleted = false
AND r.code LIKE CONCAT(:codePrefix, '%')
""",
)
fun findCodesByPrefix(@Param("codePrefix") codePrefix: String): List<String>
}

+ 5
- 1
src/main/java/com/ffii/fpsms/modules/deliveryOrder/entity/models/DeliveryOrderInfo.kt View File

@@ -47,6 +47,9 @@ interface DeliveryOrderInfoLite {
val supplierCode: String?
@get:Value("#{target.shop?.addr3}")
val shopAddress: String?

@get:Value("#{target.isExtra}")
val isExtra: Boolean
}
data class DeliveryOrderInfoLiteDto(
val id: Long,
@@ -57,5 +60,6 @@ data class DeliveryOrderInfoLiteDto(
val shopName: String?,
val supplierName: String?,
val shopAddress: String?,
val truckLanceCode: String?
val truckLanceCode: String?,
val isExtra: Boolean = false,
)

+ 2
- 0
src/main/java/com/ffii/fpsms/modules/deliveryOrder/entity/models/DeliveryOrderLineInfo.kt View File

@@ -17,4 +17,6 @@ interface DeliveryOrderLineInfo {

@get:Value("#{target.item?.id}")
val itemId: Long?
@get:Value("#{target.deleted}")
val deleted: Boolean?
}

+ 767
- 278
src/main/java/com/ffii/fpsms/modules/deliveryOrder/service/DeliveryOrderService.kt
File diff suppressed because it is too large
View File


+ 117
- 0
src/main/java/com/ffii/fpsms/modules/deliveryOrder/service/DoFloorSupplierSettingsService.kt View File

@@ -0,0 +1,117 @@
package com.ffii.fpsms.modules.deliveryOrder.service

import com.ffii.fpsms.modules.settings.entity.SettingsRepository
import org.springframework.stereotype.Service
import java.util.Locale

/** 供 DO 搜索/車線/報表 SQL 等共用的 2F/4F 供應商代碼(來自 `settings` CSV)。 */
@Service
open class DoFloorSupplierSettingsService(
private val settingsRepository: SettingsRepository,
) {
companion object {
private const val SETTING_DO_FLOOR_SUPPLIERS_2F = "DO.floor.suppliers.2F"
private const val SETTING_DO_FLOOR_SUPPLIERS_4F = "DO.floor.suppliers.4F"

private val DEFAULT_SUPPLIERS_2F = listOf("P07", "P06D", "P06Y")
private val DEFAULT_SUPPLIERS_4F = listOf("P06B")
}

open fun supplierCodesFromSetting(settingName: String, defaultList: List<String>): List<String> {
val raw = settingsRepository.findByName(settingName).map { it.value }.orElse(null)
?.trim()
.orEmpty()
if (raw.isEmpty()) return defaultList
val parsed = raw.split(",").map { it.trim() }.filter { it.isNotEmpty() }.distinct()
return parsed.ifEmpty { defaultList }
}

open fun loadDoFloorSupplierLists(): Pair<List<String>, List<String>> {
val suppliers2F = supplierCodesFromSetting(SETTING_DO_FLOOR_SUPPLIERS_2F, DEFAULT_SUPPLIERS_2F)
val suppliers4F = supplierCodesFromSetting(SETTING_DO_FLOOR_SUPPLIERS_4F, DEFAULT_SUPPLIERS_4F)
return suppliers2F to suppliers4F
}

open fun allowedSupplierCodesForFloor(floor: String?): List<String> {
val f = floor?.trim()?.uppercase(Locale.ROOT).orEmpty()
val (codes2F, codes4F) = loadDoFloorSupplierLists()
return when {
f.isEmpty() || f == "ALL" || f == "All" -> (codes2F + codes4F).distinct()
f == "2F" -> codes2F
f == "4F" -> codes4F
else -> (codes2F + codes4F).distinct()
}
}

/** 4F 清單優先;其餘預設 2F(與既有 DO 車線邏輯一致)。 */
open fun preferredStoreFloorForSupplier(
supplierCode: String?,
suppliers2F: List<String>,
suppliers4F: List<String>,
): String {
val code = supplierCode?.trim().orEmpty()
if (code.isEmpty()) return "2F"
if (suppliers4F.contains(code)) return "4F"
if (suppliers2F.contains(code)) return "2F"
return "2F"
}

/** DO 揀貨建議:名單外供應商不限制 2F/4F。 */
open fun preferredFloorForPickLotOrNull(
supplierCode: String?,
suppliers2F: List<String>,
suppliers4F: List<String>,
): String? {
val code = supplierCode?.trim().orEmpty()
if (code.isEmpty()) return null
if (suppliers4F.contains(code)) return "4F"
if (suppliers2F.contains(code)) return "2F"
return null
}

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

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

/**
* 依目前 settings 產生原生 SQL CASE(供 JDBC 字串拼接)。
* @param codeExpr 已加別名的欄位,如 `s.code`、`supplier.code`
*/
open fun sqlPreferredFloorCases(codeExpr: String = "s.code"): SqlPreferredFloorCases {
val (s2f, s4f) = loadDoFloorSupplierLists()
val in4 = joinSqlInList(s4f)
val in2 = joinSqlInList(s2f)
val floor =
"CASE WHEN $codeExpr IN ($in4) THEN '4F' WHEN $codeExpr IN ($in2) THEN '2F' ELSE NULL END"
val storeId =
"CASE WHEN $codeExpr IN ($in4) THEN 4 WHEN $codeExpr IN ($in2) THEN 2 ELSE NULL END"
return SqlPreferredFloorCases(floorStringCase = floor, storeIdNumericCase = storeId)
}

private fun joinSqlInList(codes: List<String>): String =
codes.joinToString(", ") { "'" + it.replace("'", "''") + "'" }
}

+ 80
- 129
src/main/java/com/ffii/fpsms/modules/deliveryOrder/service/DoPickOrderService.kt View File

@@ -847,139 +847,90 @@ open class DoPickOrderService(
* Groups DoPickOrder and DoPickOrderRecord data to provide summary statistics.
*/
open fun getTruckScheduleDashboard(targetDate: LocalDate): List<TruckScheduleDashboardResponse> {
// Fetch all active DoPickOrders for the target date
val doPickOrders = doPickOrderRepository.findByStoreIdAndRequiredDeliveryDateAndTicketStatusIn(
"2/F", targetDate, listOf(DoPickOrderStatus.pending, DoPickOrderStatus.released, DoPickOrderStatus.completed)
) + doPickOrderRepository.findByStoreIdAndRequiredDeliveryDateAndTicketStatusIn(
"4/F", targetDate, listOf(DoPickOrderStatus.pending, DoPickOrderStatus.released, DoPickOrderStatus.completed)
)
// Fetch all DoPickOrderRecords for the target date (completed records)
val doPickOrderRecords = doPickOrderRecordRepository.findByStoreIdAndRequiredDeliveryDateAndTicketStatusIn(
"2/F", targetDate, listOf(DoPickOrderStatus.completed)
) + doPickOrderRecordRepository.findByStoreIdAndRequiredDeliveryDateAndTicketStatusIn(
"4/F", targetDate, listOf(DoPickOrderStatus.completed)
)
// Combine both types into a unified data structure for aggregation
data class TicketData(
val storeId: String?,
val truckId: Long?,
val truckLanceCode: String?,
val truckDepartureTime: java.time.LocalTime?,
val shopId: Long?,
val shopCode: String?,
val ticketNo: String?,
val ticketReleaseTime: LocalDateTime?,
val ticketCompleteDateTime: LocalDateTime?,
val ticketStatus: DoPickOrderStatus?,
val doPickOrderId: Long?,
val isRecord: Boolean
)
val allTickets = mutableListOf<TicketData>()
doPickOrders.forEach { dpo ->
allTickets.add(TicketData(
storeId = dpo.storeId,
truckId = dpo.truckId,
truckLanceCode = dpo.truckLanceCode,
truckDepartureTime = dpo.truckDepartureTime,
shopId = dpo.shopId,
shopCode = dpo.shopCode,
ticketNo = dpo.ticketNo,
ticketReleaseTime = dpo.ticketReleaseTime,
ticketCompleteDateTime = dpo.ticketCompleteDateTime,
ticketStatus = dpo.ticketStatus,
doPickOrderId = dpo.id,
isRecord = false
))
}
doPickOrderRecords.forEach { record ->
allTickets.add(TicketData(
storeId = record.storeId,
truckId = record.truckId,
truckLanceCode = record.truckLanceCode,
truckDepartureTime = record.truckDepartureTime,
shopId = record.shopId,
shopCode = record.shopCode,
ticketNo = record.ticketNo,
ticketReleaseTime = record.ticketReleaseTime,
ticketCompleteDateTime = record.ticketCompleteDateTime,
ticketStatus = record.ticketStatus,
doPickOrderId = record.recordId,
isRecord = true
))
}
// Group by storeId, truckLanceCode, truckDepartureTime
val grouped = allTickets.groupBy {
Triple(it.storeId, it.truckLanceCode, it.truckDepartureTime)
}
return grouped.map { (key, tickets) ->
val (storeId, truckLanceCode, truckDepartureTime) = key
// Count distinct shops
val distinctShops = tickets.mapNotNull { it.shopId ?: it.shopCode?.hashCode()?.toLong() }.distinct().size
// Count distinct tickets
val distinctTickets = tickets.mapNotNull { it.ticketNo }.distinct().size
// Calculate total items to pick
var totalItems = 0
tickets.forEach { ticket ->
if (ticket.doPickOrderId != null) {
if (ticket.isRecord) {
totalItems += countFGItemsFromRecordById(ticket.doPickOrderId)
} else {
totalItems += countFGItemsById(ticket.doPickOrderId)
}
}
// Source of truth: delivery_order_pick_order (+ linked pick_order / pick_order_line)
//
// NOTE: delivery_order_pick_order 沒有 truckId 欄位;dashboard 的 truckId 目前僅作為展示/鍵值用途,
// 回傳 null 讓前端保持相容即可。
val sql = """
SELECT
dop.storeId AS storeId,
dop.truckLanceCode AS truckLanceCode,
dop.truckDepartureTime AS truckDepartureTime,
COUNT(DISTINCT dop.shopCode) AS numberOfShopsToServe,
COUNT(DISTINCT dop.ticketNo) AS numberOfPickTickets,
COALESCE(SUM(pol_cnt.cnt), 0) AS totalItemsToPick,
SUM(CASE WHEN dop.ticketReleaseTime IS NOT NULL THEN 1 ELSE 0 END) AS numberOfTicketsReleased,
MIN(dop.ticketReleaseTime) AS firstTicketStartTime,
SUM(CASE WHEN dop.ticketCompleteDateTime IS NOT NULL THEN 1 ELSE 0 END) AS numberOfTicketsCompleted,
MAX(dop.ticketCompleteDateTime) AS lastTicketEndTime
FROM fpsmsdb.delivery_order_pick_order dop
LEFT JOIN (
SELECT
po.deliveryOrderPickOrderId AS dopId,
COUNT(pol.id) AS cnt
FROM fpsmsdb.pick_order po
INNER JOIN fpsmsdb.pick_order_line pol
ON pol.poId = po.id
AND pol.deleted = 0
WHERE po.deleted = 0
AND po.deliveryOrderPickOrderId IS NOT NULL
GROUP BY po.deliveryOrderPickOrderId
) pol_cnt
ON pol_cnt.dopId = dop.id
WHERE dop.deleted = 0
AND dop.requiredDeliveryDate = :targetDate
AND dop.ticketStatus IN ('pending', 'released', 'completed')
GROUP BY dop.storeId, dop.truckLanceCode, dop.truckDepartureTime
ORDER BY dop.storeId, dop.truckDepartureTime
""".trimIndent()

val rows = jdbcDao.queryForList(sql, mapOf("targetDate" to targetDate))

fun str(row: Map<String, Any?>, key: String): String? = row[key]?.toString()
fun intVal(row: Map<String, Any?>, key: String): Int =
when (val v = row[key]) {
null -> 0
is Number -> v.toInt()
else -> v.toString().toBigDecimalOrNull()?.toInt() ?: 0
}
// Count released tickets (ticketReleaseTime is not null)
val releasedTickets = tickets.count { it.ticketReleaseTime != null }
// Find first ticket start time (earliest ticketReleaseTime)
val firstTicketStartTime = tickets
.mapNotNull { it.ticketReleaseTime }
.minOrNull()
// Count completed tickets (ticketCompleteDateTime is not null)
val completedTickets = tickets.count { it.ticketCompleteDateTime != null }
// Find last ticket end time (latest ticketCompleteDateTime)
val lastTicketEndTime = tickets
.mapNotNull { it.ticketCompleteDateTime }
.maxOrNull()
// Calculate pick time taken in minutes
val pickTimeTakenMinutes = if (firstTicketStartTime != null && lastTicketEndTime != null) {
ChronoUnit.MINUTES.between(firstTicketStartTime, lastTicketEndTime)
} else {
null
fun timeVal(row: Map<String, Any?>, key: String): java.time.LocalTime? =
when (val v = row[key]) {
null -> null
is java.time.LocalTime -> v
is java.sql.Time -> v.toLocalTime()
is java.time.OffsetTime -> v.toLocalTime()
is String -> runCatching { java.time.LocalTime.parse(v) }.getOrNull()
else -> null
}
// Get truck ID (use first non-null)
val truckId = tickets.firstOrNull { it.truckId != null }?.truckId
fun dtVal(row: Map<String, Any?>, key: String): LocalDateTime? =
when (val v = row[key]) {
null -> null
is LocalDateTime -> v
is java.sql.Timestamp -> v.toLocalDateTime()
is String -> runCatching { LocalDateTime.parse(v) }.getOrNull()
else -> null
}

return rows.map { row ->
val first = dtVal(row, "firstTicketStartTime")
val last = dtVal(row, "lastTicketEndTime")
val minutes = if (first != null && last != null) ChronoUnit.MINUTES.between(first, last) else null

TruckScheduleDashboardResponse(
storeId = storeId,
truckId = truckId,
truckLanceCode = truckLanceCode,
truckDepartureTime = truckDepartureTime,
numberOfShopsToServe = distinctShops,
numberOfPickTickets = distinctTickets,
totalItemsToPick = totalItems,
numberOfTicketsReleased = releasedTickets,
firstTicketStartTime = firstTicketStartTime,
numberOfTicketsCompleted = completedTickets,
lastTicketEndTime = lastTicketEndTime,
pickTimeTakenMinutes = pickTimeTakenMinutes
storeId = str(row, "storeId"),
truckId = null,
truckLanceCode = str(row, "truckLanceCode"),
truckDepartureTime = timeVal(row, "truckDepartureTime"),
numberOfShopsToServe = intVal(row, "numberOfShopsToServe"),
numberOfPickTickets = intVal(row, "numberOfPickTickets"),
totalItemsToPick = intVal(row, "totalItemsToPick"),
numberOfTicketsReleased = intVal(row, "numberOfTicketsReleased"),
firstTicketStartTime = first,
numberOfTicketsCompleted = intVal(row, "numberOfTicketsCompleted"),
lastTicketEndTime = last,
pickTimeTakenMinutes = minutes,
)
}.sortedWith(compareBy({ it.storeId }, { it.truckDepartureTime }))
}
}
private fun countFGItemsById(doPickOrderId: Long): Int {


+ 41
- 23
src/main/java/com/ffii/fpsms/modules/deliveryOrder/service/DoReleaseCoordinatorService.kt View File

@@ -103,6 +103,7 @@ class DoReleaseCoordinatorService(
private val userRepository: UserRepository,
private val pickOrderRepository: PickOrderRepository,
private val doPickOrderRecordRepository: DoPickOrderRecordRepository,
private val doFloorSupplierSettingsService: DoFloorSupplierSettingsService,
) {
private val poolSize = Runtime.getRuntime().availableProcessors()
private val executor = Executors.newFixedThreadPool(min(poolSize, 4))
@@ -140,22 +141,15 @@ class DoReleaseCoordinatorService(
private fun updateBatchTicketNumbers() {
try {
val dayOfWeekSql = getDayOfWeekAbbrSql("do.estimatedArrivalDate")
val pfCases = doFloorSupplierSettingsService.sqlPreferredFloorCases("s.code")
val updateSql = """
UPDATE fpsmsdb.do_pick_order dpo
INNER JOIN (
WITH PreferredFloor AS (
SELECT
do.id AS deliveryOrderId,
CASE
WHEN s.code = 'P06B' THEN '4F'
WHEN s.code = 'P07' OR s.code = 'P06D' THEN '2F'
ELSE NULL
END AS preferred_floor,
CASE
WHEN s.code = 'P06B' THEN 4
WHEN s.code = 'P07' OR s.code = 'P06D' THEN 2
ELSE NULL
END AS preferred_store_id
${pfCases.floorStringCase} AS preferred_floor,
${pfCases.storeIdNumericCase} AS preferred_store_id
FROM fpsmsdb.delivery_order do
LEFT JOIN fpsmsdb.shop s ON s.id = do.supplierId AND s.deleted = 0
WHERE do.deleted = 0
@@ -307,20 +301,13 @@ class DoReleaseCoordinatorService(
println(" DEBUG: Getting ordered IDs for ${ids.size} orders")
println(" DEBUG: First 5 IDs: ${ids.take(5)}")
val dayOfWeekSql = getDayOfWeekAbbrSql("do.estimatedArrivalDate")
val pfCases = doFloorSupplierSettingsService.sqlPreferredFloorCases("s.code")
val sql = """
WITH PreferredFloor AS (
SELECT
do.id AS deliveryOrderId,
CASE
WHEN s.code = 'P06B' THEN '4F'
WHEN s.code = 'P07' OR s.code = 'P06D' THEN '2F'
ELSE NULL
END AS preferred_floor,
CASE
WHEN s.code = 'P06B' THEN 4
WHEN s.code = 'P07' OR s.code = 'P06D' THEN 2
ELSE NULL
END AS preferred_store_id
${pfCases.floorStringCase} AS preferred_floor,
${pfCases.storeIdNumericCase} AS preferred_store_id
FROM fpsmsdb.delivery_order do
LEFT JOIN fpsmsdb.shop s ON s.id = do.supplierId AND s.deleted = 0
WHERE do.id IN (${ids.joinToString(",")})
@@ -719,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} " +
@@ -767,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 数量
@@ -871,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) {


+ 583
- 0
src/main/java/com/ffii/fpsms/modules/deliveryOrder/service/DoReplenishmentService.kt View File

@@ -0,0 +1,583 @@
package com.ffii.fpsms.modules.deliveryOrder.service

import com.ffii.core.support.JdbcDao
import com.ffii.fpsms.modules.deliveryOrder.entity.DoPickOrderLineRepository
import com.ffii.fpsms.modules.deliveryOrder.entity.DoPickOrderRecordRepository
import com.ffii.fpsms.modules.deliveryOrder.entity.DoPickOrderRepository
import com.ffii.fpsms.modules.deliveryOrder.entity.DoReplenishment
import com.ffii.fpsms.modules.deliveryOrder.entity.DoReplenishmentRepository
import com.ffii.fpsms.modules.deliveryOrder.entity.DeliveryOrder
import com.ffii.fpsms.modules.deliveryOrder.entity.DeliveryOrderLineRepository
import com.ffii.fpsms.modules.deliveryOrder.entity.DeliveryOrderPickOrderRepository
import com.ffii.fpsms.modules.deliveryOrder.entity.DeliveryOrderRepository
import com.ffii.fpsms.modules.deliveryOrder.enums.DeliveryOrderStatus
import com.ffii.fpsms.modules.deliveryOrder.web.models.DoReplenishmentResponse
import com.ffii.fpsms.modules.deliveryOrder.web.models.ReleaseDoResult
import com.ffii.fpsms.modules.deliveryOrder.web.models.SubmitDoReplenishmentLineRequest
import com.ffii.fpsms.modules.deliveryOrder.web.models.SubmitDoReplenishmentRequest
import com.ffii.fpsms.modules.master.entity.ItemsRepository
import com.ffii.fpsms.modules.master.entity.UomConversionRepository
import com.ffii.fpsms.modules.pickOrder.entity.PickOrderLine
import com.ffii.fpsms.modules.pickOrder.entity.PickOrderLineRepository
import com.ffii.fpsms.modules.pickOrder.entity.PickOrderRepository
import com.ffii.fpsms.modules.stock.entity.StockOutLIneRepository
import com.ffii.fpsms.modules.stock.service.WorkbenchStockOutLinePickProgress
import com.ffii.fpsms.modules.pickOrder.enums.PickOrderLineStatus
import org.springframework.stereotype.Service
import org.springframework.transaction.annotation.Transactional
import java.math.BigDecimal
import java.time.LocalDate
import java.time.format.DateTimeFormatter

@Service
open class DoReplenishmentService(
private val doReplenishmentRepository: DoReplenishmentRepository,
private val deliveryOrderRepository: DeliveryOrderRepository,
private val deliveryOrderPickOrderRepository: DeliveryOrderPickOrderRepository,
private val deliveryOrderLineRepository: DeliveryOrderLineRepository,
private val doPickOrderRepository: DoPickOrderRepository,
private val doPickOrderLineRepository: DoPickOrderLineRepository,
private val doPickOrderRecordRepository: DoPickOrderRecordRepository,
private val stockOutLIneRepository: StockOutLIneRepository,
private val uomConversionRepository: UomConversionRepository,
private val pickOrderRepository: PickOrderRepository,
private val pickOrderLineRepository: PickOrderLineRepository,
private val itemsRepository: ItemsRepository,
private val jdbcDao: JdbcDao,
) {

@Transactional
open fun submit(request: SubmitDoReplenishmentRequest): List<DoReplenishmentResponse> {
if (request.lines.isEmpty()) {
throw IllegalArgumentException("No replenishment lines to submit")
}
val nextSeqByDate = mutableMapOf<LocalDate, Int>()
val created = mutableListOf<DoReplenishment>()
val mergedLines = mergeSubmitLines(request.lines)

for (lineReq in mergedLines) {
val deliveryOrder = deliveryOrderRepository.findByIdAndDeletedIsFalse(lineReq.sourceDoId)
?: throw IllegalArgumentException("Source delivery order not found: ${lineReq.sourceDoId}")
if (deliveryOrder.status != DeliveryOrderStatus.COMPLETED) {
throw IllegalArgumentException("Source delivery order must be completed: ${deliveryOrder.code}")
}

val doLine = deliveryOrderLineRepository.findById(lineReq.sourceDoLineId).orElse(null)
?: throw IllegalArgumentException("Source delivery order line not found: ${lineReq.sourceDoLineId}")
if (doLine.deleted == true || doLine.deliveryOrder?.id != lineReq.sourceDoId) {
throw IllegalArgumentException("Source line does not belong to delivery order ${lineReq.sourceDoId}")
}

val existingPending = doReplenishmentRepository.findFirstBySourceDoLineIdAndStatusAndDeletedIsFalse(
lineReq.sourceDoLineId,
DoReplenishment.STATUS_PENDING,
)
if (existingPending != null) {
existingPending.replenishQty =
(existingPending.replenishQty ?: BigDecimal.ZERO).add(lineReq.replenishQty)
if (existingPending.truckLaneCode.isNullOrBlank()) {
existingPending.truckLaneCode =
resolveSourceDoTruckLaneCode(deliveryOrder, lineReq.truckLaneCode)
}
lineReq.reason?.trim()?.takeIf { it.isNotEmpty() }?.let { existingPending.reason = it }
created += doReplenishmentRepository.save(existingPending)
continue
}

val m18DataLog = doLine.m18DataLog
?: throw IllegalArgumentException("Source line missing M18 data log")
val m18Id = m18DataLog.m18Id
?: throw IllegalArgumentException("Source line missing M18 id")
val item = doLine.item
?: throw IllegalArgumentException("Source line missing item")

val seq = nextSeqByDate.getOrPut(lineReq.deliveryDate) {
nextCodeSequence(lineReq.deliveryDate)
}
nextSeqByDate[lineReq.deliveryDate] = seq + 1
val code = formatReplenishmentCode(lineReq.deliveryDate, seq)

val shop = deliveryOrder.shop
val entity = DoReplenishment().apply {
this.code = code
deliveryDate = lineReq.deliveryDate
sourceDoId = lineReq.sourceDoId
sourceDoCode = deliveryOrder.code
sourceDoLineId = lineReq.sourceDoLineId
sourceM18DataLogId = m18DataLog.id
sourceM18Id = m18Id
itemId = item.id
itemNo = doLine.itemNo ?: item.code
itemName = item.name
replenishQty = lineReq.replenishQty
uomId = doLine.uom?.id
shopId = shop?.id
shopCode = shop?.code
shopName = shop?.name
truckLaneCode = resolveSourceDoTruckLaneCode(deliveryOrder, lineReq.truckLaneCode)
status = DoReplenishment.STATUS_PENDING
reason = lineReq.reason?.trim()?.takeIf { it.isNotEmpty() }
}
created += doReplenishmentRepository.save(entity)
}

return toResponses(created)
}

open fun list(deliveryDate: LocalDate?, status: String?): List<DoReplenishmentResponse> {
val normalizedStatus = status?.trim()?.takeIf { it.isNotEmpty() && it != "all" }
val rows = doReplenishmentRepository.search(deliveryDate, normalizedStatus)
return toResponses(rows)
}

open fun listForBatchRelease(
truckLaneCode: String?,
shopName: String?,
): List<DoReplenishmentResponse> {
val truck = truckLaneCode?.trim()?.takeIf { it.isNotEmpty() }
val shop = shopName?.trim()?.takeIf { it.isNotEmpty() }
if (truck == null && shop == null) {
return emptyList()
}
val rows = doReplenishmentRepository.searchForBatchRelease(
status = DoReplenishment.STATUS_PENDING,
truckLaneCode = truck,
shopName = shop,
)
return toResponses(rows)
}

open fun findReplenishmentsByTargetDoIds(targetDoIds: Collection<Long>): List<DoReplenishment> {
if (targetDoIds.isEmpty()) return emptyList()
return doReplenishmentRepository.findByTargetDoIdInAndDeletedIsFalse(targetDoIds)
}

data class ReplenishPdfIndex(
private val targetDoItemKeys: Set<Pair<Long, Long>>,
private val pickOrderLineIds: Set<Long>,
) {
fun matches(deliveryOrderId: Long, itemId: Long?, pickOrderLineId: Long?): Boolean {
if (pickOrderLineId != null && pickOrderLineId in pickOrderLineIds) return true
val resolvedItemId = itemId ?: return false
return deliveryOrderId to resolvedItemId in targetDoItemKeys
}

companion object {
val EMPTY = ReplenishPdfIndex(emptySet(), emptySet())
}
}

open fun buildReplenishPdfIndex(deliveryOrderIds: Collection<Long>): ReplenishPdfIndex {
if (deliveryOrderIds.isEmpty()) return ReplenishPdfIndex.EMPTY
val records = doReplenishmentRepository.findByTargetDoIdInAndDeletedIsFalse(deliveryOrderIds)
.filter { it.pickOrderLineId != null }
if (records.isEmpty()) return ReplenishPdfIndex.EMPTY
return ReplenishPdfIndex(
targetDoItemKeys = records.mapNotNull { row ->
val targetDoId = row.targetDoId
val itemId = row.itemId
if (targetDoId != null && itemId != null) targetDoId to itemId else null
}.toSet(),
pickOrderLineIds = records.mapNotNull { it.pickOrderLineId }.toSet(),
)
}

/** Replenishment POL rows with no matching DOL on the target DO — append as extra DN lines. */
open fun replenishmentsWithoutDeliveryOrderLine(
deliveryOrderIds: Collection<Long>,
exportLines: List<DeliveryOrderService.DeliveryNoteExportLine>,
): List<DoReplenishment> {
if (deliveryOrderIds.isEmpty()) return emptyList()
val dolItemKeys = exportLines.mapNotNull { row ->
row.line.itemId?.let { row.deliveryOrderId to it }
}.toSet()
return doReplenishmentRepository.findByTargetDoIdInAndDeletedIsFalse(deliveryOrderIds)
.filter { replenishment ->
val polId = replenishment.pickOrderLineId ?: return@filter false
val targetDoId = replenishment.targetDoId ?: return@filter false
val itemId = replenishment.itemId ?: return@filter false
polId > 0 && (targetDoId to itemId) !in dolItemKeys
}
}

@Transactional
open fun completeByPickOrderLineId(pickOrderLineId: Long) {
val row = doReplenishmentRepository.findFirstByPickOrderLineIdAndStatusAndDeletedIsFalse(
pickOrderLineId,
DoReplenishment.STATUS_PROCESSING,
) ?: return
row.status = DoReplenishment.STATUS_COMPLETED
doReplenishmentRepository.save(row)
}

@Transactional
open fun completeProcessingByDeliveryOrderPickOrderId(deliveryOrderPickOrderId: Long) {
if (deliveryOrderPickOrderId <= 0L) return
val rows = doReplenishmentRepository.findByDeliveryOrderPickOrderIdAndStatusAndDeletedIsFalse(
deliveryOrderPickOrderId,
DoReplenishment.STATUS_PROCESSING,
)
for (row in rows) {
row.pickOrderLineId?.let { completeByPickOrderLineId(it) }
}
}

/**
* After workbench batch release links pick orders to a ticket, create replenishment POL rows
* (no DOL), assign target DO / ticket FKs, and move status pending → processing.
*
* @return pick order ids that received new replenishment lines (for V1 downstream rebuild)
*/
@Transactional(rollbackFor = [Exception::class])
open fun releasePendingReplenishmentsForWorkbenchBatch(
releasedResults: List<ReleaseDoResult>,
): Set<Long> {
if (releasedResults.isEmpty()) return emptySet()

val pending = findPendingReplenishmentsForReleasedResults(releasedResults)
if (pending.isEmpty()) return emptySet()

val affectedPickOrderIds = mutableSetOf<Long>()
for (replenishment in pending) {
val matchingResults = releasedResults.filter { replenishmentMatchesResult(replenishment, it) }
if (matchingResults.isEmpty()) continue

val targetResult = matchingResults.minByOrNull { it.deliveryOrderId }
?: continue
val dopoId = resolveDeliveryOrderPickOrderId(targetResult.pickOrderId)
?: continue
val ticketPickOrderIds = pickOrderRepository.findIdsByDeliveryOrderPickOrderId(dopoId)
if (ticketPickOrderIds.isEmpty()) continue

val itemId = replenishment.itemId ?: continue
val targetPickOrderId = resolveTargetPickOrderId(ticketPickOrderIds, itemId)
val pickOrder = pickOrderRepository.findById(targetPickOrderId).orElse(null) ?: continue
val item = itemsRepository.findById(itemId).orElse(null) ?: continue
val uom = replenishment.uomId?.let { uomConversionRepository.findById(it).orElse(null) }
?: continue

val pol = PickOrderLine().apply {
this.pickOrder = pickOrder
this.item = item
this.qty = replenishment.replenishQty
this.uom = uom
this.status = PickOrderLineStatus.PENDING
}
val savedPol = pickOrderLineRepository.save(pol)

pickOrder.totalLines = (pickOrder.totalLines ?: 0) + 1
pickOrderRepository.save(pickOrder)

replenishment.targetDoId = targetResult.deliveryOrderId
replenishment.targetDoCode = targetResult.deliveryOrderCode
replenishment.pickOrderLineId = savedPol.id
replenishment.deliveryOrderPickOrderId = dopoId
replenishment.status = DoReplenishment.STATUS_PROCESSING
doReplenishmentRepository.save(replenishment)

affectedPickOrderIds += targetPickOrderId
}

return affectedPickOrderIds
}

private fun findPendingReplenishmentsForReleasedResults(
releasedResults: List<ReleaseDoResult>,
): List<DoReplenishment> {
val pairs = releasedResults
.map { result ->
(result.shopName?.trim()?.takeIf { it.isNotEmpty() }
?: result.shopCode?.trim()?.takeIf { it.isNotEmpty() }
?: "") to (result.truckLanceCode?.trim()?.takeIf { it.isNotEmpty() } ?: "")
}
.distinct()
.filter { (shop, truck) -> shop.isNotEmpty() || truck.isNotEmpty() }

if (pairs.isEmpty()) return emptyList()

val byId = linkedMapOf<Long, DoReplenishment>()
for ((shop, truck) in pairs) {
val rows = doReplenishmentRepository.searchForBatchRelease(
status = DoReplenishment.STATUS_PENDING,
truckLaneCode = truck.takeIf { it.isNotEmpty() },
shopName = shop.takeIf { it.isNotEmpty() },
)
for (row in rows) {
val id = row.id ?: continue
if (releasedResults.any { replenishmentMatchesResult(row, it) }) {
byId[id] = row
}
}
}
return byId.values.toList()
}

private fun replenishmentMatchesResult(
replenishment: DoReplenishment,
result: ReleaseDoResult,
): Boolean {
val doTruck = normalizeText(result.truckLanceCode)
val recordTruck = normalizeText(replenishment.truckLaneCode)
if (doTruck.isNotEmpty()) {
if (recordTruck.isEmpty() || recordTruck != doTruck) return false
}

val doShopToken = shopTokenFromResult(result)
if (doShopToken.isEmpty()) return false

val recordShopCode = normalizeText(replenishment.shopCode)
val recordShopName = normalizeText(replenishment.shopName)
return recordShopCode == doShopToken ||
recordShopName.startsWith(doShopToken) ||
(recordShopCode.isNotEmpty() && doShopToken.startsWith(recordShopCode)) ||
(recordShopCode.isNotEmpty() && recordShopCode.startsWith(doShopToken))
}

private fun shopTokenFromResult(result: ReleaseDoResult): String {
val raw = result.shopCode?.trim()?.takeIf { it.isNotEmpty() }
?: result.shopName?.trim()
?: ""
if (raw.isEmpty()) return ""
return normalizeText(raw.split(" - ").firstOrNull() ?: raw)
}

private fun normalizeText(value: String?): String = value?.trim()?.lowercase() ?: ""

private fun resolveDeliveryOrderPickOrderId(pickOrderId: Long): Long? {
return jdbcDao.queryForList(
"""
SELECT deliveryOrderPickOrderId AS dopoId
FROM fpsmsdb.pick_order
WHERE id = :pickOrderId AND deleted = 0
""".trimIndent(),
mapOf("pickOrderId" to pickOrderId),
).firstOrNull()
?.get("dopoId")
?.let { (it as Number).toLong() }
}

/** Prefer pick_order that already has the same item on this ticket; else smallest pick_order.id. */
private fun resolveTargetPickOrderId(ticketPickOrderIds: List<Long>, itemId: Long): Long {
val sortedIds = ticketPickOrderIds.sorted()
val lines = pickOrderLineRepository.findAllByPickOrderIdInAndDeletedFalse(sortedIds)
val pickOrderWithItem = lines
.filter { it.item?.id == itemId }
.mapNotNull { it.pickOrder?.id }
.minOrNull()
return pickOrderWithItem ?: sortedIds.first()
}

private fun nextCodeSequence(deliveryDate: LocalDate): Int {
val prefix = codePrefix(deliveryDate)
val suffixPattern = Regex("^${Regex.escape(prefix)}(\\d+)$")
var maxSeq = 0
for (code in doReplenishmentRepository.findCodesByPrefix(prefix)) {
suffixPattern.find(code)?.groupValues?.getOrNull(1)?.toIntOrNull()?.let { n ->
if (n > maxSeq) maxSeq = n
}
}
return maxSeq + 1
}

private fun formatReplenishmentCode(deliveryDate: LocalDate, sequence: Int): String {
return "${codePrefix(deliveryDate)}${sequence.toString().padStart(3, '0')}"
}

private fun codePrefix(deliveryDate: LocalDate): String {
val ymd = deliveryDate.format(DateTimeFormatter.BASIC_ISO_DATE)
return "RP-$ymd-"
}

/** 同一批次內相同來源行合併補貨數量。 */
private fun mergeSubmitLines(
lines: List<SubmitDoReplenishmentLineRequest>,
): List<SubmitDoReplenishmentLineRequest> {
if (lines.size <= 1) return lines
val merged = linkedMapOf<String, SubmitDoReplenishmentLineRequest>()
for (line in lines) {
val key = "${line.sourceDoId}:${line.sourceDoLineId}"
val existing = merged[key]
if (existing == null) {
merged[key] = line
} else {
merged[key] = existing.copy(
replenishQty = existing.replenishQty.add(line.replenishQty),
truckLaneCode = existing.truckLaneCode?.takeIf { it.isNotBlank() } ?: line.truckLaneCode,
reason = existing.reason?.takeIf { it.isNotBlank() } ?: line.reason,
)
}
}
return merged.values.toList()
}

/** 來源 DO 車線:優先 do_pick_order / do_pick_order_record,其次請求帶入值。 */
private fun resolveSourceDoTruckLaneCode(
deliveryOrder: DeliveryOrder,
requestTruckLaneCode: String?,
): String? {
val sourceDoId = deliveryOrder.id ?: return requestTruckLaneCode?.trim()?.takeIf { it.isNotEmpty() }

doPickOrderRepository.findByDoOrderIdAndDeletedFalse(sourceDoId)
.mapNotNull { it.truckLanceCode?.trim()?.takeIf { code -> code.isNotEmpty() } }
.firstOrNull()
?.let { return it }

doPickOrderRecordRepository.findByDoOrderIdAndDeletedFalse(sourceDoId)
.mapNotNull { it.truckLanceCode?.trim()?.takeIf { code -> code.isNotEmpty() } }
.firstOrNull()
?.let { return it }

return requestTruckLaneCode?.trim()?.takeIf { it.isNotEmpty() }
}

/**
* Actual shipped qty per item on a completed source DO: sum of [stock_out_line.qty]
* for the linked pick order line. Falls back to [fallbackQtyByItemId] when no pick link exists
* (same rule as delivery note PDF).
*/
open fun resolveActualShippedQtyForDeliveryOrder(
doId: Long,
fallbackQtyByItemId: Map<Long, BigDecimal> = emptyMap(),
): Map<Long, BigDecimal> {
val keys = fallbackQtyByItemId.keys.map { doId to it }
if (keys.isEmpty()) {
return emptyMap()
}
return resolveActualShippedQtyBySourceKeys(keys, keys.associateWith { fallbackQtyByItemId[it.second]!! })
.mapKeys { it.key.second }
}

private fun resolveActualShippedQtyBySourceKeys(
keys: List<Pair<Long, Long>>,
fallbackQtyByKey: Map<Pair<Long, Long>, BigDecimal>,
): Map<Pair<Long, Long>, BigDecimal> {
if (keys.isEmpty()) {
return emptyMap()
}

val doIds = keys.map { it.first }.distinct()
val pickOrderIdByDoId = doIds.mapNotNull { doId ->
resolvePickOrderIdForDo(doId)?.let { doId to it }
}.toMap()

val pickOrderIds = pickOrderIdByDoId.values.distinct()
val pickOrderLines = if (pickOrderIds.isEmpty()) {
emptyList()
} else {
pickOrderLineRepository.findAllByPickOrderIdInAndDeletedFalse(pickOrderIds)
}
val pickOrderLinesByPoId = pickOrderLines.groupBy { it.pickOrder?.id }

val polIds = pickOrderLines.mapNotNull { it.id }
val stockOutQtyByPolId = if (polIds.isEmpty()) {
emptyMap()
} else {
stockOutLIneRepository.findAllByPickOrderLineIdInAndDeletedFalse(polIds)
.groupBy { it.pickOrderLine?.id }
.mapValues { (_, lines) ->
lines.fold(BigDecimal.ZERO) { acc, line ->
acc.add(BigDecimal.valueOf(line.qty ?: 0.0))
}
}
}

return keys.associateWith { (doId, itemId) ->
val pickOrderId = pickOrderIdByDoId[doId]
val polId = pickOrderId?.let { poId ->
pickOrderLinesByPoId[poId]?.firstOrNull { it.item?.id == itemId }?.id
}
if (polId != null) {
stockOutQtyByPolId[polId] ?: BigDecimal.ZERO
} else {
fallbackQtyByKey[doId to itemId] ?: BigDecimal.ZERO
}
}
}

private fun resolvePickOrderIdForDo(doId: Long): Long? {
pickOrderRepository.findByDeliveryOrderId(doId).firstOrNull()?.id?.let { return it }
return doPickOrderLineRepository.findByDoOrderIdAndDeletedFalse(doId)
.mapNotNull { it.pickOrderId }
.firstOrNull()
}

private fun toResponses(entities: List<DoReplenishment>): List<DoReplenishmentResponse> {
val uomIds = entities.mapNotNull { it.uomId }.distinct()
val shortUomById = if (uomIds.isEmpty()) {
emptyMap()
} else {
uomConversionRepository.findAllById(uomIds).associate { uom ->
uom.id!! to (uom.udfShortDesc?.takeIf { it.isNotBlank() } ?: uom.code)
}
}

val sourceLineIds = entities.mapNotNull { it.sourceDoLineId }.distinct()
val sourceLineQtyById = if (sourceLineIds.isEmpty()) {
emptyMap()
} else {
deliveryOrderLineRepository.findAllById(sourceLineIds).associate { line ->
line.id!! to line.qty
}
}

val dopoIds = entities.mapNotNull { it.deliveryOrderPickOrderId }.distinct()
val dopoById = if (dopoIds.isEmpty()) {
emptyMap()
} else {
deliveryOrderPickOrderRepository.findAllById(dopoIds).associateBy { it.id!! }
}

val sourceKeys = entities.map { it.sourceDoId!! to it.itemId!! }.distinct()
val fallbackQtyBySourceKey = entities.associate { row ->
(row.sourceDoId!! to row.itemId!!) to (
row.sourceDoLineId?.let { sourceLineQtyById[it] } ?: BigDecimal.ZERO
)
}
val actualShippedQtyBySourceKey = resolveActualShippedQtyBySourceKeys(
keys = sourceKeys,
fallbackQtyByKey = fallbackQtyBySourceKey,
)

val polIds = entities.mapNotNull { it.pickOrderLineId }.distinct()
val stockOutLinesByPolId = if (polIds.isEmpty()) {
emptyMap()
} else {
stockOutLIneRepository.findAllInfoByPickOrderLineIdInAndDeletedFalse(polIds)
.groupBy { it.pickOrderLineId }
}

return entities.map { row ->
DoReplenishmentResponse(
id = row.id!!,
code = row.code!!,
deliveryDate = row.deliveryDate!!,
sourceDoId = row.sourceDoId!!,
sourceDoCode = row.sourceDoCode,
sourceDoLineId = row.sourceDoLineId!!,
itemId = row.itemId!!,
itemNo = row.itemNo,
itemName = row.itemName,
originalQty = actualShippedQtyBySourceKey[row.sourceDoId!! to row.itemId!!],
actualPickQty = row.pickOrderLineId?.let { polId ->
WorkbenchStockOutLinePickProgress.sumPickedQty(
stockOutLinesByPolId[polId].orEmpty(),
)
},
replenishQty = row.replenishQty!!,
shortUom = row.uomId?.let { shortUomById[it] },
shopCode = row.shopCode,
shopName = row.shopName,
truckLaneCode = row.truckLaneCode,
targetDoId = row.targetDoId,
targetDoCode = row.targetDoCode,
targetDoEstimatedArrivalDate = row.deliveryOrderPickOrderId?.let { dopoById[it]?.requiredDeliveryDate },
pickOrderLineId = row.pickOrderLineId,
deliveryOrderPickOrderId = row.deliveryOrderPickOrderId,
relateTicketNo = row.deliveryOrderPickOrderId?.let { dopoById[it]?.ticketNo },
status = row.status,
reason = row.reason,
created = row.created,
)
}
}
}

+ 77
- 9
src/main/java/com/ffii/fpsms/modules/deliveryOrder/service/DoWorkbenchDopoAssignmentService.kt View File

@@ -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].
*/
@@ -110,7 +112,7 @@ open class DoWorkbenchDopoAssignmentService(
"4/F" -> "4/F"
else -> request.storeId
}
println(" DEBUG: assignByLaneForWorkbench storeId=$actualStoreId date=${request.requiredDate} lane=${request.truckLanceCode} dep=${request.truckDepartureTime}")
println(" DEBUG: assignByLaneForWorkbench storeId=$actualStoreId date=${request.requiredDate} lane=${request.truckLanceCode} dep=${request.truckDepartureTime} seq=${request.loadingSequence}")

val params = mutableMapOf<String, Any>(
"storeId" to actualStoreId,
@@ -140,12 +142,26 @@ open class DoWorkbenchDopoAssignmentService(
sql.append(" AND dop.truckDepartureTime = :depTime ")
params["depTime"] = depSqlTime
}
if (request.loadingSequence != null) {
sql.append(" AND dop.loadingSequence = :loadingSequence ")
params["loadingSequence"] = request.loadingSequence
}
if (isisExtraReleaseType(request.releaseType)) {
sql.append(WorkbenchReleaseTypeSupport.etraFamilySql())
} else {
sql.append(WorkbenchReleaseTypeSupport.assignFilterSql(request.releaseType))
}
// Fetch a batch of candidates and try atomic-assign sequentially.
// This avoids forcing the frontend to refresh when a single picked candidate is concurrently assigned.
val candidateLimit = 50
val maxRounds = 3

sql.append(" ORDER BY dop.requiredDeliveryDate ASC, dop.truckDepartureTime ASC, dop.id ASC LIMIT $candidateLimit ")
val shouldOrderBySequence = actualStoreId == "2/F" && request.loadingSequence == null
if (shouldOrderBySequence) {
sql.append(" ORDER BY dop.requiredDeliveryDate ASC, dop.truckDepartureTime ASC, dop.loadingSequence ASC, dop.id ASC LIMIT $candidateLimit ")
} else {
sql.append(" ORDER BY dop.requiredDeliveryDate ASC, dop.truckDepartureTime ASC, dop.id ASC LIMIT $candidateLimit ")
}

fun extractIds(rows: List<Map<String, Any?>>): List<Long> {
if (rows.isEmpty()) return emptyList()
@@ -192,7 +208,10 @@ open class DoWorkbenchDopoAssignmentService(
)
}

/** Legacy lane assign (V1): old FG-style (no atomic conflict guard). */
/**
* FP-MTMS Version Checklist | Functions Ref. No. 37 | v1.0.1 | 2026-08-03
* Legacy lane assign (V1): old FG-style (no atomic conflict guard).
*/
@Transactional
open fun assignByLaneForWorkbenchV1(request: AssignByLaneRequest): MessageResponse {
val user = userRepository.findById(request.userId).orElse(null)
@@ -205,7 +224,7 @@ open class DoWorkbenchDopoAssignmentService(
"4/F" -> "4/F"
else -> request.storeId
}
println(" DEBUG: assignByLaneForWorkbenchV1 storeId=$actualStoreId date=${request.requiredDate} lane=${request.truckLanceCode} dep=${request.truckDepartureTime}")
println(" DEBUG: assignByLaneForWorkbenchV1 storeId=$actualStoreId date=${request.requiredDate} lane=${request.truckLanceCode} dep=${request.truckDepartureTime} seq=${request.loadingSequence}")

val params = mutableMapOf<String, Any>(
"storeId" to actualStoreId,
@@ -234,7 +253,21 @@ open class DoWorkbenchDopoAssignmentService(
sql.append(" AND dop.truckDepartureTime = :depTime ")
params["depTime"] = depSqlTime
}
sql.append(" ORDER BY dop.requiredDeliveryDate ASC, dop.truckDepartureTime ASC, dop.id ASC LIMIT 1 ")
if (request.loadingSequence != null) {
sql.append(" AND dop.loadingSequence = :loadingSequence ")
params["loadingSequence"] = request.loadingSequence
}
if (isisExtraReleaseType(request.releaseType)) {
sql.append(WorkbenchReleaseTypeSupport.etraFamilySql())
} else {
sql.append(WorkbenchReleaseTypeSupport.assignFilterSql(request.releaseType))
}
val shouldOrderBySequenceV1 = actualStoreId == "2/F" && request.loadingSequence == null
if (shouldOrderBySequenceV1) {
sql.append(" ORDER BY dop.requiredDeliveryDate ASC, dop.truckDepartureTime ASC, dop.loadingSequence ASC, dop.id ASC LIMIT 1 ")
} else {
sql.append(" ORDER BY dop.requiredDeliveryDate ASC, dop.truckDepartureTime ASC, dop.id ASC LIMIT 1 ")
}

val candidates = try {
jdbcDao.queryForList(sql.toString(), params)
@@ -283,6 +316,11 @@ open class DoWorkbenchDopoAssignmentService(
} else null
}

private fun isisExtraReleaseType(releaseType: String?): Boolean {
val n = releaseType?.trim()?.lowercase().orEmpty()
return n == "isExtra"
}

private fun parseDepartureTimeToSql(raw: String?): Time? {
if (raw.isNullOrBlank()) return null
val s = raw.trim()
@@ -362,18 +400,19 @@ open class DoWorkbenchDopoAssignmentService(
)
}
// 下方逻辑先保持不变(你后续再做第二阶段批次化)
// Suggestion store scope: use DOPO.storeId when set; Truck X (null) → 2/F or 4/F from DO suppliers.
val storeIdRow = jdbcDao.queryForList(
"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)
@@ -401,4 +440,33 @@ open class DoWorkbenchDopoAssignmentService(
)
)
}

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

+ 867
- 220
src/main/java/com/ffii/fpsms/modules/deliveryOrder/service/DoWorkbenchMainService.kt
File diff suppressed because it is too large
View File


+ 1139
- 98
src/main/java/com/ffii/fpsms/modules/deliveryOrder/service/DoWorkbenchReleaseService.kt
File diff suppressed because it is too large
View File


+ 55
- 0
src/main/java/com/ffii/fpsms/modules/deliveryOrder/service/TruckLaneSearchSpec.kt View File

@@ -0,0 +1,55 @@
package com.ffii.fpsms.modules.deliveryOrder.service

import java.util.Locale

/**
* 車線搜索正規化(search-do-lite-v2)。
* 實際 [com.ffii.fpsms.modules.pickOrder.entity.Truck] 編碼主要為 `車線-…` 或 `P06B_…`;未指派預設列為 `車線-X`(shopId 可為 null)。
*/
object TruckLaneSearchSpec {
const val UNASSIGNED_LANE_LABEL: String = "車線-X"

sealed interface Mode {
data object NoFilter : Mode
/** 僅未指派:推算為 null/空白/字面量 車線-X(與預設車列一致) */
data object UnassignedOnly : Mode
/**
* 一般關鍵字:以 [needleLower](trim + [Locale.ROOT] lowercase)對推算車線做 [String.contains]。
* 不再因「`車線-` 開頭」額外併入未指派,避免搜 `車線-待1` 卻出現 `車線-X`;廣義條件(無車線欄位)仍由 [NoFilter] 帶出含 X 的列。
*/
data class Keyword(
val needleLower: String,
) : Mode
}

fun parse(raw: String?): Mode {
val trimmed = raw?.trim().orEmpty()
if (trimmed.isEmpty()) return Mode.NoFilter
if (isUnassignedSearchToken(trimmed)) return Mode.UnassignedOnly
return Mode.Keyword(
needleLower = trimmed.lowercase(Locale.ROOT),
)
}

private fun isUnassignedSearchToken(trimmed: String): Boolean {
if (trimmed.length == 1 && trimmed.equals("x", ignoreCase = true)) return true
val normalized = trimmed.lowercase(Locale.ROOT).replace("车线", "車線")
return normalized == "車線-x"
}

fun isUnassignedResolvedLane(calculated: String?): Boolean {
if (calculated.isNullOrBlank()) return true
return calculated.trim().equals(UNASSIGNED_LANE_LABEL, ignoreCase = true)
}

fun matches(mode: Mode, resolvedTruckLanceCode: String?): Boolean {
when (mode) {
Mode.NoFilter -> return true
Mode.UnassignedOnly -> return isUnassignedResolvedLane(resolvedTruckLanceCode)
is Mode.Keyword -> {
val lane = resolvedTruckLanceCode?.trim()?.lowercase(Locale.ROOT).orEmpty()
return lane.contains(mode.needleLower)
}
}
}
}

+ 75
- 0
src/main/java/com/ffii/fpsms/modules/deliveryOrder/service/WorkbenchReleaseTypeSupport.kt View File

@@ -0,0 +1,75 @@
package com.ffii.fpsms.modules.deliveryOrder.service

/**
* Workbench [delivery_order_pick_order.releaseType] values and SQL filters.
* Etra UI uses [etraFamilySql] (standalone `isExtra` + merged `isExtrabatch` / `isExtrasingle`).
*/
object WorkbenchReleaseTypeSupport {
const val BATCH = "batch"
const val SINGLE = "single"
const val IS_EXTRA_BATCH = "isExtrabatch"
const val IS_EXTRA_SINGLE = "isExtrasingle"
const val LEGACY_IS_EXTRA = "isExtra"

fun batchFamilyTypes(): List<String> = listOf(BATCH, IS_EXTRA_BATCH)

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

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

/** FP-MTMS Version Checklist | Functions Ref. No. 37 | v1.0.1 | 2026-08-03 */
fun assignFilterSql(releaseType: String?, column: String = "dop.releaseType"): String {
val n = releaseType?.trim()?.lowercase().orEmpty()
return when (n) {
"batch" -> batchFamilySql(column)
"single" -> singleFamilySql(column)
"isextra", "etra", "etrafamily" -> etraFamilySql(column)
else -> ""
}
}

fun batchFamilySql(column: String = "dop.releaseType"): String =
" AND LOWER(COALESCE($column, '')) IN ('batch', 'isextrabatch') "

fun singleFamilySql(column: String = "dop.releaseType"): String =
" AND LOWER(COALESCE($column, '')) IN ('single', 'isextrasingle') "

fun legacyIsExtraSql(column: String = "dop.releaseType"): String =
" AND LOWER(COALESCE($column, '')) = 'isextra' "

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

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

/** [TI-M] merged workbench ticket release type (batch-family merge). */
fun mergeTicketReleaseType(isSingleRelease: Boolean): String =
if (isSingleRelease) IS_EXTRA_SINGLE else IS_EXTRA_BATCH

fun upgradedReleaseTypeIfNeeded(currentType: String?, isExtraRelease: Boolean, isSingleRelease: Boolean): String? {
if (!isExtraRelease) return null
val cur = currentType?.trim()?.lowercase().orEmpty()
return when {
isSingleRelease && cur == SINGLE.lowercase() -> IS_EXTRA_SINGLE
!isSingleRelease && cur == BATCH.lowercase() -> IS_EXTRA_BATCH
else -> null
}
}
}

+ 52
- 2
src/main/java/com/ffii/fpsms/modules/deliveryOrder/web/DeliveryOrderController.kt View File

@@ -4,6 +4,7 @@ import com.ffii.fpsms.modules.deliveryOrder.entity.DeliveryOrder
import com.ffii.fpsms.modules.deliveryOrder.entity.models.DeliveryOrderInfo
import com.ffii.fpsms.modules.deliveryOrder.enums.DeliveryOrderStatus
import com.ffii.fpsms.modules.deliveryOrder.service.DeliveryOrderService
import com.ffii.fpsms.modules.deliveryOrder.service.DoReplenishmentService
import com.ffii.fpsms.modules.deliveryOrder.web.models.SaveDeliveryOrderRequest
import com.ffii.fpsms.modules.deliveryOrder.web.models.SaveDeliveryOrderResponse
import com.ffii.fpsms.modules.deliveryOrder.web.models.SaveDeliveryOrderStatusRequest
@@ -44,7 +45,10 @@ import com.ffii.fpsms.modules.deliveryOrder.web.models.Check4FTruckBatchResponse
import com.ffii.fpsms.modules.deliveryOrder.web.models.DoSearchRowResponse
import com.ffii.fpsms.modules.deliveryOrder.entity.models.DeliveryOrderInfoLite
import com.ffii.fpsms.modules.deliveryOrder.entity.models.DeliveryOrderInfoLiteDto
import com.ffii.fpsms.modules.deliveryOrder.web.models.DoReplenishmentResponse
import com.ffii.fpsms.modules.deliveryOrder.web.models.SubmitDoReplenishmentRequest
import org.slf4j.LoggerFactory
import java.time.LocalDate

@RequestMapping("/do")
@RestController
@@ -52,7 +56,7 @@ class DeliveryOrderController(
private val deliveryOrderService: DeliveryOrderService,
private val stockInLineService: StockInLineService,
private val doPickOrderService: DoPickOrderService,
private val doReplenishmentService: DoReplenishmentService,
) {
private val log = LoggerFactory.getLogger(javaClass)

@@ -70,7 +74,9 @@ class DeliveryOrderController(
estimatedArrivalDate = request.estimatedArrivalDate,
pageNum = request.pageNum,
pageSize = request.pageSize,
truckLanceCode = request.truckLanceCode
truckLanceCode = request.truckLanceCode,
floor = request.floor,
isExtra = request.isExtra,
)
}

@@ -86,6 +92,27 @@ class DeliveryOrderController(
estimatedArrivalDate = request.estimatedArrivalDate,
pageNum = request.pageNum,
pageSize = request.pageSize,
floor = request.floor,
isExtra = request.isExtra,
)
}

/**
* DO 輕量搜索 v2:車線關鍵字正規化(`車線-X`/`x`/`車線-` 前綴併入未指派)、
* 允許供應商條件下分批掃描,避免單次載入過大;請求體同 [searchDoLite]。
*/
@PostMapping("/search-do-lite-v2")
fun searchDoLiteV2(@RequestBody request: SearchDeliveryOrderInfoRequest): RecordsRes<DeliveryOrderInfoLiteDto> {
return deliveryOrderService.searchDoLiteByPageV2(
code = request.code,
shopName = request.shopName,
status = request.status,
estimatedArrivalDate = request.estimatedArrivalDate,
pageNum = request.pageNum,
pageSize = request.pageSize,
truckLanceCode = request.truckLanceCode,
floor = request.floor,
isExtra = request.isExtra,
)
}

@@ -99,6 +126,29 @@ class DeliveryOrderController(
return deliveryOrderService.getDetailedDo(id);
}

@PostMapping("/replenishment")
fun submitReplenishment(
@Valid @RequestBody request: SubmitDoReplenishmentRequest,
): List<DoReplenishmentResponse> {
return doReplenishmentService.submit(request)
}

@GetMapping("/replenishment")
fun listReplenishment(
@RequestParam(required = false) deliveryDate: LocalDate?,
@RequestParam(required = false) status: String?,
): List<DoReplenishmentResponse> {
return doReplenishmentService.list(deliveryDate, status)
}

@GetMapping("/replenishment/for-batch-release")
fun listReplenishmentForBatchRelease(
@RequestParam(required = false) truckLaneCode: String?,
@RequestParam(required = false) shopName: String?,
): List<DoReplenishmentResponse> {
return doReplenishmentService.listForBatchRelease(truckLaneCode, shopName)
}

@GetMapping("/search-code/{code}")
fun searchByCode(@PathVariable code: String): List<DeliveryOrderInfo> {
return deliveryOrderService.searchByCode(code);


+ 91
- 8
src/main/java/com/ffii/fpsms/modules/deliveryOrder/web/DoWorkbenchController.kt View File

@@ -24,6 +24,11 @@ import org.springframework.http.HttpStatus
import org.springframework.http.MediaType
import org.springframework.http.ResponseEntity
import java.time.format.DateTimeFormatter
import jakarta.servlet.http.HttpServletResponse
import jakarta.validation.Valid
import net.sf.jasperreports.engine.JasperExportManager
import net.sf.jasperreports.engine.JasperPrint
import java.io.OutputStream
@RestController
@RequestMapping("/doPickOrder/workbench")
class DoWorkbenchController(
@@ -96,23 +101,50 @@ class DoWorkbenchController(
)
}

/** All Etra workbench tickets for a day, grouped by shop → truck (see [DoWorkbenchMainService.getWorkbenchEtraLaneSummary]). */
@GetMapping("/summary-is-etra")
fun getWorkbenchEtraSummary(
@RequestParam(required = false) @DateTimeFormat(iso = DateTimeFormat.ISO.DATE) requiredDate: LocalDate?,
): List<WorkbenchEtraShopLaneGroup> =
doWorkbenchMainService.getWorkbenchEtraLaneSummary(requiredDate)

/** Past-date backlog tickets from `delivery_order_pick_order` (not `do_pick_order`). */
@GetMapping("/released")
fun getWorkbenchReleasedDoPickOrders(
@RequestParam(required = false) shopName: String?,
@RequestParam(required = false) storeId: String?,
@RequestParam(required = false) truck: String?
@RequestParam(required = false) truck: String?,
@RequestParam(required = false) releaseType: String?,
/** 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)
return doWorkbenchMainService.findWorkbenchReleasedDeliveryOrderPickOrdersForSelection(
shopName,
storeId,
truck,
releaseTypeFilter = releaseType,
floor = floor,
)
}

@GetMapping("/released-today")
fun getWorkbenchReleasedDoPickOrdersToday(
@RequestParam(required = false) shopName: String?,
@RequestParam(required = false) storeId: String?,
@RequestParam(required = false) truck: String?
@RequestParam(required = false) truck: String?,
@RequestParam(required = false) @DateTimeFormat(iso = DateTimeFormat.ISO.DATE) requiredDate: LocalDate?,
@RequestParam(required = false) releaseType: String?,
/** 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, storeId, truck)
return doWorkbenchMainService.findWorkbenchReleasedDeliveryOrderPickOrdersForSelectionToday(
shopName,
storeId,
truck,
requiredDeliveryDate = requiredDate,
releaseTypeFilter = releaseType,
floor = floor,
)
}

@PostMapping("/assign-by-delivery-order-pick-order-id")
@@ -152,19 +184,40 @@ class DoWorkbenchController(
*/
@PostMapping("/batch-release/async-v2")
fun startWorkbenchBatchReleaseAsyncV2(
@RequestBody ids: List<Long>,
@RequestBody request: WorkbenchBatchReleaseRequest,
@RequestParam(defaultValue = "1") userId: Long
): MessageResponse {
return doWorkbenchReleaseService.startBatchReleaseAsyncV2(
request.ids,
userId,
request.mergeExtraIntoLaneTicket,
)
}

/**
* One delivery order, same release pipeline as [startWorkbenchBatchReleaseAsyncV2], but
* [delivery_order_pick_order.releaseType] = `single` and ticket prefix `TI-S-` (not batch / `TI-B-`).
* Body: JSON number (mirrors [DoPickOrderController.startBatchReleaseAsyncSingle]).
*/
@PostMapping("/batch-release/async-single-v2")
fun startWorkbenchBatchReleaseAsyncSingleV2(
@RequestBody doId: Long,
@RequestParam(defaultValue = "1") userId: Long
): MessageResponse {
return doWorkbenchReleaseService.startBatchReleaseAsyncV2(ids, userId)
return doWorkbenchReleaseService.startBatchReleaseAsyncSingleV2(listOf(doId), userId)
}

/** Synchronous batch release V2 (same semantics as async-v2; for tools / tests). */
@PostMapping("/batch-release/sync-v2")
fun workbenchBatchReleaseSyncV2(
@RequestBody ids: List<Long>,
@RequestBody request: WorkbenchBatchReleaseRequest,
@RequestParam(defaultValue = "1") userId: Long
): MessageResponse {
return doWorkbenchReleaseService.releaseBatchV2(ids, userId)
return doWorkbenchReleaseService.releaseBatchV2(
request.ids,
userId,
request.mergeExtraIntoLaneTicket,
)
}
@GetMapping("/batch-release/progress/{jobId}")
@@ -172,6 +225,22 @@ class DoWorkbenchController(
return doWorkbenchReleaseService.getBatchReleaseProgress(jobId)
}

/** Case 3: unassigned plain batch/single + isExtra tickets on the same lane (for merge UI). */
@GetMapping("/merge-ticket-candidates")
fun getWorkbenchMergeTicketCandidates(
@RequestParam @DateTimeFormat(iso = DateTimeFormat.ISO.DATE) requiredDate: LocalDate,
@RequestParam(required = false) shopSearch: String?,
): WorkbenchMergeTicketCandidatesResponse =
doWorkbenchReleaseService.getMergeTicketCandidates(requiredDate, shopSearch)

/** Case 3 / 3b: merge batch/single or existing [TI-M] + isExtra into [TI-M]. */
@PostMapping("/merge-tickets")
fun mergeWorkbenchTickets(@RequestBody request: WorkbenchMergeTicketsRequest): MessageResponse =
doWorkbenchReleaseService.mergeTicketsCase3(
request.batchOrSingleDopoId,
request.isExtraDopoId,
)

@GetMapping("/ticket-release-table/{startDate}&{endDate}")
fun getWorkbenchTicketReleaseTable(
@PathVariable startDate: LocalDate,
@@ -203,6 +272,20 @@ class DoWorkbenchController(
doWorkbenchMainService.printDeliveryNoteWorkbench(request)
}

@PostMapping("/DN")
fun downloadWorkbenchDN(
@Valid @RequestBody request: ExportDeliveryNoteRequest,
response: HttpServletResponse,
) {
response.characterEncoding = "utf-8"
response.contentType = "application/pdf"
val out: OutputStream = response.outputStream
val pdf = doWorkbenchMainService.exportDeliveryNoteWorkbench(request)
val jasperPrint = pdf["report"] as JasperPrint
response.addHeader("filename", "${pdf["filename"]}.pdf")
out.write(JasperExportManager.exportReportToPdf(jasperPrint))
}

@GetMapping("/print-DNLabels")
fun printWorkbenchDNLabels(@ModelAttribute request: PrintDNLabelsRequest) {
doWorkbenchMainService.printDNLabelsWorkbench(request)


+ 30
- 2
src/main/java/com/ffii/fpsms/modules/deliveryOrder/web/models/DoDetailResponse.kt View File

@@ -18,6 +18,12 @@ data class DoDetailResponse(
@JsonFormat(pattern = "yyyy-MM-dd")
val completeDate: LocalDateTime?,
val status: String?,
/** 加單 DO(M18 加單專用同步) */
val isExtra: Boolean = false,
/** 揀貨員名稱(來源:delivery_order_pick_order.handlerName) */
val handlerName: String? = null,
/** 來源 DO 車線(do_pick_order / delivery_order_pick_order) */
val truckLaneCode: String? = null,
val deliveryOrderLines: List<DoDetailLineResponse>
)

@@ -25,12 +31,18 @@ data class DoDetailLineResponse(
val id: Long,
val itemNo: String?,
val qty: java.math.BigDecimal?,
/** Sum of stock_out_line.qty for the linked pick order line; falls back to [qty] when unavailable. */
val actualShippedQty: java.math.BigDecimal?,
val price: java.math.BigDecimal?,
val status: String?,
val itemName: String?,
val uom: String?,
val uomCode: String?,
val shortUom: String?,
/** Sum of (inQty - outQty - holdQty) on AVAILABLE lot lines for this item. */
val stockQty: java.math.BigDecimal?,
/** `available` when stockQty >= qty, else `insufficient`. */
val availableStatus: String?,
)
data class StoreLaneSummary(
val storeId: String,
@@ -49,7 +61,18 @@ data class LaneBtn(
val unassigned: Int,
val total: Int,
// 同一 truckLanceCode + loadingSequence 的 handler 去重后逗号拼接
val handlerName: String? = null
val handlerName: String? = null,
/** Workbench Etra lane: `delivery_order_pick_order.storeId` (2/F, 4/F, …) for assign / modal scope */
val storeId: String? = null,
/** Workbench Etra / lane row: `truckDepartureTime` as ISO local time string for assign-by-lane */
val truckDepartureTime: String? = null,
)

/** Etra family tickets (`isExtra` / `isExtrabatch` / `isExtrasingle`) for a day, grouped by shop then truck. */
data class WorkbenchEtraShopLaneGroup(
val shopCode: String?,
val shopName: String?,
val lanes: List<LaneBtn>,
)
data class AssignByLaneRequest(
val userId: Long,
@@ -57,9 +80,12 @@ data class AssignByLaneRequest(
val truckDepartureTime: String?, // 可选:限定出车时间
val truckLanceCode: String ,
val loadingSequence: Int? = null,
val requiredDate: LocalDate? // 必填:车道编号
val requiredDate: LocalDate?, // 必填:车道编号
/** When `isExtra`, assignment candidates are limited to `releaseType = isExtra` rows. */
val releaseType: String? = null,
)
data class DoPickOrderSummaryItem(
@JsonFormat(pattern = "HH:mm")
val truckDepartureTime: java.time.LocalTime?,
val truckLanceCode: String?,
val loadingSequence: Int?,
@@ -101,11 +127,13 @@ interface DoSearchRowProjection {
}
data class ReleasedDoPickOrderListItem(
val id: Long, // doPickOrderId,用於 assign
@JsonFormat(pattern = "yyyy-MM-dd")
val requiredDeliveryDate: LocalDate?, // Date 欄
val shopCode: String?, // Shop
val shopName: String?, // Shop
val storeId: String?, // 2/F or 4/F
val truckLanceCode: String?, // Truck (Lane)
@JsonFormat(pattern = "HH:mm")
val truckDepartureTime: LocalTime?, // Truck 時間
val deliveryOrderCodes: List<String> // 多個 DO code,前端換行顯示
)


+ 62
- 0
src/main/java/com/ffii/fpsms/modules/deliveryOrder/web/models/DoReplenishmentModels.kt View File

@@ -0,0 +1,62 @@
package com.ffii.fpsms.modules.deliveryOrder.web.models

import com.fasterxml.jackson.annotation.JsonFormat
import jakarta.validation.Valid
import jakarta.validation.constraints.NotEmpty
import jakarta.validation.constraints.NotNull
import jakarta.validation.constraints.Positive
import java.math.BigDecimal
import java.time.LocalDate
import java.time.LocalDateTime

data class SubmitDoReplenishmentLineRequest(
@field:NotNull
@field:JsonFormat(pattern = "yyyy-MM-dd")
val deliveryDate: LocalDate,
@field:NotNull
val sourceDoId: Long,
@field:NotNull
val sourceDoLineId: Long,
@field:NotNull
@field:Positive
val replenishQty: BigDecimal,
val truckLaneCode: String? = null,
val reason: String? = null,
)

data class SubmitDoReplenishmentRequest(
@field:NotEmpty
@field:Valid
val lines: List<SubmitDoReplenishmentLineRequest>,
)

data class DoReplenishmentResponse(
val id: Long,
val code: String,
@JsonFormat(pattern = "yyyy-MM-dd")
val deliveryDate: LocalDate,
val sourceDoId: Long,
val sourceDoCode: String?,
val sourceDoLineId: Long,
val itemId: Long,
val itemNo: String?,
val itemName: String?,
val originalQty: BigDecimal?,
val actualPickQty: BigDecimal?,
val replenishQty: BigDecimal,
val shortUom: String?,
val shopCode: String?,
val shopName: String?,
val truckLaneCode: String?,
val targetDoId: Long?,
val targetDoCode: String?,
@JsonFormat(pattern = "yyyy-MM-dd")
val targetDoEstimatedArrivalDate: LocalDate?,
val pickOrderLineId: Long?,
val deliveryOrderPickOrderId: Long?,
val relateTicketNo: String?,
val status: String,
val reason: String?,
@JsonFormat(pattern = "yyyy-MM-dd'T'HH:mm:ss")
val created: LocalDateTime?,
)

+ 1
- 0
src/main/java/com/ffii/fpsms/modules/deliveryOrder/web/models/ExportDNLabelsRequest.kt View File

@@ -3,4 +3,5 @@ package com.ffii.fpsms.modules.deliveryOrder.web.models
data class ExportDNLabelsRequest (
val doPickOrderId: Long,
val numOfCarton: Int,
val blankCartonNumber: Boolean? = false,
)

+ 2
- 1
src/main/java/com/ffii/fpsms/modules/deliveryOrder/web/models/PrintDNLabelsRequest.kt View File

@@ -4,5 +4,6 @@ data class PrintDNLabelsRequest (
val doPickOrderId: Long,
val printerId: Long,
val printQty: Int?,
val numOfCarton: Int
val numOfCarton: Int,
val blankCartonNumber: Boolean? = false,
)

+ 7
- 2
src/main/java/com/ffii/fpsms/modules/deliveryOrder/web/models/ReleaseDoRequest.kt View File

@@ -21,7 +21,8 @@ data class ReleaseDoResult(

val truckDepartureTime: LocalTime?,
val truckLanceCode: String?,
val loadingSequence: Int?
val loadingSequence: Int?,
val isExtra: Boolean = false,
)
data class SearchDeliveryOrderInfoRequest(
val code: String?,
@@ -30,5 +31,9 @@ data class SearchDeliveryOrderInfoRequest(
val estimatedArrivalDate: LocalDateTime?,
val pageSize: Int?,
val pageNum: Int?,
val truckLanceCode: String?
val truckLanceCode: String?,
/** `ALL`/`All`/null:P06B+P07+P06D+P06Y;`2F`:P07+P06D+P06Y ;`4F`:P06B。車線-X 亦依供應商歸屬出現在對應樓層。 */
val floor: String? = null,
/** null:不篩 isExtra;true/false:只顯示加單或非加單 DO */
val isExtra: Boolean? = null,
)

Some files were not shown because too many files changed in this diff

Loading…
Cancel
Save