Compare commits

...

126 次程式碼提交

作者 SHA1 備註 提交日期
  CANCERYS\kw093 112c34005c bom備註 1 周之前
  CANCERYS\kw093 daa7cbce65 Import Bom Excel check 1 周之前
  tommy ae81949e46 no message 1 周之前
  CANCERYS\kw093 ef7bcca120 Cancel Pick Order 1 周之前
  CANCERYS\kw093 ee4a80ccc2 do ticket select fix 1 周之前
  Fai Luk 1fb5200e16 no message 2 週之前
  vluk@2fi-solutions.com.hk 88136da489 no message 2 週之前
  vluk@2fi-solutions.com.hk c23898e31a python job order not to show cancelled job orders 2 週之前
  vluk@2fi-solutions.com.hk 70f620959a fixing the GRN duplicated item bug for same PO 2 週之前
  CANCERYS\kw093 fd3a0f00fa 補貨 search fix 3 週之前
  CANCERYS\kw093 8ff5c9e902 補貨 FIX 3 週之前
  tommy 84dddf9fba no message 3 週之前
  tommy b632df1d91 no message 3 週之前
  CANCERYS\kw093 7f62902a62 po detail ui add deleted button and ui update 3 週之前
  CANCERYS\kw093 3814cd4c79 jo pick order resuggest reject W402 warehosue 3 週之前
  CANCERYS\kw093 42c3ada0df if pol sync fail, will send email to tell pol have sync fail 1 月之前
  B.E.N.S.O.N 97a26b1a3b QC添加溫度/濕度儲存 1 月之前
  CANCERYS\kw093 789666aad8 do pdf fix 1 月之前
  tommy 7ac98d950b DN PDF 單號顯示問題 1 月之前
  tommy 46f7d39f7b 調整庫存權限 1 月之前
  vluk@2fi-solutions.com.hk 2e437a831b no message 1 月之前
  tommy 8aa202052b label printer tracking update 1 月之前
  tommy 76ff1cd22f label printer 1 月之前
  tommy ac238e7ba3 stocktake report update 1 月之前
  vluk@2fi-solutions.com.hk 7db4286368 no message 1 月之前
  vluk@2fi-solutions.com.hk 1035482880 no message 1 月之前
  vluk@2fi-solutions.com.hk 4399ac6d22 no message 1 月之前
  vluk@2fi-solutions.com.hk a7d3f156a5 no message 1 月之前
  vluk@2fi-solutions.com.hk 5eba1a42f2 no message 1 月之前
  vluk@2fi-solutions.com.hk f08f06da0f try to fix the bom item error 1 月之前
  CANCERYS\kw093 44646254f6 do skip isfee item 1 月之前
  vluk@2fi-solutions.com.hk 4d38dcff74 no message 1 月之前
  B.E.N.S.O.N 172d32f613 成品出倉執貨時 標籤列印時頁數顯示空白 1 月之前
  vluk@2fi-solutions.com.hk a111a80bf6 handle the laser printer that failed to print 1175 full name with (uom) 1 月之前
  vluk@2fi-solutions.com.hk 193a1824a2 added a report to check bom sync history 1 月之前
  vluk@2fi-solutions.com.hk 4d01f1d84f fixing the non-unique vendor result of bom finding 1 月之前
  vluk@2fi-solutions.com.hk 506d39c7f3 try to update the m18 bom with a new version number 1 月之前
  kelvin.yau b705a8acb6 replenishment update 1 月之前
  CANCERYS\kw093 a0b3a58d65 report fix 2 1 月之前
  CANCERYS\kw093 76a2ace7f9 rpeort fix 1 月之前
  CANCERYS\kw093 88e36de88c 補貨V1 1 月之前
  vluk@2fi-solutions.com.hk cdbfb157ca no message 1 月之前
  vluk@2fi-solutions.com.hk 13d646af6e Merge branch 'production' of https://git.2fi-solutions.com/jason/FPSMS-backend into production 1 月之前
  vluk@2fi-solutions.com.hk 1c1eadc71d alter for the BOM sync finding PP purchase order supplier and also others for supplier id by code 1 月之前
  tommy 22e982fea1 補貨 + truck scheduler update 1 月之前
  vluk@2fi-solutions.com.hk 306a8474c6 updates for some items missing the () uom of job order list which using by python 1 月之前
  vluk@2fi-solutions.com.hk 064d379229 Bag3 1 月之前
  kelvin.yau f74dc566ee replenishment setup 1 月之前
  tommy 5f816fcc92 re-schedule truck 1 月之前
  tommy 2987847917 add precon 1 月之前
  tommy 66f05c1e75 report permission 1 月之前
  tommy e438ae1d29 Merge branch 'production' of https://git.2fi-solutions.com/jason/FPSMS-backend into production 1 月之前
  tommy 0bc0e86933 DeliveryNotePdf update 1 月之前
  kelvin.yau e1ecc73f33 pick record download + precondition checking 1 月之前
  CANCERYS\kw093 076ff1c555 ti-M merge 1 月之前
  CANCERYS\kw093 b02f176e08 job order bom status 1 月之前
  CANCERYS\kw093 9bbd43aade update do is extra same order 1 月之前
  tommy 65ed99ba39 translate and route schedule 1 月之前
  CANCERYS\kw093 51bb08e2f2 improt bom fix 1 月之前
  CANCERYS\kw093 9331b7ebdd bom import fix 1 月之前
  CANCERYS\kw093 1bd3af87b1 do search fix 1 月之前
  CANCERYS\kw093 46b5b9f8c8 do PDF fix 2 月之前
  CANCERYS\kw093 3b7199cd90 stock take batch save fix 2 月之前
  CANCERYS\kw093 76cab93f57 s 2 月之前
  CANCERYS\kw093 e67440f56f search stock take fix 2 月之前
  CANCERYS\kw093 9b787159ef update stock take 2 月之前
  CANCERYS\kw093 7bafe58558 update stock tkae fix 2 月之前
  CANCERYS\kw093 2383b62ad0 stock take update 2 月之前
  CANCERYS\kw093 03663cc801 update new stock take 2 月之前
  CANCERYS\kw093 9ef208fd75 bom combo fix 2 月之前
  vluk@2fi-solutions.com.hk edc2f8c0f2 no message 2 月之前
  vluk@2fi-solutions.com.hk f2fbc5d7dc no message 2 月之前
  vluk@2fi-solutions.com.hk 9dbd06e338 added for bom sync to m18 2 月之前
  CANCERYS\kw093 6c095b3a29 product process fix 2 月之前
  B.E.N.S.O.N b180b73c97 膠茜數目使用數量 Update 2 月之前
  vluk@2fi-solutions.com.hk 6dc9d30292 no message 2 月之前
  vluk@2fi-solutions.com.hk a951485df2 no message 2 月之前
  vluk@2fi-solutions.com.hk 02811c401b no message 2 月之前
  CANCERYS\kw093 cf8f1564d1 Merge remote-tracking branch 'origin/production' into production 2 月之前
  CANCERYS\kw093 95f20378ea jo dashbaord update 2 月之前
  vluk@2fi-solutions.com.hk 74312d84bd don't overwrite the purchase order when sync from m18 if the status is not "pending" anymore 2 月之前
  CANCERYS\kw093 4cd0e5479a Merge remote-tracking branch 'origin/production' into production 2 月之前
  CANCERYS\kw093 c57bec2f74 update TRF fix 2 月之前
  vluk@2fi-solutions.com.hk 3f70bc475c refining the device monitoring page 2 月之前
  vluk@2fi-solutions.com.hk 9cdb1f71b8 added export do qty in /ps for daily delivery qty; added monitor page for production use 2 月之前
  CANCERYS\kw093 b8c608c5b4 chart improve 2 月之前
  kelvin.yau c5ea9b5f05 merge fix 2 月之前
  kelvin.yau 5b61294a53 Merge branch 'production' of https://git.2fi-solutions.com/derek/FPSMS-backend into production 2 月之前
  tommy e9f1f48edb routeboard 2 月之前
  CANCERYS\kw093 e78971d7b2 job order auto cancel and delay 2 月之前
  CANCERYS\kw093 f1a9d63a99 Merge remote-tracking branch 'origin/production' into production 2 月之前
  CANCERYS\kw093 1a6cb36897 inventory search fix 2 月之前
  tommy 57ab57dd65 routeboard 2 月之前
  CANCERYS\kw093 7141c0f6b4 chart sql improt 2 月之前
  CANCERYS\kw093 1d971256c4 fix bag lot line function slow query 2 月之前
  CANCERYS\kw093 870fbca20e new supplier 2 月之前
  CANCERYS\kw093 9dd08d6a70 update Report and stock ledger search 2 月之前
  kelvin.yau d4dc79d6fa UPDATE CODE FOR ENSON (DO MARK COMPLETED) 2 月之前
  vluk@2fi-solutions.com.hk dd348f36ae adding for bom sync 2 月之前
  vluk@2fi-solutions.com.hk 4b83633f28 updated Bag3, adding m18 BOM syn, delete the DO2_EXTRA syn, move the DO2 sync to 1pm 2 月之前
  B.E.N.S.O.N 891a929e1f User Page Update 2 月之前
  CANCERYS\kw093 078965686f Merge remote-tracking branch 'origin/production' into production 2 月之前
  CANCERYS\kw093 1b1f23c283 update pick order complete logci and shortner Transactional 2 月之前
  kelvin.yau 042828d96f fix 2 月之前
  CANCERYS\kw093 9491f0f32c update bom import 2 月之前
  vluk@2fi-solutions.com.hk 1472a05830 added isExtra and sync extra DO from m18 2 月之前
  tommy 5cb1989ad6 truck dashboard update 2 月之前
  CANCERYS\kw093 d4af229304 update do search and jo bom name coe 2 月之前
  CANCERYS\kw093 b5af7aad05 update do finish jump page, 2 月之前
  kelvin.yau 5be61f895d Stock Adj fix 2 月之前
  CANCERYS\kw093 a10f069a4f update index again 2 月之前
  CANCERYS\kw093 2d738e9714 added index for delivery_order_pick_order 2 月之前
  vluk@2fi-solutions.com.hk 24ee1d8f11 revert the index 2 月之前
  vluk@2fi-solutions.com.hk e1902f3b0e added index to hep querying 2 月之前
  CANCERYS\kw093 15c961d543 update truck X and singal relesae 2 月之前
  CANCERYS\kw093 d5b94751e7 update scan pick reject list 2 月之前
  CANCERYS\kw093 ee2b4d255a update 2F assign by lance 2 月之前
  tommy 66df3b1db6 add logistic table , chnaged district reference type 2 月之前
  CANCERYS\kw093 292ae22a7e update do 4F assign by lance 2 月之前
  B.E.N.S.O.N 0a992c381d Merge remote-tracking branch 'origin/production' into production 2 月之前
  B.E.N.S.O.N 8002b6d621 QR Code Printing Update 2 月之前
  CANCERYS\kw093 777d962f12 update stock take batch handle efficient 2 月之前
  CANCERYS\kw093 31abe1b05a update stock take and stock take report and improved checkAndCompletePickOrderByConsoCode 2 月之前
  CANCERYS\kw093 d04e864323 fix worknbench floor problem 2 月之前
  DESKTOP-064TTA1\Fai LUK 4cb8d0d6de Merge branch 'master' into production 2 月之前
  kelvin.yau 59757eeacf New PO Workbench, Improve loading speed. 3 月之前
共有 100 個檔案被更改,包括 8900 行新增1291 行删除
分割檢視
  1. +33
    -0
      .cursor/rules/frontend-prevent-duplicate-api-calls.mdc
  2. +643
    -0
      .cursor/skills/fp-mtms-version-checklist/SKILL.md
  3. +5
    -1
      .gitignore
  4. +387
    -173
      python/Bag3.py
  5. 二進制
      python/__pycache__/Bag3.cpython-313.pyc
  6. +32
    -2
      python/installAndExe.txt
  7. +22
    -0
      src/main/java/com/ffii/fpsms/config/security/SecurityConfig.java
  8. +48
    -0
      src/main/java/com/ffii/fpsms/m18/entity/M18BomShopSyncLog.kt
  9. +49
    -0
      src/main/java/com/ffii/fpsms/m18/entity/M18BomShopSyncLogRepository.kt
  10. +7
    -0
      src/main/java/com/ffii/fpsms/m18/entity/M18DataLogRepository.kt
  11. +7
    -1
      src/main/java/com/ffii/fpsms/m18/entity/SchedulerSyncLogRepository.kt
  12. +14
    -0
      src/main/java/com/ffii/fpsms/m18/model/M18BomForShopSaveAttemptResult.kt
  13. +96
    -0
      src/main/java/com/ffii/fpsms/m18/model/M18BomForShopSaveRequest.kt
  14. +18
    -0
      src/main/java/com/ffii/fpsms/m18/model/M18BomShopBatchSyncSummary.kt
  15. +14
    -0
      src/main/java/com/ffii/fpsms/m18/model/M18BomShopSyncTriggerResult.kt
  16. +697
    -0
      src/main/java/com/ffii/fpsms/m18/service/M18BomForShopService.kt
  17. +41
    -0
      src/main/java/com/ffii/fpsms/m18/service/M18BomHeaderLookupService.kt
  18. +107
    -25
      src/main/java/com/ffii/fpsms/m18/service/M18DeliveryOrderService.kt
  19. +32
    -8
      src/main/java/com/ffii/fpsms/m18/service/M18MasterDataService.kt
  20. +32
    -9
      src/main/java/com/ffii/fpsms/m18/service/M18PurchaseOrderService.kt
  21. +43
    -0
      src/main/java/com/ffii/fpsms/m18/service/M18VendorLookupService.kt
  22. +19
    -2
      src/main/java/com/ffii/fpsms/m18/web/M18TestController.kt
  23. +1
    -1
      src/main/java/com/ffii/fpsms/modules/bag/service/bagService.kt
  24. +13
    -27
      src/main/java/com/ffii/fpsms/modules/bag/web/bagController.kt
  25. +188
    -118
      src/main/java/com/ffii/fpsms/modules/chart/service/ChartService.kt
  26. +11
    -5
      src/main/java/com/ffii/fpsms/modules/chart/web/ChartController.kt
  27. +4
    -5
      src/main/java/com/ffii/fpsms/modules/common/MailSMTP.kt
  28. +15
    -0
      src/main/java/com/ffii/fpsms/modules/common/SettingNames.java
  29. +18
    -0
      src/main/java/com/ffii/fpsms/modules/common/alert/LoggingSmsSender.kt
  30. +27
    -0
      src/main/java/com/ffii/fpsms/modules/common/alert/PoLineFailureAlertSupport.kt
  31. +73
    -0
      src/main/java/com/ffii/fpsms/modules/common/alert/SchedulerSyncAlertProperties.kt
  32. +504
    -0
      src/main/java/com/ffii/fpsms/modules/common/alert/SchedulerSyncAlertService.kt
  33. +27
    -0
      src/main/java/com/ffii/fpsms/modules/common/alert/SmsAlertConfig.kt
  34. +11
    -0
      src/main/java/com/ffii/fpsms/modules/common/alert/SmsSender.kt
  35. +68
    -0
      src/main/java/com/ffii/fpsms/modules/common/alert/SyncAlertEmailSender.kt
  36. +138
    -0
      src/main/java/com/ffii/fpsms/modules/common/alert/TwilioSmsSender.kt
  37. +7
    -4
      src/main/java/com/ffii/fpsms/modules/common/mail/service/MailSenderService.kt
  38. +20
    -3
      src/main/java/com/ffii/fpsms/modules/common/mail/service/MailTemplateService.kt
  39. +350
    -24
      src/main/java/com/ffii/fpsms/modules/common/scheduler/service/SchedulerService.kt
  40. +55
    -0
      src/main/java/com/ffii/fpsms/modules/common/scheduler/web/SchedulerController.kt
  41. +4
    -0
      src/main/java/com/ffii/fpsms/modules/deliveryOrder/entity/DeliveryOrder.kt
  42. +4
    -0
      src/main/java/com/ffii/fpsms/modules/deliveryOrder/entity/DeliveryOrderPickOrder.kt
  43. +6
    -0
      src/main/java/com/ffii/fpsms/modules/deliveryOrder/entity/DeliveryOrderRepository.kt
  44. +2
    -0
      src/main/java/com/ffii/fpsms/modules/deliveryOrder/entity/DoPickOrderRecordRepository.kt
  45. +2
    -0
      src/main/java/com/ffii/fpsms/modules/deliveryOrder/entity/DoPickOrderRepository.kt
  46. +106
    -0
      src/main/java/com/ffii/fpsms/modules/deliveryOrder/entity/DoReplenishment.kt
  47. +89
    -0
      src/main/java/com/ffii/fpsms/modules/deliveryOrder/entity/DoReplenishmentRepository.kt
  48. +5
    -1
      src/main/java/com/ffii/fpsms/modules/deliveryOrder/entity/models/DeliveryOrderInfo.kt
  49. +2
    -0
      src/main/java/com/ffii/fpsms/modules/deliveryOrder/entity/models/DeliveryOrderLineInfo.kt
  50. +767
    -278
      src/main/java/com/ffii/fpsms/modules/deliveryOrder/service/DeliveryOrderService.kt
  51. +95
    -0
      src/main/java/com/ffii/fpsms/modules/deliveryOrder/service/DoFloorSupplierSettingsService.kt
  52. +80
    -129
      src/main/java/com/ffii/fpsms/modules/deliveryOrder/service/DoPickOrderService.kt
  53. +7
    -20
      src/main/java/com/ffii/fpsms/modules/deliveryOrder/service/DoReleaseCoordinatorService.kt
  54. +583
    -0
      src/main/java/com/ffii/fpsms/modules/deliveryOrder/service/DoReplenishmentService.kt
  55. +37
    -4
      src/main/java/com/ffii/fpsms/modules/deliveryOrder/service/DoWorkbenchDopoAssignmentService.kt
  56. +580
    -213
      src/main/java/com/ffii/fpsms/modules/deliveryOrder/service/DoWorkbenchMainService.kt
  57. +1083
    -98
      src/main/java/com/ffii/fpsms/modules/deliveryOrder/service/DoWorkbenchReleaseService.kt
  58. +55
    -0
      src/main/java/com/ffii/fpsms/modules/deliveryOrder/service/TruckLaneSearchSpec.kt
  59. +65
    -0
      src/main/java/com/ffii/fpsms/modules/deliveryOrder/service/WorkbenchReleaseTypeSupport.kt
  60. +52
    -2
      src/main/java/com/ffii/fpsms/modules/deliveryOrder/web/DeliveryOrderController.kt
  61. +85
    -8
      src/main/java/com/ffii/fpsms/modules/deliveryOrder/web/DoWorkbenchController.kt
  62. +30
    -2
      src/main/java/com/ffii/fpsms/modules/deliveryOrder/web/models/DoDetailResponse.kt
  63. +62
    -0
      src/main/java/com/ffii/fpsms/modules/deliveryOrder/web/models/DoReplenishmentModels.kt
  64. +1
    -0
      src/main/java/com/ffii/fpsms/modules/deliveryOrder/web/models/ExportDNLabelsRequest.kt
  65. +2
    -1
      src/main/java/com/ffii/fpsms/modules/deliveryOrder/web/models/PrintDNLabelsRequest.kt
  66. +7
    -2
      src/main/java/com/ffii/fpsms/modules/deliveryOrder/web/models/ReleaseDoRequest.kt
  67. +1
    -0
      src/main/java/com/ffii/fpsms/modules/deliveryOrder/web/models/SaveDeliveryOrderRequest.kt
  68. +5
    -0
      src/main/java/com/ffii/fpsms/modules/deliveryOrder/web/models/TicketReleaseTableResponse.kt
  69. +4
    -0
      src/main/java/com/ffii/fpsms/modules/deliveryOrder/web/models/TruckScheduleDashboardResponse.kt
  70. +11
    -0
      src/main/java/com/ffii/fpsms/modules/deliveryOrder/web/models/WorkbenchBatchReleaseRequest.kt
  71. +35
    -0
      src/main/java/com/ffii/fpsms/modules/deliveryOrder/web/models/WorkbenchMergeTicketModels.kt
  72. +5
    -0
      src/main/java/com/ffii/fpsms/modules/deliveryOrder/web/models/WorkbenchTicketReleaseTableResponse.kt
  73. +1
    -0
      src/main/java/com/ffii/fpsms/modules/jobOrder/entity/JobOrderRepository.kt
  74. +1
    -7
      src/main/java/com/ffii/fpsms/modules/jobOrder/scheduler/LaserBag2AutoSendScheduler.kt
  75. +35
    -5
      src/main/java/com/ffii/fpsms/modules/jobOrder/service/JoPickOrderService.kt
  76. +22
    -22
      src/main/java/com/ffii/fpsms/modules/jobOrder/service/JoWorkbenchMainService.kt
  77. +36
    -0
      src/main/java/com/ffii/fpsms/modules/jobOrder/service/JoWorkbenchPickConstants.kt
  78. +27
    -9
      src/main/java/com/ffii/fpsms/modules/jobOrder/service/JobOrderBomMaterialService.kt
  79. +243
    -0
      src/main/java/com/ffii/fpsms/modules/jobOrder/service/JobOrderPlanStartAutoService.kt
  80. +119
    -10
      src/main/java/com/ffii/fpsms/modules/jobOrder/service/JobOrderService.kt
  81. +1
    -2
      src/main/java/com/ffii/fpsms/modules/jobOrder/service/LaserBag2AutoSendService.kt
  82. +274
    -33
      src/main/java/com/ffii/fpsms/modules/jobOrder/service/PSService.kt
  83. +7
    -4
      src/main/java/com/ffii/fpsms/modules/jobOrder/service/PlasticBagPrinterService.kt
  84. +24
    -8
      src/main/java/com/ffii/fpsms/modules/jobOrder/web/JobOrderController.kt
  85. +42
    -1
      src/main/java/com/ffii/fpsms/modules/jobOrder/web/PSController.kt
  86. +37
    -1
      src/main/java/com/ffii/fpsms/modules/jobOrder/web/model/CreateJobOrderRequest.kt
  87. +4
    -0
      src/main/java/com/ffii/fpsms/modules/jobOrder/web/model/ExportPickRecordRequest.kt
  88. +7
    -0
      src/main/java/com/ffii/fpsms/modules/jobOrder/web/model/PickRecordPlasticBoxCartonQtyResponse.kt
  89. +9
    -0
      src/main/java/com/ffii/fpsms/modules/jobOrder/web/model/PlasticBoxCartonQtyDashboardRecord.kt
  90. +4
    -0
      src/main/java/com/ffii/fpsms/modules/jobOrder/web/model/PrintPickRecordRequest.kt
  91. +33
    -0
      src/main/java/com/ffii/fpsms/modules/logistic/entity/Logistic.kt
  92. +12
    -0
      src/main/java/com/ffii/fpsms/modules/logistic/entity/LogisticRepository.kt
  93. +82
    -0
      src/main/java/com/ffii/fpsms/modules/logistic/service/LogisticService.kt
  94. +65
    -0
      src/main/java/com/ffii/fpsms/modules/logistic/web/LogisticController.kt
  95. +9
    -0
      src/main/java/com/ffii/fpsms/modules/logistic/web/models/DeleteLogisticRequest.kt
  96. +10
    -0
      src/main/java/com/ffii/fpsms/modules/logistic/web/models/LogisticResponse.kt
  97. +21
    -0
      src/main/java/com/ffii/fpsms/modules/logistic/web/models/SaveLogisticRequest.kt
  98. +12
    -0
      src/main/java/com/ffii/fpsms/modules/logistic/web/models/SaveLogisticsBatchRequest.kt
  99. +15
    -2
      src/main/java/com/ffii/fpsms/modules/master/entity/Bom.kt
  100. +0
    -21
      src/main/java/com/ffii/fpsms/modules/master/entity/BomMaterial.kt

+ 33
- 0
.cursor/rules/frontend-prevent-duplicate-api-calls.mdc 查看文件

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


+ 643
- 0
.cursor/skills/fp-mtms-version-checklist/SKILL.md 查看文件

@@ -0,0 +1,643 @@
---
name: fp-mtms-version-checklist
description: >-
Updates the FP-MTMS Version Programs and Functions Checklist Excel on each
developer's synced SharePoint folder. Use only when the user explicitly
invokes this skill or asks to update the FP-MTMS version control checklist,
page/program version sheet, or function version sheet.
disable-model-invocation: true
---

# FP-MTMS Version Checklist Excel

## Purpose

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

## Language

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

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

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

**Exception — do NOT bilingualize:**

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

### Format

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

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

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

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

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

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

## Excel path (per developer)

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

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

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

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

Do not edit `.url` shortcuts or Downloads copies unless the user explicitly asks.

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

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

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

Instead:

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

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

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

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

Before drafting or writing any row:

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

## History shift (required on every update)

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

| Before write | After shift |
|--------------|-------------|
| Latest | → 1st previous |
| 1st previous | → 2nd previous |
| 2nd previous | → 3rd previous |
| 3rd previous | → **deleted** (dropped) |

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

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

Treat each “slot” as a triple:

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

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

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

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

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

Always show the shift in the preview (old Latest → new 1st, etc.).

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

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

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

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

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

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

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

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

**Cell layout example:**

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

**Rules:**

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

## Batch updates

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

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

## Sheets and columns

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

### Sheet: `Page&Program Name`

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

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

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

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

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

Format:

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

Examples:

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

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

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

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

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

Format (one file per line):

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

Multi-file example:

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

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

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

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

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

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

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

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

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

Examples:

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

## Preview format (required before save)

Show a clear preview, for example:

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

Row 12 (UPDATE existing | Ref 7 | Page: 批號追溯 | Function: ItemTracingFlowGraphSearch — src/components/ItemTracing/ItemTracingFlowGraphSearch.tsx)
History shift:
old Latest (H/I/J) → 1st
old 1st → 2nd
old 2nd → 3rd
old 3rd → DROP
New Latest:
H: v1.2.0 2026-07-14
I: 新增流程圖節點搜尋。
Add flow-graph node search.
ItemTracingFlowGraphSearch.tsx
L42–88: 搜尋框與節點高亮。
L42–88: search box and node highlight.
J: <developer>

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

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

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

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

## Workflow

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

## Code comments on every updated function (required)

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

### Comment contents (required fields)

Must include:

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

### Canonical format

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

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

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

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

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

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

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

### Placement rules

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

### Preview

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

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

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

## Inference hints (for filling the preview)

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

## Do not

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

+ 5
- 1
.gitignore 查看文件

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


+ 387
- 173
python/Bag3.py 查看文件

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

二進制
python/__pycache__/Bag3.cpython-313.pyc 查看文件


+ 32
- 2
python/installAndExe.txt 查看文件

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

+ 22
- 0
src/main/java/com/ffii/fpsms/config/security/SecurityConfig.java 查看文件

@@ -91,6 +91,28 @@ public class SecurityConfig {
.hasAnyAuthority("TESTING", "ADMIN", "STOCK")
.requestMatchers(HttpMethod.GET, "/product-process/Demo/Process/alerts/fg-qc-putaway")
.hasAuthority("TESTING")
.requestMatchers(HttpMethod.GET, "/device-presence/ping").authenticated()
.requestMatchers(HttpMethod.POST, "/device-presence/heartbeat").authenticated()
.requestMatchers(HttpMethod.GET, "/device-presence/active")
.hasAnyAuthority("TESTING", "ADMIN")
.requestMatchers(HttpMethod.GET, "/device-presence/history")
.hasAnyAuthority("TESTING", "ADMIN")
.requestMatchers(HttpMethod.GET, "/printer-monitor/status")
.hasAnyAuthority("TESTING", "ADMIN")
.requestMatchers(HttpMethod.GET, "/printer-monitor/history")
.hasAnyAuthority("TESTING", "ADMIN")
.requestMatchers(HttpMethod.POST, "/printer-monitor/check")
.hasAnyAuthority("TESTING", "ADMIN")
.requestMatchers(HttpMethod.GET, "/label-printer-monitor/status")
.hasAnyAuthority("TESTING", "ADMIN")
.requestMatchers(HttpMethod.POST, "/label-printer-monitor/check")
.hasAnyAuthority("TESTING", "ADMIN")
.requestMatchers(HttpMethod.GET, "/label-printer-monitor/label-stats")
.hasAnyAuthority("TESTING", "ADMIN")
.requestMatchers(HttpMethod.GET, "/label-printer-monitor/odometer-stats")
.hasAnyAuthority("TESTING", "ADMIN")
.requestMatchers(HttpMethod.POST, "/stockAdjustment/submit")
.hasAnyAuthority("ADMIN", "INVENTORY_ADJUST")
.anyRequest().authenticated())
.httpBasic(httpBasic -> httpBasic.authenticationEntryPoint(
(request, response, authException) -> sendUnauthorizedJson(response, "Unauthorized", "UNAUTHORIZED")))


+ 48
- 0
src/main/java/com/ffii/fpsms/m18/entity/M18BomShopSyncLog.kt 查看文件

@@ -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 查看文件

@@ -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 查看文件

@@ -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 查看文件

@@ -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 查看文件

@@ -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 查看文件

@@ -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 查看文件

@@ -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 查看文件

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

+ 697
- 0
src/main/java/com/ffii/fpsms/m18/service/M18BomForShopService.kt 查看文件

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

/**
* From the **finished-good** [Bom.item] stock unit [com.ffii.fpsms.modules.master.entity.UomConversion.code]
* (pattern `LETTER_PREFIX` + `DIGITS` + `UNIT_SUFFIX`, e.g. PACK2LB): harvest qty = outputQty × digits, unit = suffix.
* Falls back to plain [outputQty] and null unit when item/stock UOM/code is missing or does not match.
*/
private fun resolveUdfHarvestFields(bom: Bom, outputQty: BigDecimal): Pair<String, String?> {
val itemId = bom.item?.id
if (itemId == null) {
logger.warn("[M18 BOM] bom.item id missing; udfHarvest=outputQty only. bomId=${bom.id}")
return outputQty.stripTrailingZeros().toPlainString() to null
}
// outputQty is stored in base unit; harvest = base qty, unit = base suffix or uom code tail.
val baseUom = bom.uom ?: itemUomService.findBaseUnitByItemId(itemId)?.uom
val harvestUnit = bom.outputQtyUom?.trim()?.takeIf { it.isNotEmpty() }
?: baseUom?.udfShortDesc?.trim()?.takeIf { it.isNotEmpty() }
?: baseUom?.code?.trim()?.takeIf { it.isNotEmpty() }
return outputQty.stripTrailingZeros().toPlainString() to harvestUnit
}

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

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

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

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

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

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

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

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

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

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

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

return PoSupplierUnitContext(supplierM18Id, purchaseUnitM18Id)
}

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

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

if (supplierCode == null) {
return directM18Id
}

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

+ 41
- 0
src/main/java/com/ffii/fpsms/m18/service/M18BomHeaderLookupService.kt 查看文件

@@ -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 查看文件

@@ -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 查看文件

@@ -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 查看文件

@@ -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 查看文件

@@ -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 查看文件

@@ -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 查看文件

@@ -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 查看文件

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

+ 188
- 118
src/main/java/com/ffii/fpsms/modules/chart/service/ChartService.kt 查看文件

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

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

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

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

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

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

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

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

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

+ 11
- 5
src/main/java/com/ffii/fpsms/modules/chart/web/ChartController.kt 查看文件

@@ -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 查看文件

@@ -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 查看文件

@@ -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 查看文件

@@ -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 查看文件

@@ -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 查看文件

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

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

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

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

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

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

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

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

+ 504
- 0
src/main/java/com/ffii/fpsms/modules/common/alert/SchedulerSyncAlertService.kt 查看文件

@@ -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. vluk@2fi-solutions.com.hk)"
}
if (!syncAlertEmailSender.isSmtpConfigured()) {
return syncAlertEmailSender.smtpConfigIssue()
?: "Configure MAIL.smtp.username, MAIL.smtp.password, MAIL.smtp.host in settings table"
}
val from = syncAlertEmailSender.resolveFromAddress() ?: "unknown"
val body =
message?.trim()?.takeIf { it.isNotEmpty() }
?: (
"FPSMS sync alert email test.\n\n" +
"If you receive this, Office 365 SMTP is working.\n" +
"From (MAIL.smtp.username): $from\n" +
"Time: ${LocalDateTime.now()}"
)
val emailSubject =
subject?.trim()?.takeIf { it.isNotEmpty() }
?: "${properties.email.subjectPrefix} [TEST]"
return try {
syncAlertEmailSender.send(emailTo, emailSubject, body)
"Test email sent from $from to ${emailTo.joinToString()}\nSubject: $emailSubject"
} catch (e: Exception) {
val hint =
when {
e.message?.contains("530", ignoreCase = true) == true ||
e.message?.contains("not authenticated", ignoreCase = true) == true ->
"\n\nOffice 365 fix checklist:\n" +
"1. UPDATE settings SET value='true' WHERE name='MAIL.smtp.auth';\n" +
"2. MAIL.smtp.username must match the mailbox (vluk@2fi-solutions.com.hk)\n" +
"3. Use an App password if MFA is on (not your normal login password)\n" +
"4. IT must enable 'Authenticated SMTP' for this mailbox in Exchange admin\n" +
"5. Restart backend after changing settings (SMTP client is cached)"
else -> ""
}
"Email send failed: ${e.message}$hint"
}
}

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

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

+ 27
- 0
src/main/java/com/ffii/fpsms/modules/common/alert/SmsAlertConfig.kt 查看文件

@@ -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 查看文件

@@ -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 查看文件

@@ -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 查看文件

@@ -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 查看文件

@@ -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 查看文件

@@ -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 查看文件

@@ -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 查看文件

@@ -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 查看文件

@@ -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 查看文件

@@ -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 查看文件

@@ -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 查看文件

@@ -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 查看文件

@@ -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 查看文件

@@ -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 查看文件

@@ -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 查看文件

@@ -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 查看文件

@@ -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
文件差異過大導致無法顯示
查看文件


+ 95
- 0
src/main/java/com/ffii/fpsms/modules/deliveryOrder/service/DoFloorSupplierSettingsService.kt 查看文件

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

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

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

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

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

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

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

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

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

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

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

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

+ 80
- 129
src/main/java/com/ffii/fpsms/modules/deliveryOrder/service/DoPickOrderService.kt 查看文件

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


+ 7
- 20
src/main/java/com/ffii/fpsms/modules/deliveryOrder/service/DoReleaseCoordinatorService.kt 查看文件

@@ -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(",")})


+ 583
- 0
src/main/java/com/ffii/fpsms/modules/deliveryOrder/service/DoReplenishmentService.kt 查看文件

@@ -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,
)
}
}
}

+ 37
- 4
src/main/java/com/ffii/fpsms/modules/deliveryOrder/service/DoWorkbenchDopoAssignmentService.kt 查看文件

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

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

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

fun extractIds(rows: List<Map<String, Any?>>): List<Long> {
if (rows.isEmpty()) return emptyList()
@@ -205,7 +219,7 @@ open class DoWorkbenchDopoAssignmentService(
"4/F" -> "4/F"
else -> request.storeId
}
println(" DEBUG: assignByLaneForWorkbenchV1 storeId=$actualStoreId date=${request.requiredDate} lane=${request.truckLanceCode} dep=${request.truckDepartureTime}")
println(" DEBUG: assignByLaneForWorkbenchV1 storeId=$actualStoreId date=${request.requiredDate} lane=${request.truckLanceCode} dep=${request.truckDepartureTime} seq=${request.loadingSequence}")

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

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

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

private fun parseDepartureTimeToSql(raw: String?): Time? {
if (raw.isNullOrBlank()) return null
val s = raw.trim()


+ 580
- 213
src/main/java/com/ffii/fpsms/modules/deliveryOrder/service/DoWorkbenchMainService.kt
文件差異過大導致無法顯示
查看文件


+ 1083
- 98
src/main/java/com/ffii/fpsms/modules/deliveryOrder/service/DoWorkbenchReleaseService.kt
文件差異過大導致無法顯示
查看文件


+ 55
- 0
src/main/java/com/ffii/fpsms/modules/deliveryOrder/service/TruckLaneSearchSpec.kt 查看文件

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

+ 65
- 0
src/main/java/com/ffii/fpsms/modules/deliveryOrder/service/WorkbenchReleaseTypeSupport.kt 查看文件

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

/**
* Workbench [delivery_order_pick_order.releaseType] values and SQL filters.
* Legacy `isExtra` tickets are excluded from merge; only used for legacy Etra views.
*/
object WorkbenchReleaseTypeSupport {
const val BATCH = "batch"
const val SINGLE = "single"
const val IS_EXTRA_BATCH = "isExtrabatch"
const val IS_EXTRA_SINGLE = "isExtrasingle"
const val LEGACY_IS_EXTRA = "isExtra"

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

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

fun summaryFilterSql(releaseType: String, column: String = "dop.releaseType"): String =
when (releaseType.trim().lowercase()) {
"batch" -> batchFamilySql(column)
"single" -> singleFamilySql(column)
"isextra" -> legacyIsExtraSql(column)
else -> ""
}

fun assignFilterSql(releaseType: String?, column: String = "dop.releaseType"): String {
val n = releaseType?.trim()?.lowercase().orEmpty()
return when (n) {
"batch" -> batchFamilySql(column)
"single" -> singleFamilySql(column)
"isextra" -> legacyIsExtraSql(column)
else -> ""
}
}

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

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

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

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

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

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

+ 52
- 2
src/main/java/com/ffii/fpsms/modules/deliveryOrder/web/DeliveryOrderController.kt 查看文件

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


+ 85
- 8
src/main/java/com/ffii/fpsms/modules/deliveryOrder/web/DoWorkbenchController.kt 查看文件

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

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

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

@GetMapping("/released-today")
fun getWorkbenchReleasedDoPickOrdersToday(
@RequestParam(required = false) shopName: String?,
@RequestParam(required = false) storeId: String?,
@RequestParam(required = false) truck: String?
@RequestParam(required = false) truck: String?,
@RequestParam(required = false) @DateTimeFormat(iso = DateTimeFormat.ISO.DATE) requiredDate: LocalDate?,
@RequestParam(required = false) releaseType: String?,
): List<ReleasedDoPickOrderListItem> {
return doWorkbenchMainService.findWorkbenchReleasedDeliveryOrderPickOrdersForSelectionToday(shopName, storeId, truck)
return doWorkbenchMainService.findWorkbenchReleasedDeliveryOrderPickOrdersForSelectionToday(
shopName,
storeId,
truck,
requiredDeliveryDate = requiredDate,
releaseTypeFilter = releaseType,
)
}

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

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

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

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

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

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

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

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


+ 30
- 2
src/main/java/com/ffii/fpsms/modules/deliveryOrder/web/models/DoDetailResponse.kt 查看文件

@@ -18,6 +18,12 @@ data class DoDetailResponse(
@JsonFormat(pattern = "yyyy-MM-dd")
val completeDate: LocalDateTime?,
val status: String?,
/** 加單 DO(M18 加單專用同步) */
val isExtra: Boolean = false,
/** 揀貨員名稱(來源:delivery_order_pick_order.handlerName) */
val handlerName: String? = null,
/** 來源 DO 車線(do_pick_order / delivery_order_pick_order) */
val truckLaneCode: String? = null,
val deliveryOrderLines: List<DoDetailLineResponse>
)

@@ -25,12 +31,18 @@ data class DoDetailLineResponse(
val id: Long,
val itemNo: String?,
val qty: java.math.BigDecimal?,
/** Sum of stock_out_line.qty for the linked pick order line; falls back to [qty] when unavailable. */
val actualShippedQty: java.math.BigDecimal?,
val price: java.math.BigDecimal?,
val status: String?,
val itemName: String?,
val uom: String?,
val uomCode: String?,
val shortUom: String?,
/** Sum of (inQty - outQty - holdQty) on AVAILABLE lot lines for this item. */
val stockQty: java.math.BigDecimal?,
/** `available` when stockQty >= qty, else `insufficient`. */
val availableStatus: String?,
)
data class StoreLaneSummary(
val storeId: String,
@@ -49,7 +61,18 @@ data class LaneBtn(
val unassigned: Int,
val total: Int,
// 同一 truckLanceCode + loadingSequence 的 handler 去重后逗号拼接
val handlerName: String? = null
val handlerName: String? = null,
/** Workbench Etra lane: `delivery_order_pick_order.storeId` (2/F, 4/F, …) for assign / modal scope */
val storeId: String? = null,
/** Workbench Etra / lane row: `truckDepartureTime` as ISO local time string for assign-by-lane */
val truckDepartureTime: String? = null,
)

/** All Etra (`releaseType=isExtra`) tickets for a day, grouped by shop then truck (no 2F/4F split in UI). */
data class WorkbenchEtraShopLaneGroup(
val shopCode: String?,
val shopName: String?,
val lanes: List<LaneBtn>,
)
data class AssignByLaneRequest(
val userId: Long,
@@ -57,9 +80,12 @@ data class AssignByLaneRequest(
val truckDepartureTime: String?, // 可选:限定出车时间
val truckLanceCode: String ,
val loadingSequence: Int? = null,
val requiredDate: LocalDate? // 必填:车道编号
val requiredDate: LocalDate?, // 必填:车道编号
/** When `isExtra`, assignment candidates are limited to `releaseType = isExtra` rows. */
val releaseType: String? = null,
)
data class DoPickOrderSummaryItem(
@JsonFormat(pattern = "HH:mm")
val truckDepartureTime: java.time.LocalTime?,
val truckLanceCode: String?,
val loadingSequence: Int?,
@@ -101,11 +127,13 @@ interface DoSearchRowProjection {
}
data class ReleasedDoPickOrderListItem(
val id: Long, // doPickOrderId,用於 assign
@JsonFormat(pattern = "yyyy-MM-dd")
val requiredDeliveryDate: LocalDate?, // Date 欄
val shopCode: String?, // Shop
val shopName: String?, // Shop
val storeId: String?, // 2/F or 4/F
val truckLanceCode: String?, // Truck (Lane)
@JsonFormat(pattern = "HH:mm")
val truckDepartureTime: LocalTime?, // Truck 時間
val deliveryOrderCodes: List<String> // 多個 DO code,前端換行顯示
)


+ 62
- 0
src/main/java/com/ffii/fpsms/modules/deliveryOrder/web/models/DoReplenishmentModels.kt 查看文件

@@ -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 查看文件

@@ -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 查看文件

@@ -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 查看文件

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

+ 1
- 0
src/main/java/com/ffii/fpsms/modules/deliveryOrder/web/models/SaveDeliveryOrderRequest.kt 查看文件

@@ -20,6 +20,7 @@ data class SaveDeliveryOrderRequest(
val handlerId: Long?,
val m18BeId: Long?,
val deleted: Boolean? = false,
val isExtra: Boolean? = false,
)

data class SaveDeliveryOrderStatusRequest(


+ 5
- 0
src/main/java/com/ffii/fpsms/modules/deliveryOrder/web/models/TicketReleaseTableResponse.kt 查看文件

@@ -1,5 +1,6 @@
package com.ffii.fpsms.modules.deliveryOrder.web.models

import com.fasterxml.jackson.annotation.JsonFormat
import java.time.LocalDateTime
import java.time.LocalDate
import java.time.LocalTime
@@ -15,14 +16,18 @@ data class TicketReleaseTableResponse(
val loadingSequence: Int?,
val ticketStatus: String?,
val truckId: Long?,
@JsonFormat(pattern = "HH:mm")
val truckDepartureTime: LocalTime?,
val shopId: Long?,
val handledBy: Long?,
@JsonFormat(pattern = "yyyy-MM-dd HH:mm:ss")
val ticketReleaseTime: LocalDateTime?,
@JsonFormat(pattern = "yyyy-MM-dd HH:mm:ss")
val ticketCompleteDateTime: LocalDateTime?,
val truckLanceCode: String?,
val shopCode: String?,
val shopName: String?,
@JsonFormat(pattern = "yyyy-MM-dd")
val requiredDeliveryDate: LocalDate?,
val handlerName: String?,
val numberOfFGItems: Int = 0,


+ 4
- 0
src/main/java/com/ffii/fpsms/modules/deliveryOrder/web/models/TruckScheduleDashboardResponse.kt 查看文件

@@ -1,5 +1,6 @@
package com.ffii.fpsms.modules.deliveryOrder.web.models

import com.fasterxml.jackson.annotation.JsonFormat
import java.time.LocalDateTime
import java.time.LocalTime

@@ -7,13 +8,16 @@ data class TruckScheduleDashboardResponse(
val storeId: String?,
val truckId: Long?,
val truckLanceCode: String?,
@JsonFormat(pattern = "HH:mm")
val truckDepartureTime: LocalTime?,
val numberOfShopsToServe: Int,
val numberOfPickTickets: Int,
val totalItemsToPick: Int,
val numberOfTicketsReleased: Int,
@JsonFormat(pattern = "yyyy-MM-dd HH:mm:ss")
val firstTicketStartTime: LocalDateTime?,
val numberOfTicketsCompleted: Int,
@JsonFormat(pattern = "yyyy-MM-dd HH:mm:ss")
val lastTicketEndTime: LocalDateTime?,
val pickTimeTakenMinutes: Long?
)


+ 11
- 0
src/main/java/com/ffii/fpsms/modules/deliveryOrder/web/models/WorkbenchBatchReleaseRequest.kt 查看文件

@@ -0,0 +1,11 @@
package com.ffii.fpsms.modules.deliveryOrder.web.models

/**
* Workbench batch release body (async-v2 / sync-v2).
* [mergeExtraIntoLaneTicket]: when true, isExtra DOs join batch/single family (isExtrabatch / isExtrasingle);
* when false, standalone `releaseType=isExtra` tickets with `TI-E-` prefix.
*/
data class WorkbenchBatchReleaseRequest(
val ids: List<Long> = emptyList(),
val mergeExtraIntoLaneTicket: Boolean = true,
)

+ 35
- 0
src/main/java/com/ffii/fpsms/modules/deliveryOrder/web/models/WorkbenchMergeTicketModels.kt 查看文件

@@ -0,0 +1,35 @@
package com.ffii.fpsms.modules.deliveryOrder.web.models

import com.fasterxml.jackson.annotation.JsonFormat
import java.time.LocalDate
import java.time.LocalTime

data class WorkbenchMergeTicketCandidate(
val id: Long,
val ticketNo: String?,
val releaseType: String?,
val shopId: Long?,
val shopCode: String?,
val shopName: String?,
val storeId: String?,
val truckId: Long?,
@JsonFormat(pattern = "yyyy-MM-dd")
val requiredDeliveryDate: LocalDate?,
val truckLanceCode: String?,
@JsonFormat(pattern = "HH:mm")
val truckDepartureTime: LocalTime?,
val loadingSequence: Int?,
val deliveryOrderCodes: List<String>,
/** Stable lane identity for same-truck merge matching (2/F, 4/F, truck-X). */
val laneKey: String,
)

data class WorkbenchMergeTicketCandidatesResponse(
val batchFamilyTickets: List<WorkbenchMergeTicketCandidate>,
val isExtraTickets: List<WorkbenchMergeTicketCandidate>,
)

data class WorkbenchMergeTicketsRequest(
val batchOrSingleDopoId: Long,
val isExtraDopoId: Long,
)

+ 5
- 0
src/main/java/com/ffii/fpsms/modules/deliveryOrder/web/models/WorkbenchTicketReleaseTableResponse.kt 查看文件

@@ -1,5 +1,6 @@
package com.ffii.fpsms.modules.deliveryOrder.web.models

import com.fasterxml.jackson.annotation.JsonFormat
import java.time.LocalDate
import java.time.LocalDateTime
import java.time.LocalTime
@@ -10,13 +11,17 @@ data class WorkbenchTicketReleaseTableResponse(
val ticketNo: String?,
val loadingSequence: Int?,
val ticketStatus: String?,
@JsonFormat(pattern = "HH:mm")
val truckDepartureTime: LocalTime?,
val handledBy: Long?,
@JsonFormat(pattern = "yyyy-MM-dd HH:mm:ss")
val ticketReleaseTime: LocalDateTime?,
@JsonFormat(pattern = "yyyy-MM-dd HH:mm:ss")
val ticketCompleteDateTime: LocalDateTime?,
val truckLanceCode: String?,
val shopCode: String?,
val shopName: String?,
@JsonFormat(pattern = "yyyy-MM-dd")
val requiredDeliveryDate: LocalDate?,
val handlerName: String?,
val numberOfFGItems: Int = 0,


+ 1
- 0
src/main/java/com/ffii/fpsms/modules/jobOrder/entity/JobOrderRepository.kt 查看文件

@@ -143,6 +143,7 @@ interface JobOrderRepository : AbstractRepository<JobOrder, Long> {
JOIN b.bomProcesses bp
JOIN bp.process p
WHERE jo.deleted = false
AND (jo.isHidden = false OR jo.isHidden IS NULL)
AND jo.planStart >= :planStartFrom
AND jo.planStart < :planStartToExclusive
AND p.name = :processName


+ 1
- 7
src/main/java/com/ffii/fpsms/modules/jobOrder/scheduler/LaserBag2AutoSendScheduler.kt 查看文件

@@ -25,16 +25,10 @@ class LaserBag2AutoSendScheduler(
return
}
try {
val report = laserBag2AutoSendService.runAutoSend(
laserBag2AutoSendService.runAutoSend(
planStart = LocalDate.now(),
limitPerRun = limitPerRun,
)
logger.info(
"Laser Bag2 scheduler: processed {}/{} job orders for {}",
report.jobOrdersProcessed,
report.jobOrdersFound,
report.planStart,
)
} catch (e: Exception) {
logger.error("Laser Bag2 scheduler failed", e)
}


+ 35
- 5
src/main/java/com/ffii/fpsms/modules/jobOrder/service/JoPickOrderService.kt 查看文件

@@ -51,6 +51,7 @@ import com.ffii.fpsms.modules.stock.entity.enum.StockInLineStatus
import com.ffii.fpsms.modules.stock.entity.StockInLineRepository
import java.time.LocalDate
import com.ffii.fpsms.modules.jobOrder.web.model.MaterialPickStatusItem
import com.ffii.fpsms.modules.jobOrder.web.model.PlasticBoxCartonQtyDashboardRecord
@Service
open class JoPickOrderService(
private val joPickOrderRepository: JoPickOrderRepository,
@@ -1319,13 +1320,13 @@ open fun getJobOrderListForPrintQrCode(date: LocalDate): List<JobOrderListForPri
val allStockInLines = stockInLineRepository.findByReceiptDateAndDeletedFalse(date)
println("📦 Found ${allStockInLines.size} StockInLines with receiptDate = $date")
println("📦 All StockInLines: ${allStockInLines.map { it.id }}, ${allStockInLines.map { it.jobOrder?.id }}")
println("📦 All StockInLines: ${allStockInLines.map { it.status }}, ${allStockInLines.map { it.jobOrder?.bom?.description }}")
println("📦 All StockInLines: ${allStockInLines.map { it.status }}, ${allStockInLines.map { it.jobOrder?.bom?.bomKind }}")
// 2. 进一步过滤:status = "received" 或 "completed",且 BOM type = "WIP" 或 "FG"
val stockInLines = allStockInLines.filter {
it.jobOrder != null
//it.status == "received" && // 只显示 QC 完成但尚未 putaway 的
// it.jobOrder?.bom?.description in listOf("WIP", "FG")
// it.jobOrder?.bom?.bomKind in listOf("WIP", "FG")
}
println("✅ After filtering: ${stockInLines.size} StockInLines")

@@ -1347,7 +1348,7 @@ open fun getJobOrderListForPrintQrCode(date: LocalDate): List<JobOrderListForPri

for (stockInLine in stockInLines) {
val jobOrder = jobOrders.find { it.id == stockInLine.jobOrder?.id }
if (jobOrder != null && jobOrder.bom?.description in listOf("WIP", "FG")) {
if (jobOrder != null && jobOrder.bom?.bomKind in listOf("WIP", "FG")) {
// Check if this job order is already in the result (to avoid duplicates)
val existing = result.find {
it.stockInLineId == stockInLine.id
@@ -1365,7 +1366,7 @@ open fun getJobOrderListForPrintQrCode(date: LocalDate): List<JobOrderListForPri
finihedTime = jobOrder.planEnd ?: jobOrder.modified ?: LocalDateTime.now()
)
result.add(response)
println("✅ Added StockInLine ${stockInLine.id} (JobOrder ${jobOrder.id}, BOM: ${jobOrder.bom?.description})")
println("✅ Added StockInLine ${stockInLine.id} (JobOrder ${jobOrder.id}, BOM: ${jobOrder.bom?.bomKind})")
}
} else {
println("❌ StockInLine ${stockInLine.id}: JobOrder ${stockInLine.jobOrder?.id} not found or BOM type not WIP/FG")
@@ -1688,6 +1689,9 @@ open fun getCompletedJobOrderPickOrders(completedDate: LocalDate?): List<Map<Str
"secondScanCompleted" to secondScanCompleted,
"totalItems" to joPickOrders.size,
"completedItems" to joPickOrders.count { it.matchStatus == JoPickOrderStatus.completed },
"plasticBoxCartonQty2f" to pickOrder.plasticBoxCartonQty2f,
"plasticBoxCartonQty3f" to pickOrder.plasticBoxCartonQty3f,
"plasticBoxCartonQty4f" to pickOrder.plasticBoxCartonQty4f,
)
} else {
println("❌ Pick order ${pickOrder.id} has no job order, skipping.")
@@ -1703,6 +1707,32 @@ open fun getCompletedJobOrderPickOrders(completedDate: LocalDate?): List<Map<Str
emptyList()
}
}

open fun getPlasticBoxCartonQtyDashboard(
from: LocalDate,
to: LocalDate,
): List<PlasticBoxCartonQtyDashboardRecord> {
val fromDt = from.atStartOfDay()
val toExclusive = to.plusDays(1).atStartOfDay()
return pickOrderRepository
.findCompletedWithPlasticBoxCartonQtyInPlanStartRange(
PickOrderStatus.COMPLETED,
fromDt,
toExclusive,
)
.mapNotNull { pickOrder ->
val planStart = pickOrder.jobOrder?.planStart ?: return@mapNotNull null
val statLocalDate = planStart.toLocalDate()
PlasticBoxCartonQtyDashboardRecord(
pickOrderId = pickOrder.id,
statDate = "${statLocalDate.year}-${"%02d".format(statLocalDate.monthValue)}-${"%02d".format(statLocalDate.dayOfMonth)}",
plasticBoxCartonQty2f = pickOrder.plasticBoxCartonQty2f,
plasticBoxCartonQty3f = pickOrder.plasticBoxCartonQty3f,
plasticBoxCartonQty4f = pickOrder.plasticBoxCartonQty4f,
)
}
}

open fun getJobOrderPickOrders(date: LocalDate?, status: PickOrderStatus?): List<Map<String, Any?>> {
println("=== getJobOrderPickOrders ===")
println("date: $date, status: $status")
@@ -2352,7 +2382,7 @@ open fun getCompletedJobOrderPickOrders(completedDate: LocalDate?): List<Map<Str
jobOrderType = jobOrderType?.name,
itemId = item.id ?: 0L,
itemName = item.name ?: "",
bomDescription = bom?.description,
bomDescription = bom?.bomKind,
reqQty = jobOrder.reqQty ?: BigDecimal.ZERO,
//uomId = bom.outputQtyUom?.id : 0L,
uomId = 0,


+ 22
- 22
src/main/java/com/ffii/fpsms/modules/jobOrder/service/JoWorkbenchMainService.kt 查看文件

@@ -3,11 +3,12 @@ package com.ffii.fpsms.modules.jobOrder.service
import com.ffii.fpsms.modules.jobOrder.entity.JoPickOrderRepository
import com.ffii.fpsms.modules.jobOrder.entity.JobOrderRepository
import com.ffii.fpsms.modules.jobOrder.enums.JobOrderStatus
import com.ffii.fpsms.modules.jobOrder.web.model.JobOrderBasicInfoResponse
import com.ffii.fpsms.modules.jobOrder.web.model.JobOrderLotsHierarchicalResponse
import com.ffii.fpsms.modules.jobOrder.web.model.PickOrderInfoWorkbenchResponse
import com.ffii.fpsms.modules.jobOrder.web.model.JobOrderLotsHierarchicalWorkbenchResponse
import com.ffii.fpsms.modules.jobOrder.web.model.JobOrderBasicInfoWorkbenchResponse
import com.ffii.fpsms.modules.jobOrder.web.model.LotDetailResponse
import com.ffii.fpsms.modules.jobOrder.web.model.PickOrderInfoResponse
import com.ffii.fpsms.modules.jobOrder.web.model.PickOrderLineWithLotsResponse
import com.ffii.fpsms.modules.jobOrder.web.model.PickOrderLineWithLotsWorkbenchResponse
import com.ffii.fpsms.modules.jobOrder.web.model.StockOutLineDetailResponse
import com.ffii.fpsms.modules.master.web.models.MessageResponse
import com.ffii.fpsms.modules.pickOrder.entity.PickOrderLineRepository
@@ -49,11 +50,8 @@ open class JoWorkbenchMainService(
private val suggestedPickLotWorkbenchService: SuggestedPickLotWorkbenchService,
private val stockOutLineWorkbenchService: StockOutLineWorkbenchService,
) {
// Keep aligned with SuggestedPickLotWorkbenchService default excludes for diagnosis logs.
private val workbenchDefaultExcludeWarehouseCodes: Set<String> = setOf(
//"2F-W202-01-00",
//"2F-W200-#A-00",
)
private val workbenchDefaultExcludeWarehouseCodes: Set<String> =
JoWorkbenchPickConstants.DEFAULT_EXCLUDE_WAREHOUSE_CODES

private fun debugPrintSuggestionNullReasons(pickOrderId: Long) {
val pickOrder = pickOrderRepository.findById(pickOrderId).orElse(null) ?: return
@@ -194,9 +192,9 @@ open class JoWorkbenchMainService(
/**
* Hierarchical pick UI for JO Workbench: available qty **in − out**; stockouts include **suggestedPickQty** when SPL matches SOL lot line.
*/
open fun getJobOrderLotsHierarchicalByPickOrderIdWorkbench(pickOrderId: Long): JobOrderLotsHierarchicalResponse {
println("=== JoWorkbenchMainService.getJobOrderLotsHierarchicalByPickOrderIdWorkbench ===")
println("pickOrderId: $pickOrderId")
open fun getJobOrderLotsHierarchicalByPickOrderIdWorkbench(pickOrderId: Long): JobOrderLotsHierarchicalWorkbenchResponse {
//println("=== JoWorkbenchMainService.getJobOrderLotsHierarchicalByPickOrderIdWorkbench ===")
//println("pickOrderId: $pickOrderId")

return try {
val pickOrder = pickOrderRepository.findById(pickOrderId).orElse(null)
@@ -299,8 +297,8 @@ open class JoWorkbenchMainService(
}

val joPickOrders = joPickOrderRepository.findByPickOrderId(pickOrder.id!!)
val pickOrderInfo = PickOrderInfoResponse(
val pickOrderInfo = PickOrderInfoWorkbenchResponse(
id = pickOrder.id,
code = pickOrder.code,
consoCode = pickOrder.consoCode,
@@ -310,10 +308,12 @@ open class JoWorkbenchMainService(
type = pickOrder.type?.value,
status = pickOrder.status?.value,
assignTo = pickOrder.assignTo?.id,
jobOrder = JobOrderBasicInfoResponse(
jobOrder = JobOrderBasicInfoWorkbenchResponse(
id = jobOrder.id!!,
code = jobOrder.code ?: "",
name = "Job Order ${jobOrder.code}"
name = "Job Order ${jobOrder.code}",
itemCode = jobOrder.bom?.code,
itemName = jobOrder.bom?.name,
)
)

@@ -342,7 +342,7 @@ open class JoWorkbenchMainService(
val handlerNameInner = jpoInner?.handledBy?.let { uid ->
userService.find(uid).orElse(null)?.name
}
println("handlerName: $handlerNameInner")
//println("handlerName: $handlerNameInner")
val availableQty = if (sol?.status == "rejected") {
null
} else {
@@ -429,7 +429,7 @@ open class JoWorkbenchMainService(
)
}

PickOrderLineWithLotsResponse(
PickOrderLineWithLotsWorkbenchResponse(
id = pol.id!!,
itemId = item?.id,
itemCode = item?.code,
@@ -445,7 +445,7 @@ open class JoWorkbenchMainService(
)
}

JobOrderLotsHierarchicalResponse(
JobOrderLotsHierarchicalWorkbenchResponse(
pickOrder = pickOrderInfo,
pickOrderLines = pickOrderLinesResult
)
@@ -456,10 +456,10 @@ open class JoWorkbenchMainService(
}
}

private fun emptyHierarchical(message: String): JobOrderLotsHierarchicalResponse {
private fun emptyHierarchical(message: String): JobOrderLotsHierarchicalWorkbenchResponse {
println("❌ $message")
return JobOrderLotsHierarchicalResponse(
pickOrder = PickOrderInfoResponse(
return JobOrderLotsHierarchicalWorkbenchResponse(
pickOrder = PickOrderInfoWorkbenchResponse(
id = null,
code = null,
consoCode = null,
@@ -467,7 +467,7 @@ open class JoWorkbenchMainService(
type = null,
status = null,
assignTo = null,
jobOrder = JobOrderBasicInfoResponse(0, "", "")
jobOrder = JobOrderBasicInfoWorkbenchResponse(0, "", "",null,null)
),
pickOrderLines = emptyList()
)


+ 36
- 0
src/main/java/com/ffii/fpsms/modules/jobOrder/service/JoWorkbenchPickConstants.kt 查看文件

@@ -0,0 +1,36 @@
package com.ffii.fpsms.modules.jobOrder.service

/**
* JO workbench pick constants.
*
* [DEFAULT_EXCLUDE_WAREHOUSE_CODES] applies on **assign / first prime** ([JoWorkbenchMainService])
* and on **scan-pick re-suggest** ([com.ffii.fpsms.modules.deliveryOrder.service.DoWorkbenchMainService]).
*/
object JoWorkbenchPickConstants {
val DEFAULT_EXCLUDE_WAREHOUSE_CODES: Set<String> = setOf(
"4F-W402-01-00",
"4F-W402-02-00",
"4F-W402-03-00",
"4F-W402-04-00",
"4F-W402-05-00",
"4F-W402-#A-00",
"4F-W402-#B-00",
"4F-W402-#C-00",
"4F-W402-#D-00",
"4F-W402-#E-00",
"4F-W402-#F-00",
"4F-W402-#G-00",
"4F-W402-#H-00",
"4F-W402-#I-00",
"4F-W402-#J-00",
"4F-W402-#K-00",
"4F-W402-#L-00",
"4F-W402-#M-00",
"4F-W402-#N-00",
"4F-W402-#O-00",
"4F-W402-#P-00",
"4F-W402-#Q-00",
"4F-W402-#R-00",
"4F-W402-#S-00",
)
}

+ 27
- 9
src/main/java/com/ffii/fpsms/modules/jobOrder/service/JobOrderBomMaterialService.kt 查看文件

@@ -8,6 +8,8 @@ import com.ffii.fpsms.modules.jobOrder.web.model.CreateJobOrderBomMaterialReques
import com.ffii.fpsms.modules.master.entity.ItemsRepository
import com.ffii.fpsms.modules.master.entity.UomConversionRepository
import com.ffii.fpsms.modules.master.service.ItemUomService
import com.ffii.fpsms.modules.master.service.BomMaterialQtyService
import com.ffii.fpsms.modules.master.service.BomOutputQtyService
import com.ffii.fpsms.modules.master.web.models.MessageResponse
import org.springframework.stereotype.Service
import java.math.BigDecimal
@@ -20,22 +22,23 @@ open class JobOrderBomMaterialService(
private val jobOrderRepository: JobOrderRepository,
private val itemsRepository: ItemsRepository,
val itemUomService: ItemUomService,
private val uomConversionRepository: UomConversionRepository
private val uomConversionRepository: UomConversionRepository,
private val bomOutputQtyService: BomOutputQtyService,
private val bomMaterialQtyService: BomMaterialQtyService,
) {
open fun createJobOrderBomMaterialRequests(joId: Long): List<CreateJobOrderBomMaterialRequest> {
val zero = BigDecimal.ZERO
val jo = jobOrderRepository.findById(joId).getOrNull() ?: throw NoSuchElementException()
val proportion = (jo.reqQty ?: zero).divide(jo.bom?.outputQty ?: BigDecimal.ONE, 5, RoundingMode.HALF_UP)
val bomStockOutputQty = bomOutputQtyService.stockOutputQty(jo.bom)
val proportion = (jo.reqQty ?: zero).divide(bomStockOutputQty, 5, RoundingMode.HALF_UP)

val jobmRequests = jo.bom?.bomMaterials?.map { bm ->
// val salesUnit = bm.item?.id?.let { itemUomService.findSalesUnitByItemId(it) }
val stockUnit = bm.item?.id?.let { itemUomService.findStockUnitByItemId(it) }
CreateJobOrderBomMaterialRequest(
joId = joId,
itemId = bm.item?.id,
//reqQty = (bm.qty?.times(proportion) ?: zero).setScale(0,RoundingMode.CEILING),
reqQty = (bm.stockQty?.times(proportion) ?: zero).setScale(0, RoundingMode.CEILING),
uomId = stockUnit?.uom?.id
reqQty = bomMaterialQtyService.stockQtyForJob(bm, proportion),
uomId = bomMaterialQtyService.stockUomIdForJob(bm) ?: stockUnit?.uom?.id
)
} ?: listOf()

@@ -43,10 +46,25 @@ open class JobOrderBomMaterialService(
}

fun createJobOrderBomMaterials(request: List<CreateJobOrderBomMaterialRequest>): MessageResponse {
val joIds = request.mapNotNull { it.joId }.distinct()
val itemIds = request.mapNotNull { it.itemId }.distinct()
val uomIds = request.mapNotNull { it.uomId }.distinct()

// 批量取回,避免在 map 裡逐筆 findById 造成 N+1。
val joById = if (joIds.isNotEmpty()) {
jobOrderRepository.findAllById(joIds).associateBy { it.id }
} else emptyMap()
val itemById = if (itemIds.isNotEmpty()) {
itemsRepository.findAllById(itemIds).associateBy { it.id }
} else emptyMap()
val uomById = if (uomIds.isNotEmpty()) {
uomConversionRepository.findAllById(uomIds).associateBy { it.id }
} else emptyMap()

val joBomMaterials = request.map { req ->
val jo = req.joId?.let { jobOrderRepository.findById(it).getOrNull() }
val item = req.itemId?.let { itemsRepository.findById(it).getOrNull() }
val uom = req.uomId?.let { uomConversionRepository.findById(it).getOrNull() }
val jo = req.joId?.let { joById[it] }
val item = req.itemId?.let { itemById[it] }
val uom = req.uomId?.let { uomById[it] }
val roundedReqQty = req.reqQty?.setScale(0, RoundingMode.CEILING)
val statusEnum = JobOrderBomMaterialStatus.entries.find { it.value == req.status }
?: JobOrderBomMaterialStatus.PENDING


+ 243
- 0
src/main/java/com/ffii/fpsms/modules/jobOrder/service/JobOrderPlanStartAutoService.kt 查看文件

@@ -0,0 +1,243 @@
package com.ffii.fpsms.modules.jobOrder.service

import com.ffii.fpsms.modules.jobOrder.entity.JobOrder
import com.ffii.fpsms.modules.jobOrder.entity.JobOrderRepository
import com.ffii.fpsms.modules.jobOrder.enums.JobOrderStatus
import com.ffii.fpsms.modules.pickOrder.entity.PickOrder
import com.ffii.fpsms.modules.pickOrder.entity.PickOrderRepository
import com.ffii.fpsms.modules.productProcess.entity.ProductProcess
import com.ffii.fpsms.modules.productProcess.entity.ProductProcessRepository
import com.ffii.fpsms.modules.productProcess.enums.ProductProcessStatus
import com.ffii.fpsms.modules.stock.entity.StockInLineRepository
import com.ffii.fpsms.modules.stock.service.StockInLineService
import org.slf4j.LoggerFactory
import org.springframework.stereotype.Service
import org.springframework.transaction.support.TransactionTemplate
import java.time.LocalDateTime
import java.util.concurrent.atomic.AtomicBoolean

/**
* Daily batch after plan day ends: at run time (default 00:00:15), process job orders whose
* [JobOrder.planStart] fell on the previous calendar day.
*
* - Branch A: no pick submitted, product process pending → hide job order.
* - Branch B: pick submitted, product process still pending → reschedule to today 00:00:00 and renumber.
*/
@Service
open class JobOrderPlanStartAutoService(
private val jobOrderRepository: JobOrderRepository,
private val pickOrderRepository: PickOrderRepository,
private val productProcessRepository: ProductProcessRepository,
private val stockInLineRepository: StockInLineRepository,
private val jobOrderService: JobOrderService,
private val stockInLineService: StockInLineService,
private val transactionTemplate: TransactionTemplate,
) {
private val logger = LoggerFactory.getLogger(javaClass)
private val inFlight = AtomicBoolean(false)

data class JobOrderPlanStartAutoReport(
val runAt: LocalDateTime,
val targetPlanDayFrom: LocalDateTime,
val targetPlanDayToExclusive: LocalDateTime,
val candidates: Int = 0,
val hidden: Int = 0,
val rescheduled: Int = 0,
val skipped: Int = 0,
val errors: Int = 0,
)

open fun runAutoProcess(runAt: LocalDateTime = LocalDateTime.now()): JobOrderPlanStartAutoReport {
if (!inFlight.compareAndSet(false, true)) {
logger.warn("Job order plan-start auto process skipped: previous run still in flight")
val targetDay = runAt.toLocalDate().minusDays(1)
return JobOrderPlanStartAutoReport(
runAt = runAt,
targetPlanDayFrom = targetDay.atStartOfDay(),
targetPlanDayToExclusive = targetDay.plusDays(1).atStartOfDay(),
)
}
try {
return runAutoProcessInternal(runAt)
} finally {
inFlight.set(false)
}
}

private fun runAutoProcessInternal(runAt: LocalDateTime): JobOrderPlanStartAutoReport {
val targetDay = runAt.toLocalDate().minusDays(1)
val from = targetDay.atStartOfDay()
val toExclusive = targetDay.plusDays(1).atStartOfDay()
val newPlanStart = runAt.toLocalDate().atStartOfDay()

var hidden = 0
var rescheduled = 0
var skipped = 0
var errors = 0

val jobOrders = jobOrderRepository
.findByDeletedFalseAndPlanStartFromBeforeExclusiveOrderByIdAsc(from, toExclusive)
.filter { isEligibleCandidate(it) }

val joIds = jobOrders.mapNotNull { it.id }
val pickOrdersByJoId = loadPickOrdersByJobOrderId(joIds)
val productProcessesByJoId = loadProductProcessesByJobOrderId(joIds)

logger.info(
"Job order plan-start auto: runAt={}, targetPlanDay=[{}, {}), candidates={}",
runAt,
from,
toExclusive,
jobOrders.size,
)

for (jo in jobOrders) {
val joId = jo.id ?: continue
try {
when (
classify(
jo,
pickOrdersByJoId[joId].orEmpty(),
productProcessesByJoId[joId],
)
) {
Branch.HIDE -> {
transactionTemplate.executeWithoutResult {
applyHide(jo, runAt)
}
hidden++
}
Branch.RESCHEDULE -> {
transactionTemplate.executeWithoutResult {
applyReschedule(jo, productProcessesByJoId[joId], newPlanStart, runAt)
}
rescheduled++
}
Branch.SKIP -> skipped++
}
} catch (e: Exception) {
errors++
logger.error("Job order plan-start auto failed for joId={} code={}: {}", joId, jo.code, e.message, e)
}
}

val report = JobOrderPlanStartAutoReport(
runAt = runAt,
targetPlanDayFrom = from,
targetPlanDayToExclusive = toExclusive,
candidates = jobOrders.size,
hidden = hidden,
rescheduled = rescheduled,
skipped = skipped,
errors = errors,
)
logger.info("Job order plan-start auto finished: {}", report)
return report
}

private fun isEligibleCandidate(jo: JobOrder): Boolean {
if (jo.isHidden == true) return false
if (jo.status == JobOrderStatus.COMPLETED) return false
return true
}

private enum class Branch {
HIDE,
RESCHEDULE,
SKIP,
}

private fun classify(
jo: JobOrder,
pickOrders: List<PickOrder>,
productProcess: ProductProcess?,
): Branch {
if (!isProductProcessPendingNotStarted(productProcess)) {
return Branch.SKIP
}
val maxSubmittedLines = pickOrders.maxOfOrNull { it.submittedLines ?: 0 } ?: 0
return when {
maxSubmittedLines == 0 -> Branch.HIDE
maxSubmittedLines > 0 -> Branch.RESCHEDULE
else -> Branch.SKIP
}
}

private fun isProductProcessPendingNotStarted(productProcess: ProductProcess?): Boolean {
if (productProcess == null) return false
if (productProcess.deleted) return false
if (productProcess.status != ProductProcessStatus.PENDING) return false
if (productProcess.startTime != null) return false
return true
}

private fun loadPickOrdersByJobOrderId(jobOrderIds: List<Long>): Map<Long, List<PickOrder>> {
if (jobOrderIds.isEmpty()) return emptyMap()
return pickOrderRepository
.findAllByJobOrder_IdInAndDeletedFalseOrderByJobOrder_IdAscCreatedDesc(jobOrderIds)
.groupBy { it.jobOrder?.id ?: -1L }
.filterKeys { it > 0L }
}

private fun loadProductProcessesByJobOrderId(jobOrderIds: List<Long>): Map<Long, ProductProcess> {
if (jobOrderIds.isEmpty()) return emptyMap()
return productProcessRepository
.findByJobOrder_IdInAndDeletedIsFalse(jobOrderIds)
.mapNotNull { pp -> pp.jobOrder?.id?.let { it to pp } }
.groupBy { it.first }
.mapValues { (_, entries) ->
entries.map { it.second }.firstOrNull { isProductProcessPendingNotStarted(it) }
?: entries.map { it.second }.first()
}
.filterValues { it != null }
.mapValues { it.value!! }
}

private fun applyHide(jo: JobOrder, runAt: LocalDateTime) {
jo.isHidden = true
appendRemarks(jo, "[auto ${runAt.toLocalDate()}] hidden: overdue plan day, no pick submitted, process pending")
jobOrderRepository.save(jo)
logger.info("Job order plan-start auto hid joId={} code={}", jo.id, jo.code)
}

private fun applyReschedule(
jo: JobOrder,
productProcess: ProductProcess?,
newPlanStart: LocalDateTime,
runAt: LocalDateTime,
) {
val pp = productProcess?.takeIf { isProductProcessPendingNotStarted(it) }
?: throw IllegalStateException("Product process not pending for reschedule, joId=${jo.id}")

val newCode = jobOrderService.assignJobNo(newPlanStart)
jo.planStart = newPlanStart
jo.code = newCode
appendRemarks(
jo,
"[auto ${runAt.toLocalDate()}] rescheduled from overdue plan day; pick started, process pending",
)
jobOrderRepository.save(jo)

pp.date = newPlanStart.toLocalDate()
productProcessRepository.save(pp)

val sil = jo.id?.let { stockInLineRepository.findFirstByJobOrder_IdAndDeletedFalse(it) }
if (sil != null) {
sil.lotNo = stockInLineService.assignLotNoForJo(newPlanStart.toLocalDate())
sil.productLotNo = newCode
stockInLineRepository.save(sil)
}

logger.info(
"Job order plan-start auto rescheduled joId={} newCode={} newPlanStart={}",
jo.id,
newCode,
newPlanStart,
)
}

private fun appendRemarks(jo: JobOrder, snippet: String) {
val existing = jo.remarks?.trim().orEmpty()
jo.remarks = if (existing.isEmpty()) snippet else "$existing | $snippet"
}
}

+ 119
- 10
src/main/java/com/ffii/fpsms/modules/jobOrder/service/JobOrderService.kt 查看文件

@@ -69,6 +69,8 @@ import java.time.LocalDate
import java.time.LocalDateTime
import com.ffii.fpsms.modules.master.entity.BomMaterialRepository
import com.ffii.fpsms.modules.master.service.ItemUomService
import com.ffii.fpsms.modules.master.service.BomMaterialQtyService
import com.ffii.fpsms.modules.master.service.BomOutputQtyService
import com.ffii.fpsms.modules.master.web.models.ConvertUomByItemRequest
import com.ffii.fpsms.modules.stock.service.StockInLineService
import com.ffii.fpsms.modules.stock.web.model.SaveStockInLineRequest
@@ -95,8 +97,9 @@ open class JobOrderService(
val productProcessRepository: ProductProcessRepository,
val jobOrderBomMaterialRepository: JobOrderBomMaterialRepository,
val bomMaterialRepository: BomMaterialRepository,
val itemUomService: ItemUomService

val itemUomService: ItemUomService,
private val bomOutputQtyService: BomOutputQtyService,
private val bomMaterialQtyService: BomMaterialQtyService,
) {

open fun allJobOrdersByPage(request: SearchJobOrderInfoRequest): RecordsRes<JobOrderInfo> {
@@ -579,10 +582,14 @@ open class JobOrderService(
}
// ✅ 使用 stockReqQty (bomMaterial.saleQty) 和 stockUom (bomMaterial.salesUnit)
val stockReqQty = jobm.reqQty ?: bomMaterial?.stockQty ?: BigDecimal.ZERO
val stockUomId = bomMaterial?.stockUnit?.toLong()
?: itemUomService.findStockUnitByItemId(itemId)?.uom?.id // Fallback: 从 Item 获取库存单位
?: jobm.uom?.id // 最后的 fallback
val stockReqQty = jobm.reqQty
?: bomMaterial?.let { bm ->
bomMaterialQtyService.stockQtyForJob(bm, BigDecimal.ONE)
}
?: BigDecimal.ZERO
val stockUomId = bomMaterial?.let { bomMaterialQtyService.stockUomIdForJob(it) }
?: itemUomService.findStockUnitByItemId(itemId)?.uom?.id
?: jobm.uom?.id
SavePickOrderLineRequest(
itemId = itemId,
@@ -737,6 +744,92 @@ open class JobOrderService(
}

//Pick Record
private fun validatePickRecordFloor(floor: String?): String {
val normalizedFloor = floor?.trim()?.uppercase()
?: throw BadRequestException("floor is required for pick record print")
if (normalizedFloor !in setOf("2F", "3F", "4F", "ALL")) {
throw BadRequestException("floor must be one of 2F, 3F, 4F, ALL")
}
return normalizedFloor
}

private fun validatePlasticBoxCartonQty(qty: Int?): Int {
val value = qty ?: throw BadRequestException("plasticBoxCartonQty is required")
if (value < 1) {
throw BadRequestException("plasticBoxCartonQty must be at least 1")
}
return value
}

private data class AllFloorsPlasticBoxCartonQty(
val qty2f: Int,
val qty3f: Int,
val qty4f: Int,
val sum: Int,
)

private fun normalizeFloorPlasticBoxCartonQty(qty: Int?): Int {
if (qty == null) return 0
if (qty < 0) {
throw BadRequestException("plastic box carton qty cannot be negative")
}
return qty
}

private fun resolveAllFloorsPlasticBoxCartonQty(
qty2f: Int?,
qty3f: Int?,
qty4f: Int?,
): AllFloorsPlasticBoxCartonQty {
val q2 = normalizeFloorPlasticBoxCartonQty(qty2f)
val q3 = normalizeFloorPlasticBoxCartonQty(qty3f)
val q4 = normalizeFloorPlasticBoxCartonQty(qty4f)
return AllFloorsPlasticBoxCartonQty(q2, q3, q4, q2 + q3 + q4)
}

private fun updatePickOrderPlasticBoxCartonQty(pickOrderId: Long, floor: String, qty: Int) {
val pickOrder = pickOrderRepository.findById(pickOrderId).orElse(null) ?: return
when (floor) {
"2F" -> pickOrder.plasticBoxCartonQty2f = qty
"3F" -> pickOrder.plasticBoxCartonQty3f = qty
"4F" -> pickOrder.plasticBoxCartonQty4f = qty
}
pickOrderRepository.save(pickOrder)
}

private fun persistAllFloorsPlasticBoxCartonQty(pickOrderId: Long, all: AllFloorsPlasticBoxCartonQty) {
updatePickOrderPlasticBoxCartonQty(pickOrderId, "2F", all.qty2f)
updatePickOrderPlasticBoxCartonQty(pickOrderId, "3F", all.qty3f)
updatePickOrderPlasticBoxCartonQty(pickOrderId, "4F", all.qty4f)
}

open fun getPickRecordPlasticBoxCartonQty(pickOrderId: Long): PickRecordPlasticBoxCartonQtyResponse {
val pickOrder = pickOrderRepository.findById(pickOrderId).orElseThrow {
NoSuchElementException("Pick order not found with ID: $pickOrderId")
}
return PickRecordPlasticBoxCartonQtyResponse(
plasticBoxCartonQty2f = pickOrder.plasticBoxCartonQty2f,
plasticBoxCartonQty3f = pickOrder.plasticBoxCartonQty3f,
plasticBoxCartonQty4f = pickOrder.plasticBoxCartonQty4f,
)
}

private fun resolvePlasticBoxCartonQtyForPickRecord(request: ExportPickRecordRequest): Int {
val floor = validatePickRecordFloor(request.floor)
return if (floor == "ALL") {
val all = resolveAllFloorsPlasticBoxCartonQty(
request.plasticBoxCartonQty2f,
request.plasticBoxCartonQty3f,
request.plasticBoxCartonQty4f,
)
persistAllFloorsPlasticBoxCartonQty(request.pickOrderIds, all)
all.sum
} else {
request.plasticBoxCartonQty
?: throw BadRequestException("plasticBoxCartonQty is required")
}
}

@Throws(IOException::class)
@Transactional
open fun exportPickRecord(request: ExportPickRecordRequest): Map<String, Any> {
@@ -821,6 +914,8 @@ open class JobOrderService(
println("unit (from BOM): $unit")*/

params["unit"] = pickRecordInfo.firstOrNull()?.get("uomConversionDesc") as? String ?: "N/A"
val plasticBoxCartonQtyForPdf = resolvePlasticBoxCartonQtyForPickRecord(request)
params["PlasticBoxCartonQty"] = plasticBoxCartonQtyForPdf.toString()

val pickOrderCode = pickRecordInfo.firstOrNull()?.get("pickOrderCode") as? String ?: "unknown"
return mapOf(
@@ -833,13 +928,26 @@ open class JobOrderService(
@Transactional
open fun printPickRecord(request: PrintPickRecordRequest){
val printer = printerService.findById(request.printerId) ?: throw java.util.NoSuchElementException("No such printer")
val pdf = exportPickRecord(
val floor = validatePickRecordFloor(request.floor)
val exportRequest = if (floor == "ALL") {
ExportPickRecordRequest(
pickOrderIds = request.pickOrderId,
floor = request.floor,
plasticBoxCartonQty2f = request.plasticBoxCartonQty2f,
plasticBoxCartonQty3f = request.plasticBoxCartonQty3f,
plasticBoxCartonQty4f = request.plasticBoxCartonQty4f,
)
)
} else {
val plasticBoxCartonQty = validatePlasticBoxCartonQty(request.plasticBoxCartonQty)
updatePickOrderPlasticBoxCartonQty(request.pickOrderId, floor, plasticBoxCartonQty)
ExportPickRecordRequest(
pickOrderIds = request.pickOrderId,
floor = request.floor,
plasticBoxCartonQty = plasticBoxCartonQty,
)
}

val pdf = exportPickRecord(exportRequest)

val jasperPrint = pdf["report"] as JasperPrint

@@ -1023,7 +1131,8 @@ open fun updateJoReqQty(request: UpdateJoReqQtyRequest): MessageResponse {
// 更新相关的 JobOrderBomMaterial 的 reqQty(根据新的比例重新计算)
val bom = jobOrder.bom
if (bom != null && bom.outputQty != null && bom.outputQty!! > BigDecimal.ZERO) {
val proportion = newReqQty.divide(bom.outputQty!!, 5, RoundingMode.HALF_UP)
val bomStockOutputQty = bomOutputQtyService.stockOutputQty(bom)
val proportion = newReqQty.divide(bomStockOutputQty, 5, RoundingMode.HALF_UP)
val jobOrderBomMaterials = jobOrderBomMaterialRepository.findAllByJobOrderId(jobOrder.id)
jobOrderBomMaterials.forEach { jobm ->


+ 1
- 2
src/main/java/com/ffii/fpsms/modules/jobOrder/service/LaserBag2AutoSendService.kt 查看文件

@@ -35,9 +35,8 @@ class LaserBag2AutoSendService(
sendsPerJob: Int = defaultSendsPerJob,
delayBetweenSendsMs: Long = defaultDelayBetweenSendsMs,
): LaserBag2AutoSendReport {
val (reachable, laserIp, laserPort) = plasticBagPrinterService.probeLaserBag2Tcp()
val (reachable, _, _) = plasticBagPrinterService.probeLaserBag2Tcp()
if (!reachable) {
logger.warn("Connection failed to the laser print: {} / {}", laserIp, laserPort)
return LaserBag2AutoSendReport(
planStart = planStart,
jobOrdersFound = 0,


+ 274
- 33
src/main/java/com/ffii/fpsms/modules/jobOrder/service/PSService.kt 查看文件

@@ -7,13 +7,25 @@ import org.springframework.stereotype.Service
import java.time.LocalDate

import com.ffii.core.support.JdbcDao
import org.apache.poi.ss.usermodel.FillPatternType
import org.apache.poi.ss.usermodel.IndexedColors
import org.apache.poi.ss.usermodel.VerticalAlignment
import org.apache.poi.xssf.usermodel.XSSFWorkbook
import java.io.ByteArrayOutputStream
import java.math.BigDecimal
import java.math.RoundingMode

@Service
open class PSService(
private val jdbcDao: JdbcDao,
) {
/** Default: 6 days before today to 1 day after today. */
/**
* FG BOM settings rows for 排期設定.
* Default date window: 6 days before today to 1 day after today.
*
* Performance: DO daily avg is pre-aggregated once (date-first via idx_do_deleted_eta),
* then joined — avoids per-item correlated scans of full delivery_order_line history.
*/
fun getItemDailyOut(fromDate: LocalDate? = null, toDate: LocalDate? = null): List<Map<String, Any>> {
val defaultToday = LocalDate.now()
val to = toDate ?: defaultToday.plusDays(1)
@@ -23,41 +35,59 @@ open class PSService(
"toDate" to to.toString(),
)
val sql = """
WITH do_daily AS (
SELECT
dol.itemId,
DATE(do.estimatedArrivalDate) AS shipDate,
SUM(dol.qty) AS dayQty
FROM delivery_order do
INNER JOIN delivery_order_line dol
ON dol.deliveryOrderId = do.id AND dol.deleted = 0
WHERE do.deleted = 0
AND do.estimatedArrivalDate >= :fromDate
AND do.estimatedArrivalDate <= :toDate
GROUP BY dol.itemId, DATE(do.estimatedArrivalDate)
),
avg_daily AS (
SELECT itemId, IFNULL(ROUND(AVG(dayQty)), 0) AS avgQtyLastMonth
FROM do_daily
GROUP BY itemId
),
pending_job AS (
SELECT bomId, SUM(reqQty) AS pendingJobQty
FROM job_order
WHERE status != 'completed'
GROUP BY bomId
),
cot AS (
SELECT
itemCode,
SUM(CASE WHEN systemType = 'coffee' THEN 1 ELSE 0 END) AS isCoffee,
SUM(CASE WHEN systemType = 'tea' THEN 1 ELSE 0 END) AS isTea,
SUM(CASE WHEN systemType = 'lemon' THEN 1 ELSE 0 END) AS isLemon
FROM coffee_or_tea
WHERE deleted = 0
GROUP BY itemCode
)
SELECT
(SELECT dailyQty FROM item_daily_out WHERE itemCode = items.code) AS dailyQty,
(SELECT
IFNULL(ROUND(AVG(d.dailyQty)), 0)
FROM
(SELECT
SUM(dol.qty) AS dailyQty
FROM
delivery_order_line dol
LEFT JOIN delivery_order do ON dol.deliveryOrderId = do.id
WHERE
do.deleted = 0
AND dol.itemId = items.id
AND do.estimatedArrivalDate >= :fromDate AND do.estimatedArrivalDate <= :toDate
GROUP BY do.estimatedArrivalDate) AS d) AS avgQtyLastMonth,
(SELECT SUM(reqQty) FROM job_order WHERE bomId = bom.id AND status != 'completed') AS pendingJobQty,
(SELECT COUNT(1) FROM coffee_or_tea WHERE systemType = 'coffee' AND itemCode = items.code AND deleted = 0) AS isCoffee,
(SELECT COUNT(1) FROM coffee_or_tea WHERE systemType = 'tea' AND itemCode = items.code AND deleted = 0) AS isTea,
(SELECT COUNT(1) FROM coffee_or_tea WHERE systemType = 'lemon' AND itemCode = items.code AND deleted = 0) AS isLemon,
CASE WHEN item_fake_onhand.onHandQty IS NOT NULL THEN item_fake_onhand.onHandQty
ELSE inventory.onHandQty - 500 END AS stockQty,
ido.dailyQty AS dailyQty,
IFNULL(ad.avgQtyLastMonth, 0) AS avgQtyLastMonth,
pj.pendingJobQty AS pendingJobQty,
IFNULL(cot.isCoffee, 0) AS isCoffee,
IFNULL(cot.isTea, 0) AS isTea,
IFNULL(cot.isLemon, 0) AS isLemon,
CASE WHEN ifo.onHandQty IS NOT NULL THEN ifo.onHandQty
ELSE inventory.onHandQty - 500 END AS stockQty,
bom.baseScore,
bom.outputQty,
bom.outputQtyUom,
(SELECT udfudesc
FROM delivery_order_line
LEFT JOIN uom_conversion ON delivery_order_line.uomId = uom_conversion.id
WHERE delivery_order_line.itemId = bom.itemId
LIMIT 1) AS doUom,
NULL AS doUom,
items.code AS itemCode,
items.name AS itemName,
uc_stock.udfudesc AS unit,
bom.description,
bom.bomKind,
inventory.onHandQty,
item_fake_onhand.onHandQty AS fakeOnHandQty,
ifo.onHandQty AS fakeOnHandQty,
bom.itemId,
bom.id AS bomId,
CASE WHEN bom.isDark = 5 THEN 11 WHEN bom.isDark = 3 THEN 6 WHEN bom.isDark = 1 THEN 2 ELSE 0 END AS markDark,
@@ -67,14 +97,19 @@ open class PSService(
bom.complexity AS markComplexity,
CASE WHEN bom.allergicSubstances = 5 THEN 11 ELSE 0 END AS markAS,
inventory.id AS inventoryId
FROM
bom
FROM bom
LEFT JOIN items ON bom.itemId = items.id
LEFT JOIN inventory ON items.id = inventory.itemId
LEFT JOIN item_fake_onhand ON items.code = item_fake_onhand.itemCode
LEFT JOIN item_fake_onhand ifo ON items.code = ifo.itemCode
LEFT JOIN item_daily_out ido ON items.code = ido.itemCode
LEFT JOIN item_uom iu ON iu.itemId = items.id AND iu.stockUnit = 1
LEFT JOIN uom_conversion uc_stock ON uc_stock.id = iu.uomId
WHERE bom.deleted = 0 and bom.description = 'FG'
LEFT JOIN avg_daily ad ON ad.itemId = items.id
LEFT JOIN pending_job pj ON pj.bomId = bom.id
LEFT JOIN cot ON cot.itemCode = items.code
WHERE bom.deleted = 0
AND bom.bomKind = 'FG'
ORDER BY items.code
""".trimIndent()
return jdbcDao.queryForList(sql, args)
}
@@ -160,6 +195,49 @@ open class PSService(
}

/** Set or clear coffee_or_tea for itemCode + systemType (coffee / tea / lemon). */
/**
* Recalculate [inventory.onHandQty] / hold / unavailable from [inventory_lot_line] for FG BOM items.
* Same aggregation as pick-issue manual inventory sync.
*/
fun refreshInventoryOnHandForFgBomItems(): Int {
val sql = """
UPDATE inventory i
INNER JOIN (
SELECT DISTINCT b.itemId
FROM bom b
WHERE b.deleted = 0 AND b.bomKind = 'FG'
) bom_items ON bom_items.itemId = i.itemId
LEFT JOIN (
SELECT
il.itemId,
SUM(COALESCE(ill.inQty, 0) - COALESCE(ill.outQty, 0)) AS totalOnHandQty,
SUM(COALESCE(ill.holdQty, 0)) AS totalOnHoldQty,
SUM(CASE
WHEN ill.status = 'unavailable'
THEN COALESCE(ill.inQty, 0) - COALESCE(ill.outQty, 0) - COALESCE(ill.holdQty, 0)
ELSE 0
END) AS totalUnavailableQty
FROM inventory_lot_line ill
INNER JOIN inventory_lot il ON il.id = ill.inventoryLotId AND il.deleted = 0
WHERE ill.deleted = 0
GROUP BY il.itemId
) calc ON calc.itemId = i.itemId
SET
i.onHandQty = COALESCE(calc.totalOnHandQty, 0),
i.onHoldQty = COALESCE(calc.totalOnHoldQty, 0),
i.unavailableQty = COALESCE(calc.totalUnavailableQty, 0),
i.status = IF(
COALESCE(calc.totalOnHandQty, 0) - COALESCE(calc.totalOnHoldQty, 0) - COALESCE(calc.totalUnavailableQty, 0) > 0,
'available',
'unavailable'
),
i.modified = NOW(),
i.modifiedBy = 'ps-refresh-onhand'
WHERE i.deleted = 0
""".trimIndent()
return jdbcDao.executeUpdate(sql, emptyMap<String, Any>())
}

fun setCoffeeOrTea(itemCode: String, systemType: String, enabled: Boolean) {
val args = mapOf("itemCode" to itemCode, "systemType" to systemType)
jdbcDao.executeUpdate(
@@ -214,4 +292,167 @@ open class PSService(
return jdbcDao.queryForList(sql, args)
}

/**
* Items with at least one BOM (deleted = 0), plus stock-unit label.
*/
fun listBomItemsWithStockUnit(): List<Map<String, Any>> {
val sql = """
SELECT
items.code AS itemCode,
items.name AS itemName,
uc_stock.udfudesc AS stockUnit
FROM (
SELECT DISTINCT b.itemId
FROM bom b
WHERE b.deleted = 0
) bom_items
INNER JOIN items ON items.id = bom_items.itemId AND items.deleted = 0
LEFT JOIN item_uom iu_stock ON iu_stock.itemId = items.id
AND iu_stock.stockUnit = 1 AND iu_stock.deleted = 0
LEFT JOIN uom_conversion uc_stock ON uc_stock.id = iu_stock.uomId
ORDER BY items.code
""".trimIndent()
return jdbcDao.queryForList(sql, emptyMap<String, Any>())
}

/**
* Sum of [delivery_order_line.qty] (stock unit) by item code and ETA date.
* When [bomItemCodes] is provided, only matching rows are returned (BOM filter in memory).
*/
fun sumDeliveryOrderQtyByItemAndDate(
fromDate: LocalDate,
toDate: LocalDate,
bomItemCodes: Set<String>? = null,
): List<Map<String, Any>> {
val args = mapOf(
"fromDate" to fromDate.toString(),
"toDateExclusive" to toDate.plusDays(1).toString(),
)
val sql = """
WITH do_in_range AS (
SELECT id, estimatedArrivalDate
FROM delivery_order
WHERE deleted = 0
AND estimatedArrivalDate IS NOT NULL
AND estimatedArrivalDate >= :fromDate
AND estimatedArrivalDate < :toDateExclusive
)
SELECT
items.code AS itemCode,
DATE(do.estimatedArrivalDate) AS shipDate,
SUM(COALESCE(dol.qty, 0)) AS qtySum
FROM do_in_range do
INNER JOIN delivery_order_line dol ON dol.deliveryOrderId = do.id AND dol.deleted = 0
INNER JOIN items ON items.id = dol.itemId AND items.deleted = 0
GROUP BY items.code, DATE(do.estimatedArrivalDate)
ORDER BY items.code, shipDate
""".trimIndent()
val rows = jdbcDao.queryForList(sql, args)
if (bomItemCodes.isNullOrEmpty()) {
return rows
}
return rows.filter { row ->
val itemCode = row["itemCode"]?.toString() ?: return@filter false
itemCode in bomItemCodes
}
}

fun exportDeliveryOrderQtyByDateExcel(fromDate: LocalDate, toDate: LocalDate): ByteArray {
require(!fromDate.isAfter(toDate)) { "fromDate must be on or before toDate" }
val dayCount = java.time.temporal.ChronoUnit.DAYS.between(fromDate, toDate) + 1
require(dayCount in 1..366) { "Date range must be between 1 and 366 days" }

val dates = generateSequence(fromDate) { it.plusDays(1) }.takeWhile { !it.isAfter(toDate) }.toList()
val items = listBomItemsWithStockUnit()
val bomItemCodes = items.mapNotNull { it["itemCode"]?.toString() }.toSet()
val qtyRows = sumDeliveryOrderQtyByItemAndDate(fromDate, toDate, bomItemCodes)

val qtyByItemDate = mutableMapOf<String, MutableMap<LocalDate, BigDecimal>>()
qtyRows.forEach { row ->
val itemCode = row["itemCode"]?.toString() ?: return@forEach
val shipDate = parseSqlDate(row["shipDate"]) ?: return@forEach
val qty = toBigDecimal(row["qtySum"])
qtyByItemDate.getOrPut(itemCode) { mutableMapOf() }[shipDate] = qty
}

val workbook = XSSFWorkbook()
val sheet = workbook.createSheet("DO Qty by Date")

val headerStyle = workbook.createCellStyle().apply {
fillForegroundColor = IndexedColors.GREY_25_PERCENT.index
fillPattern = FillPatternType.SOLID_FOREGROUND
verticalAlignment = VerticalAlignment.CENTER
val font = workbook.createFont()
font.bold = true
setFont(font)
}
val textStyle = workbook.createCellStyle().apply {
verticalAlignment = VerticalAlignment.CENTER
}

val headerRow = sheet.createRow(0)
val headers = mutableListOf("Item Code", "Item Name", "UOM")
headers.addAll(dates.map { it.toString() })
headers.forEachIndexed { col, title ->
headerRow.createCell(col).apply {
setCellValue(title)
cellStyle = headerStyle
}
}

items.forEachIndexed { index, item ->
val row = sheet.createRow(index + 1)
val itemCode = item["itemCode"]?.toString() ?: ""
val itemName = item["itemName"]?.toString() ?: ""
val stockUnit = item["stockUnit"]?.toString() ?: ""

row.createCell(0).apply { setCellValue(itemCode); cellStyle = textStyle }
row.createCell(1).apply { setCellValue(itemName); cellStyle = textStyle }
row.createCell(2).apply { setCellValue(stockUnit); cellStyle = textStyle }

val dateQtyMap = qtyByItemDate[itemCode]
dates.forEachIndexed { dateIdx, date ->
val qty = (dateQtyMap?.get(date) ?: BigDecimal.ZERO)
.setScale(0, RoundingMode.HALF_UP)
row.createCell(3 + dateIdx).setCellValue(qty.toLong().toDouble())
}
}

for (col in 0 until headers.size) {
sheet.setColumnWidth(
col,
when (col) {
0 -> 4500
1 -> 9000
2 -> 3200
else -> 3200
},
)
}

ByteArrayOutputStream().use { out ->
workbook.write(out)
workbook.close()
return out.toByteArray()
}
}

private fun parseSqlDate(value: Any?): LocalDate? = when (value) {
null -> null
is LocalDate -> value
is java.sql.Date -> value.toLocalDate()
is java.time.LocalDateTime -> value.toLocalDate()
else -> {
val text = value.toString().trim()
if (text.length >= 10) LocalDate.parse(text.substring(0, 10)) else null
}
}

private fun toBigDecimal(value: Any?): BigDecimal = when (value) {
null -> BigDecimal.ZERO
is BigDecimal -> value
is Number -> BigDecimal.valueOf(value.toDouble())
else -> value.toString().toBigDecimalOrNull() ?: BigDecimal.ZERO
}

}

+ 7
- 4
src/main/java/com/ffii/fpsms/modules/jobOrder/service/PlasticBagPrinterService.kt 查看文件

@@ -177,7 +177,7 @@ class PlasticBagPrinterService(
val ids = filtered.mapNotNull { it.id }
val printed = pyJobOrderPrintSubmitService.sumPrintedQtyByJobOrderIds(ids)
return filtered.map { jo ->
PyJobOrderListMapper.toListItem(jo, printed[jo.id!!], stockInLineRepository, itemUomService)
PyJobOrderListMapper.toLaserListItem(jo, printed[jo.id!!], stockInLineRepository, itemUomService)
}
}

@@ -1367,15 +1367,18 @@ class PlasticBagPrinterService(
}
val qrValue = zplEscape(qrPayload)

// Must match python Bag2.py generate_zpl_dataflex()
// Must match python Bag3.py generate_zpl_dataflex() field layout / fonts.
val fontRegular = "E:STXihei.ttf"
val fontBold = "E:STXihei.ttf"

// Match python Bag3.py DataFlex defaults: narrower ^PW so job preview is not mostly empty on the right (^A@R fields are tall, not wide).
val labelPw = 400
val labelLl = 500
return """
^XA
^CI28
^PW700
^LL500
^PW$labelPw
^LL$labelLl
^PO N
^FO10,20
^BQN,2,4^FDQA,$qrValue^FS


+ 24
- 8
src/main/java/com/ffii/fpsms/modules/jobOrder/web/JobOrderController.kt 查看文件

@@ -27,7 +27,6 @@ import com.ffii.fpsms.modules.pickOrder.enums.PickOrderStatus
import org.springframework.format.annotation.DateTimeFormat
import com.ffii.fpsms.modules.productProcess.service.ProductProcessService
import com.ffii.fpsms.modules.jobOrder.web.model.*
import com.ffii.fpsms.modules.jobOrder.web.model.ExportPickRecordRequest
import com.ffii.fpsms.modules.jobOrder.web.model.PrintPickRecordRequest
import com.ffii.fpsms.modules.jobOrder.web.model.SecondScanSubmitRequest
import com.ffii.fpsms.modules.jobOrder.web.model.SecondScanIssueRequest
@@ -224,9 +223,19 @@ fun recordSecondScanIssue(
return joPickOrderService.getCompletedJobOrderPickOrderLotDetails(pickOrderId)
}

@GetMapping("/pick-record-plastic-box-carton-qty/{pickOrderId}")
fun getPickRecordPlasticBoxCartonQty(@PathVariable pickOrderId: Long): PickRecordPlasticBoxCartonQtyResponse {
return jobOrderService.getPickRecordPlasticBoxCartonQty(pickOrderId)
}

@GetMapping("/print-PickRecord")
fun printPickRecord(@ModelAttribute request: PrintPickRecordRequest){
jobOrderService.printPickRecord(request)
}

@PostMapping("/PickRecord")
@Throws(UnsupportedEncodingException::class, NoSuchMessageException::class, ParseException::class, Exception::class)
fun printPickRecord(@Valid @RequestBody request: ExportPickRecordRequest, response: HttpServletResponse){
fun exportPickRecord(@Valid @RequestBody request: ExportPickRecordRequest, response: HttpServletResponse) {
response.characterEncoding = "utf-8"
response.contentType = "application/pdf"
val out: OutputStream = response.outputStream
@@ -236,11 +245,6 @@ fun recordSecondScanIssue(
out.write(JasperExportManager.exportReportToPdf(jasperPrint))
}

@GetMapping("/print-PickRecord")
fun printPickRecord(@ModelAttribute request: PrintPickRecordRequest){
jobOrderService.printPickRecord(request)
}

@PostMapping("/FGStockInLabel")
@Throws(UnsupportedEncodingException::class, NoSuchMessageException::class, ParseException::class, Exception::class)
fun exportFGStockInLabel(@Valid @RequestBody request: ExportFGStockInLabelRequest, response: HttpServletResponse){
@@ -272,6 +276,18 @@ fun recordSecondScanIssue(
): List<Map<String, Any?>> {
return joPickOrderService.getCompletedJobOrderPickOrders(completedDate)
}

@GetMapping("/plastic-box-carton-qty-dashboard")
fun getPlasticBoxCartonQtyDashboard(
@RequestParam(name = "from")
@DateTimeFormat(iso = DateTimeFormat.ISO.DATE)
from: LocalDate,
@RequestParam(name = "to")
@DateTimeFormat(iso = DateTimeFormat.ISO.DATE)
to: LocalDate,
): List<PlasticBoxCartonQtyDashboardRecord> {
return joPickOrderService.getPlasticBoxCartonQtyDashboard(from, to)
}
@GetMapping("/job-order-pick-orders")
fun getJobOrderPickOrders(
@RequestParam(name = "date", required = false)
@@ -332,7 +348,7 @@ fun getJobOrderPickOrderLotDetails(
/** Workbench: available qty uses in−out (matches scan-pick); stockouts include suggested SPL qty when matched. */
@GetMapping("/all-lots-hierarchical-by-pick-order-workbench/{pickOrderId}")
fun getJobOrderLotsHierarchicalByPickOrderIdWorkbench(@PathVariable pickOrderId: Long): JobOrderLotsHierarchicalResponse {
fun getJobOrderLotsHierarchicalByPickOrderIdWorkbench(@PathVariable pickOrderId: Long): JobOrderLotsHierarchicalWorkbenchResponse {
return joWorkbenchMainService.getJobOrderLotsHierarchicalByPickOrderIdWorkbench(pickOrderId)
}


+ 42
- 1
src/main/java/com/ffii/fpsms/modules/jobOrder/web/PSController.kt 查看文件

@@ -4,10 +4,11 @@ import com.ffii.fpsms.modules.jobOrder.service.PlasticBagPrinterService
import com.ffii.fpsms.modules.jobOrder.service.PSService
import com.ffii.fpsms.modules.jobOrder.web.model.PrintRequest
import com.ffii.fpsms.modules.jobOrder.web.model.LaserRequest
import jakarta.servlet.http.HttpServletResponse
import org.springframework.http.HttpHeaders
import org.springframework.http.MediaType
import org.springframework.web.bind.annotation.*
import java.time.LocalDate
import java.time.format.DateTimeParseException
import org.springframework.http.ResponseEntity

@RestController
@@ -79,6 +80,13 @@ class PSController(
return ResponseEntity.ok(mapOf("ok" to true, "itemCode" to itemCode, "onHandQty" to (onHandQty ?: "deleted")))
}

/** Recalculate inventory on-hand from lot lines for FG BOM items (排期設定 刷新庫存). */
@PostMapping("/refresh-inventory-onhand")
fun refreshInventoryOnHand(): ResponseEntity<Map<String, Any>> {
val updated = psService.refreshInventoryOnHandForFgBomItems()
return ResponseEntity.ok(mapOf("ok" to true, "updated" to updated))
}

/** Set or clear coffee/tea/lemon for an item. systemType: coffee | tea | lemon, enabled: boolean. */
@PostMapping("/setCoffeeOrTea")
fun setCoffeeOrTea(@RequestBody body: Map<String, Any>): ResponseEntity<Map<String, Any>> {
@@ -91,4 +99,37 @@ class PSController(
psService.setCoffeeOrTea(itemCode, systemType, enabled)
return ResponseEntity.ok(mapOf("ok" to true, "itemCode" to itemCode, "systemType" to systemType, "enabled" to enabled))
}

/**
* Export delivery-order qty sums (stock unit) for BOM items, pivoted by ETA date.
*/
@GetMapping(
value = ["/export-do-qty-by-date"],
produces = ["application/vnd.openxmlformats-officedocument.spreadsheetml.sheet"],
)
fun exportDoQtyByDate(
@RequestParam fromDate: String,
@RequestParam toDate: String,
): ResponseEntity<Any> {
val from = try {
LocalDate.parse(fromDate)
} catch (_: DateTimeParseException) {
return ResponseEntity.badRequest().body(mapOf("error" to "Invalid fromDate"))
}
val to = try {
LocalDate.parse(toDate)
} catch (_: DateTimeParseException) {
return ResponseEntity.badRequest().body(mapOf("error" to "Invalid toDate"))
}
return try {
val bytes = psService.exportDeliveryOrderQtyByDateExcel(from, to)
val filename = "do_qty_${from}_to_${to}.xlsx"
ResponseEntity.ok()
.header(HttpHeaders.CONTENT_DISPOSITION, "attachment; filename=$filename")
.contentType(MediaType.parseMediaType("application/vnd.openxmlformats-officedocument.spreadsheetml.sheet"))
.body(bytes)
} catch (e: IllegalArgumentException) {
ResponseEntity.badRequest().body(mapOf("error" to (e.message ?: "Invalid date range")))
}
}
}

+ 37
- 1
src/main/java/com/ffii/fpsms/modules/jobOrder/web/model/CreateJobOrderRequest.kt 查看文件

@@ -90,9 +90,45 @@ data class PickOrderInfoResponse(
data class JobOrderBasicInfoResponse(
val id: Long,
val code: String,
val name: String
val name: String,
)
data class JobOrderLotsHierarchicalWorkbenchResponse(
val pickOrder: PickOrderInfoWorkbenchResponse,
val pickOrderLines: List<PickOrderLineWithLotsWorkbenchResponse>
)

data class PickOrderInfoWorkbenchResponse(
val id: Long?,
val code: String?,
val consoCode: String?,
val targetDate: String?,
val type: String?,
val status: String?,
val assignTo: Long?,
val jobOrder: JobOrderBasicInfoWorkbenchResponse
)
data class PickOrderLineWithLotsWorkbenchResponse(
val id: Long,
val itemId: Long?,
val itemCode: String?,
val itemName: String?,
val requiredQty: Double?,
// Total available qty across all inventory lot lines for this item (used by JO pick UI)
val totalAvailableQty: Double? = null,
val uomCode: String?,
val uomDesc: String?,
val status: String?,
val lots: List<LotDetailResponse>,
val stockouts: List<StockOutLineDetailResponse> = emptyList(),
val handler: String?
)
data class JobOrderBasicInfoWorkbenchResponse(
val id: Long,
val code: String,
val name: String,
val itemCode: String?,
val itemName: String?,
)
data class PickOrderLineWithLotsResponse(
val id: Long,
val itemId: Long?,


+ 4
- 0
src/main/java/com/ffii/fpsms/modules/jobOrder/web/model/ExportPickRecordRequest.kt 查看文件

@@ -3,4 +3,8 @@ package com.ffii.fpsms.modules.jobOrder.web.model
data class ExportPickRecordRequest (
val pickOrderIds: Long,
val floor: String? = null,
val plasticBoxCartonQty: Int? = null,
val plasticBoxCartonQty2f: Int? = null,
val plasticBoxCartonQty3f: Int? = null,
val plasticBoxCartonQty4f: Int? = null,
)

+ 7
- 0
src/main/java/com/ffii/fpsms/modules/jobOrder/web/model/PickRecordPlasticBoxCartonQtyResponse.kt 查看文件

@@ -0,0 +1,7 @@
package com.ffii.fpsms.modules.jobOrder.web.model

data class PickRecordPlasticBoxCartonQtyResponse(
val plasticBoxCartonQty2f: Int?,
val plasticBoxCartonQty3f: Int?,
val plasticBoxCartonQty4f: Int?,
)

+ 9
- 0
src/main/java/com/ffii/fpsms/modules/jobOrder/web/model/PlasticBoxCartonQtyDashboardRecord.kt 查看文件

@@ -0,0 +1,9 @@
package com.ffii.fpsms.modules.jobOrder.web.model

data class PlasticBoxCartonQtyDashboardRecord(
val pickOrderId: Long?,
val statDate: String,
val plasticBoxCartonQty2f: Int? = null,
val plasticBoxCartonQty3f: Int? = null,
val plasticBoxCartonQty4f: Int? = null,
)

+ 4
- 0
src/main/java/com/ffii/fpsms/modules/jobOrder/web/model/PrintPickRecordRequest.kt 查看文件

@@ -5,4 +5,8 @@ data class PrintPickRecordRequest(
val printerId: Long,
val printQty: Int?,
val floor: String? = null,
val plasticBoxCartonQty: Int? = null,
val plasticBoxCartonQty2f: Int? = null,
val plasticBoxCartonQty3f: Int? = null,
val plasticBoxCartonQty4f: Int? = null,
)

+ 33
- 0
src/main/java/com/ffii/fpsms/modules/logistic/entity/Logistic.kt 查看文件

@@ -0,0 +1,33 @@
package com.ffii.fpsms.modules.logistic.entity

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

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

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

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

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

@field:NotNull
@Column(name = "driverNumber", nullable = false)
open var driverNumber: Int? = null
}


+ 12
- 0
src/main/java/com/ffii/fpsms/modules/logistic/entity/LogisticRepository.kt 查看文件

@@ -0,0 +1,12 @@
package com.ffii.fpsms.modules.logistic.entity

import com.ffii.core.support.AbstractRepository
import org.springframework.stereotype.Repository

@Repository
interface LogisticRepository : AbstractRepository<Logistic, Long> {
fun findAllByDeletedFalseOrderByIdAsc(): List<Logistic>
fun findByIdAndDeletedFalse(id: Long): Logistic?
fun findByCarPlateAndDeletedFalse(carPlate: String): Logistic?
}


+ 82
- 0
src/main/java/com/ffii/fpsms/modules/logistic/service/LogisticService.kt 查看文件

@@ -0,0 +1,82 @@
package com.ffii.fpsms.modules.logistic.service

import com.ffii.fpsms.modules.logistic.entity.Logistic
import com.ffii.fpsms.modules.logistic.entity.LogisticRepository
import com.ffii.fpsms.modules.logistic.web.models.SaveLogisticRequest
import jakarta.transaction.Transactional
import org.springframework.stereotype.Service
import org.springframework.web.server.ResponseStatusException
import org.springframework.http.HttpStatus

@Service
open class LogisticService(
private val logisticRepository: LogisticRepository,
) {
open fun findAll(): List<Logistic> {
return logisticRepository.findAllByDeletedFalseOrderByIdAsc()
}

open fun findById(id: Long): Logistic? {
return logisticRepository.findByIdAndDeletedFalse(id)
}

open fun requireById(id: Long): Logistic {
return logisticRepository.findByIdAndDeletedFalse(id)
?: throw ResponseStatusException(HttpStatus.NOT_FOUND, "Logistic not found with id: $id")
}

@Transactional
open fun save(request: SaveLogisticRequest): Logistic {
val entity = request.id?.let { requireById(it) } ?: Logistic()

entity.apply {
logisticName = request.logisticName.trim()
carPlate = request.carPlate.trim()
driverName = request.driverName.trim()
driverNumber = request.driverNumber
}

return logisticRepository.save(entity)
}

/**
* 批次「新增」物流主檔:同一交易內寫入,任一筆失敗則整批 rollback。
* 供看板一次儲存多筆暫存主檔,避免逐筆 POST 中途失敗留下孤兒列。
*/
@Transactional
open fun saveBatchCreate(requests: List<SaveLogisticRequest>): List<Logistic> {
if (requests.isEmpty()) return emptyList()
if (requests.size > 100) {
throw ResponseStatusException(
HttpStatus.BAD_REQUEST,
"Batch size exceeds limit (100)",
)
}
requests.forEach { r ->
if (r.id != null) {
throw ResponseStatusException(
HttpStatus.BAD_REQUEST,
"save-batch only accepts new rows (id must be null)",
)
}
}
return requests.map { req ->
val entity = Logistic().apply {
logisticName = req.logisticName.trim()
carPlate = req.carPlate.trim()
driverName = req.driverName.trim()
driverNumber = req.driverNumber
}
logisticRepository.save(entity)
}
}

@Transactional
open fun deleteById(id: Long): String {
val entity = requireById(id)
entity.deleted = true
logisticRepository.save(entity)
return "Logistic deleted successfully with id: $id"
}
}


+ 65
- 0
src/main/java/com/ffii/fpsms/modules/logistic/web/LogisticController.kt 查看文件

@@ -0,0 +1,65 @@
package com.ffii.fpsms.modules.logistic.web

import com.ffii.fpsms.modules.logistic.service.LogisticService
import com.ffii.fpsms.modules.logistic.web.models.DeleteLogisticRequest
import com.ffii.fpsms.modules.logistic.web.models.LogisticResponse
import com.ffii.fpsms.modules.logistic.web.models.SaveLogisticRequest
import com.ffii.fpsms.modules.logistic.web.models.SaveLogisticsBatchRequest
import com.ffii.fpsms.modules.master.web.models.MessageResponse
import jakarta.validation.Valid
import org.springframework.http.ResponseEntity
import org.springframework.web.bind.annotation.*

@RestController
@RequestMapping("/logistic")
class LogisticController(
private val logisticService: LogisticService,
) {
@GetMapping("/all")
fun findAll(): List<LogisticResponse> {
return logisticService.findAll().map { it.toResponse() }
}

@GetMapping("/{id}")
fun findById(@PathVariable id: Long): LogisticResponse {
return logisticService.requireById(id).toResponse()
}

@PostMapping("/save")
fun save(@Valid @RequestBody request: SaveLogisticRequest): LogisticResponse {
return logisticService.save(request).toResponse()
}

/** 批次新增主檔;單一 transaction,與 [save] 分開避免誤用 id 更新混進批次。 */
@PostMapping("/save-batch")
fun saveBatch(@Valid @RequestBody body: SaveLogisticsBatchRequest): List<LogisticResponse> {
return logisticService.saveBatchCreate(body.items).map { it.toResponse() }
}

@PostMapping("/delete")
fun delete(@Valid @RequestBody request: DeleteLogisticRequest): ResponseEntity<MessageResponse> {
val result = logisticService.deleteById(request.id)
return ResponseEntity.ok(
MessageResponse(
id = request.id,
name = null,
code = null,
type = "logistic",
message = result,
errorPosition = null,
entity = null,
)
)
}

private fun com.ffii.fpsms.modules.logistic.entity.Logistic.toResponse(): LogisticResponse {
return LogisticResponse(
id = this.id ?: 0L,
logisticName = this.logisticName ?: "",
carPlate = this.carPlate ?: "",
driverName = this.driverName ?: "",
driverNumber = this.driverNumber ?: 0,
)
}
}


+ 9
- 0
src/main/java/com/ffii/fpsms/modules/logistic/web/models/DeleteLogisticRequest.kt 查看文件

@@ -0,0 +1,9 @@
package com.ffii.fpsms.modules.logistic.web.models

import jakarta.validation.constraints.NotNull

data class DeleteLogisticRequest(
@field:NotNull
val id: Long,
)


+ 10
- 0
src/main/java/com/ffii/fpsms/modules/logistic/web/models/LogisticResponse.kt 查看文件

@@ -0,0 +1,10 @@
package com.ffii.fpsms.modules.logistic.web.models

data class LogisticResponse(
val id: Long,
val logisticName: String,
val carPlate: String,
val driverName: String,
val driverNumber: Int,
)


+ 21
- 0
src/main/java/com/ffii/fpsms/modules/logistic/web/models/SaveLogisticRequest.kt 查看文件

@@ -0,0 +1,21 @@
package com.ffii.fpsms.modules.logistic.web.models

import jakarta.validation.constraints.NotBlank
import jakarta.validation.constraints.NotNull
import jakarta.validation.constraints.Size

data class SaveLogisticRequest(
val id: Long? = null,
@field:NotBlank
@field:Size(max = 255)
val logisticName: String,
@field:NotBlank
@field:Size(max = 50)
val carPlate: String,
@field:NotBlank
@field:Size(max = 255)
val driverName: String,
@field:NotNull
val driverNumber: Int,
)


+ 12
- 0
src/main/java/com/ffii/fpsms/modules/logistic/web/models/SaveLogisticsBatchRequest.kt 查看文件

@@ -0,0 +1,12 @@
package com.ffii.fpsms.modules.logistic.web.models

import jakarta.validation.Valid
import jakarta.validation.constraints.NotEmpty
import jakarta.validation.constraints.Size

data class SaveLogisticsBatchRequest(
@field:NotEmpty
@field:Size(max = 100)
@field:Valid
val items: List<SaveLogisticRequest>,
)

+ 15
- 2
src/main/java/com/ffii/fpsms/modules/master/entity/Bom.kt 查看文件

@@ -3,6 +3,8 @@ package com.ffii.fpsms.modules.master.entity
import com.fasterxml.jackson.annotation.JsonBackReference
import com.fasterxml.jackson.annotation.JsonManagedReference
import com.ffii.core.entity.BaseEntity
import com.ffii.fpsms.modules.master.enums.BomStatus
import com.ffii.fpsms.modules.master.enums.BomStatusConverter
import jakarta.persistence.*
import jakarta.validation.constraints.NotNull
import jakarta.validation.constraints.Size
@@ -51,8 +53,11 @@ open class Bom : BaseEntity<Long>() {

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

@Column(name = "revisionNo")
open var revisionNo: Int? = null

@Column(name = "outputQty", precision = 14, scale = 2)
open var outputQty: BigDecimal? = null
@@ -87,4 +92,12 @@ open class Bom : BaseEntity<Long>() {

@Column(name = "baseScore", precision = 14, scale = 2)
open var baseScore: BigDecimal? = null

@Column(name = "status", nullable = false, length = 20)
@Convert(converter = BomStatusConverter::class)
open var status: BomStatus = BomStatus.ACTIVE

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

+ 0
- 21
src/main/java/com/ffii/fpsms/modules/master/entity/BomMaterial.kt 查看文件

@@ -37,27 +37,6 @@ open class BomMaterial : BaseEntity<Long>() {
@Column(name = "uomName", length = 100)
open var uomName: String? = null

@Column(name = "saleQty", precision = 14, scale = 2)
open var saleQty: BigDecimal? = null

@ManyToOne
@JoinColumn(name = "salesUnitId")
open var salesUnit: UomConversion? = null

@Column(name = "salesUnitCode")
open var salesUnitCode: String? = null
@Column(name = "baseQty", precision = 14, scale = 2)
open var baseQty: BigDecimal? = null
@Column(name = "baseUnit", nullable = false)
open var baseUnit: Integer? = null
@Column(name = "baseUnitName", length = 100)
open var baseUnitName: String? = null
@Column(name = "stockQty", precision = 14, scale = 2)
open var stockQty: BigDecimal? = null
@Column(name = "stockUnit", nullable = false)
open var stockUnit: Integer? = null
@Column(name = "stockUnitName", length = 100)
open var stockUnitName: String? = null
@NotNull
@ManyToOne(optional = false)
@JoinColumn(name = "bomId", nullable = false)


部分文件因文件數量過多而無法顯示

Loading…
取消
儲存