Comparer les révisions

...

137 Révisions

Auteur SHA1 Message Date
  Fai Luk 5f628db30c no message il y a 2 jours
  Fai Luk 93c3e9318f no message il y a 2 jours
  Fai Luk 18a4d2dbff no message il y a 3 jours
  Fai Luk a81c6f1cf6 added onpack2030 for pp2404 il y a 3 jours
  CANCERYS\kw093 173e6ce580 WorkbenchGoodPickExecutionDetail UI 大改 il y a 4 jours
  CANCERYS\kw093 3ebf46e304 建議批號 UOM 檢核/不符則擋、標籤列印只顯示同 UOM il y a 4 jours
  tommy 94dbc8db9a no message il y a 6 jours
  tommy c63f8dfa8c item trace , drink produce dashboard il y a 6 jours
  CANCERYS\kw093 898d6f685f inventory search update il y a 1 semaine
  tommy dcb4df4c3e add ref no il y a 1 semaine
  kelvin.yau 66672ba404 updated ARCHIVE il y a 1 semaine
  kelvin.yau a64e158bc0 Updated archive function il y a 1 semaine
  tommy 7ef9ef8272 item trace il y a 1 semaine
  tommy ae81949e46 no message il y a 1 semaine
  CANCERYS\kw093 ef7bcca120 Cancel Pick Order il y a 1 semaine
  CANCERYS\kw093 ee4a80ccc2 do ticket select fix il y a 1 semaine
  Fai Luk 1fb5200e16 no message il y a 2 semaines
  vluk@2fi-solutions.com.hk 88136da489 no message il y a 2 semaines
  vluk@2fi-solutions.com.hk c23898e31a python job order not to show cancelled job orders il y a 2 semaines
  vluk@2fi-solutions.com.hk 70f620959a fixing the GRN duplicated item bug for same PO il y a 2 semaines
  CANCERYS\kw093 fd3a0f00fa 補貨 search fix il y a 3 semaines
  CANCERYS\kw093 8ff5c9e902 補貨 FIX il y a 3 semaines
  tommy 84dddf9fba no message il y a 3 semaines
  tommy b632df1d91 no message il y a 3 semaines
  CANCERYS\kw093 7f62902a62 po detail ui add deleted button and ui update il y a 3 semaines
  CANCERYS\kw093 3814cd4c79 jo pick order resuggest reject W402 warehosue il y a 3 semaines
  CANCERYS\kw093 42c3ada0df if pol sync fail, will send email to tell pol have sync fail il y a 1 mois
  B.E.N.S.O.N 97a26b1a3b QC添加溫度/濕度儲存 il y a 1 mois
  CANCERYS\kw093 789666aad8 do pdf fix il y a 1 mois
  tommy 7ac98d950b DN PDF 單號顯示問題 il y a 1 mois
  tommy 46f7d39f7b 調整庫存權限 il y a 1 mois
  vluk@2fi-solutions.com.hk 2e437a831b no message il y a 1 mois
  tommy 8aa202052b label printer tracking update il y a 1 mois
  tommy 76ff1cd22f label printer il y a 1 mois
  tommy ac238e7ba3 stocktake report update il y a 1 mois
  vluk@2fi-solutions.com.hk 7db4286368 no message il y a 1 mois
  vluk@2fi-solutions.com.hk 1035482880 no message il y a 1 mois
  vluk@2fi-solutions.com.hk 4399ac6d22 no message il y a 1 mois
  vluk@2fi-solutions.com.hk a7d3f156a5 no message il y a 1 mois
  vluk@2fi-solutions.com.hk 5eba1a42f2 no message il y a 1 mois
  vluk@2fi-solutions.com.hk f08f06da0f try to fix the bom item error il y a 1 mois
  CANCERYS\kw093 44646254f6 do skip isfee item il y a 1 mois
  vluk@2fi-solutions.com.hk 4d38dcff74 no message il y a 1 mois
  B.E.N.S.O.N 172d32f613 成品出倉執貨時 標籤列印時頁數顯示空白 il y a 1 mois
  vluk@2fi-solutions.com.hk a111a80bf6 handle the laser printer that failed to print 1175 full name with (uom) il y a 1 mois
  vluk@2fi-solutions.com.hk 193a1824a2 added a report to check bom sync history il y a 1 mois
  vluk@2fi-solutions.com.hk 4d01f1d84f fixing the non-unique vendor result of bom finding il y a 1 mois
  vluk@2fi-solutions.com.hk 506d39c7f3 try to update the m18 bom with a new version number il y a 1 mois
  kelvin.yau b705a8acb6 replenishment update il y a 1 mois
  CANCERYS\kw093 a0b3a58d65 report fix 2 il y a 1 mois
  CANCERYS\kw093 76a2ace7f9 rpeort fix il y a 1 mois
  CANCERYS\kw093 88e36de88c 補貨V1 il y a 1 mois
  vluk@2fi-solutions.com.hk cdbfb157ca no message il y a 1 mois
  vluk@2fi-solutions.com.hk 13d646af6e Merge branch 'production' of https://git.2fi-solutions.com/jason/FPSMS-backend into production il y a 1 mois
  vluk@2fi-solutions.com.hk 1c1eadc71d alter for the BOM sync finding PP purchase order supplier and also others for supplier id by code il y a 1 mois
  tommy 22e982fea1 補貨 + truck scheduler update il y a 1 mois
  vluk@2fi-solutions.com.hk 306a8474c6 updates for some items missing the () uom of job order list which using by python il y a 1 mois
  vluk@2fi-solutions.com.hk 064d379229 Bag3 il y a 1 mois
  kelvin.yau f74dc566ee replenishment setup il y a 1 mois
  tommy 5f816fcc92 re-schedule truck il y a 1 mois
  tommy 2987847917 add precon il y a 1 mois
  tommy 66f05c1e75 report permission il y a 1 mois
  tommy e438ae1d29 Merge branch 'production' of https://git.2fi-solutions.com/jason/FPSMS-backend into production il y a 1 mois
  tommy 0bc0e86933 DeliveryNotePdf update il y a 1 mois
  kelvin.yau e1ecc73f33 pick record download + precondition checking il y a 1 mois
  CANCERYS\kw093 076ff1c555 ti-M merge il y a 1 mois
  CANCERYS\kw093 b02f176e08 job order bom status il y a 1 mois
  CANCERYS\kw093 9bbd43aade update do is extra same order il y a 1 mois
  tommy 65ed99ba39 translate and route schedule il y a 1 mois
  CANCERYS\kw093 51bb08e2f2 improt bom fix il y a 1 mois
  CANCERYS\kw093 9331b7ebdd bom import fix il y a 1 mois
  CANCERYS\kw093 1bd3af87b1 do search fix il y a 1 mois
  CANCERYS\kw093 46b5b9f8c8 do PDF fix il y a 2 mois
  CANCERYS\kw093 3b7199cd90 stock take batch save fix il y a 2 mois
  CANCERYS\kw093 76cab93f57 s il y a 2 mois
  CANCERYS\kw093 e67440f56f search stock take fix il y a 2 mois
  CANCERYS\kw093 9b787159ef update stock take il y a 2 mois
  CANCERYS\kw093 7bafe58558 update stock tkae fix il y a 2 mois
  CANCERYS\kw093 2383b62ad0 stock take update il y a 2 mois
  CANCERYS\kw093 03663cc801 update new stock take il y a 2 mois
  CANCERYS\kw093 9ef208fd75 bom combo fix il y a 2 mois
  vluk@2fi-solutions.com.hk edc2f8c0f2 no message il y a 2 mois
  vluk@2fi-solutions.com.hk f2fbc5d7dc no message il y a 2 mois
  vluk@2fi-solutions.com.hk 9dbd06e338 added for bom sync to m18 il y a 2 mois
  CANCERYS\kw093 6c095b3a29 product process fix il y a 2 mois
  B.E.N.S.O.N b180b73c97 膠茜數目使用數量 Update il y a 2 mois
  vluk@2fi-solutions.com.hk 6dc9d30292 no message il y a 2 mois
  vluk@2fi-solutions.com.hk a951485df2 no message il y a 2 mois
  vluk@2fi-solutions.com.hk 02811c401b no message il y a 2 mois
  CANCERYS\kw093 cf8f1564d1 Merge remote-tracking branch 'origin/production' into production il y a 2 mois
  CANCERYS\kw093 95f20378ea jo dashbaord update il y a 2 mois
  vluk@2fi-solutions.com.hk 74312d84bd don't overwrite the purchase order when sync from m18 if the status is not "pending" anymore il y a 2 mois
  CANCERYS\kw093 4cd0e5479a Merge remote-tracking branch 'origin/production' into production il y a 2 mois
  CANCERYS\kw093 c57bec2f74 update TRF fix il y a 2 mois
  vluk@2fi-solutions.com.hk 3f70bc475c refining the device monitoring page il y a 2 mois
  vluk@2fi-solutions.com.hk 9cdb1f71b8 added export do qty in /ps for daily delivery qty; added monitor page for production use il y a 2 mois
  CANCERYS\kw093 b8c608c5b4 chart improve il y a 2 mois
  kelvin.yau c5ea9b5f05 merge fix il y a 2 mois
  kelvin.yau 5b61294a53 Merge branch 'production' of https://git.2fi-solutions.com/derek/FPSMS-backend into production il y a 2 mois
  tommy e9f1f48edb routeboard il y a 2 mois
  CANCERYS\kw093 e78971d7b2 job order auto cancel and delay il y a 2 mois
  CANCERYS\kw093 f1a9d63a99 Merge remote-tracking branch 'origin/production' into production il y a 2 mois
  CANCERYS\kw093 1a6cb36897 inventory search fix il y a 2 mois
  tommy 57ab57dd65 routeboard il y a 2 mois
  CANCERYS\kw093 7141c0f6b4 chart sql improt il y a 2 mois
  CANCERYS\kw093 1d971256c4 fix bag lot line function slow query il y a 2 mois
  CANCERYS\kw093 870fbca20e new supplier il y a 2 mois
  CANCERYS\kw093 9dd08d6a70 update Report and stock ledger search il y a 2 mois
  kelvin.yau d4dc79d6fa UPDATE CODE FOR ENSON (DO MARK COMPLETED) il y a 2 mois
  vluk@2fi-solutions.com.hk dd348f36ae adding for bom sync il y a 2 mois
  vluk@2fi-solutions.com.hk 4b83633f28 updated Bag3, adding m18 BOM syn, delete the DO2_EXTRA syn, move the DO2 sync to 1pm il y a 2 mois
  B.E.N.S.O.N 891a929e1f User Page Update il y a 2 mois
  CANCERYS\kw093 078965686f Merge remote-tracking branch 'origin/production' into production il y a 2 mois
  CANCERYS\kw093 1b1f23c283 update pick order complete logci and shortner Transactional il y a 2 mois
  kelvin.yau 042828d96f fix il y a 2 mois
  CANCERYS\kw093 9491f0f32c update bom import il y a 2 mois
  vluk@2fi-solutions.com.hk 1472a05830 added isExtra and sync extra DO from m18 il y a 2 mois
  tommy 5cb1989ad6 truck dashboard update il y a 2 mois
  CANCERYS\kw093 d4af229304 update do search and jo bom name coe il y a 2 mois
  CANCERYS\kw093 b5af7aad05 update do finish jump page, il y a 2 mois
  kelvin.yau 5be61f895d Stock Adj fix il y a 2 mois
  CANCERYS\kw093 a10f069a4f update index again il y a 2 mois
  CANCERYS\kw093 2d738e9714 added index for delivery_order_pick_order il y a 2 mois
  vluk@2fi-solutions.com.hk 24ee1d8f11 revert the index il y a 2 mois
  vluk@2fi-solutions.com.hk e1902f3b0e added index to hep querying il y a 2 mois
  CANCERYS\kw093 15c961d543 update truck X and singal relesae il y a 2 mois
  CANCERYS\kw093 d5b94751e7 update scan pick reject list il y a 2 mois
  CANCERYS\kw093 ee2b4d255a update 2F assign by lance il y a 2 mois
  tommy 66df3b1db6 add logistic table , chnaged district reference type il y a 2 mois
  CANCERYS\kw093 292ae22a7e update do 4F assign by lance il y a 2 mois
  B.E.N.S.O.N 0a992c381d Merge remote-tracking branch 'origin/production' into production il y a 2 mois
  B.E.N.S.O.N 8002b6d621 QR Code Printing Update il y a 2 mois
  CANCERYS\kw093 777d962f12 update stock take batch handle efficient il y a 2 mois
  CANCERYS\kw093 31abe1b05a update stock take and stock take report and improved checkAndCompletePickOrderByConsoCode il y a 2 mois
  CANCERYS\kw093 d04e864323 fix worknbench floor problem il y a 2 mois
  DESKTOP-064TTA1\Fai LUK 4cb8d0d6de Merge branch 'master' into production il y a 2 mois
  kelvin.yau 59757eeacf New PO Workbench, Improve loading speed. il y a 3 mois
100 fichiers modifiés avec 9111 ajouts et 1214 suppressions
  1. +33
    -0
      .cursor/rules/frontend-prevent-duplicate-api-calls.mdc
  2. +785
    -0
      .cursor/skills/fp-mtms-version-checklist/SKILL.md
  3. +5
    -1
      .gitignore
  4. +387
    -173
      python/Bag3.py
  5. BIN
      python/__pycache__/Bag3.cpython-313.pyc
  6. +32
    -2
      python/installAndExe.txt
  7. +26
    -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. +714
    -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. +187
    -117
      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. +41
    -23
      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. +635
    -213
      src/main/java/com/ffii/fpsms/modules/deliveryOrder/service/DoWorkbenchMainService.kt
  57. +1139
    -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. +91
    -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. +30
    -0
      src/main/java/com/ffii/fpsms/modules/jobOrder/service/JoPickOrderService.kt
  76. +20
    -20
      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. +243
    -0
      src/main/java/com/ffii/fpsms/modules/jobOrder/service/JobOrderPlanStartAutoService.kt
  79. +104
    -3
      src/main/java/com/ffii/fpsms/modules/jobOrder/service/JobOrderService.kt
  80. +1
    -2
      src/main/java/com/ffii/fpsms/modules/jobOrder/service/LaserBag2AutoSendService.kt
  81. +196
    -0
      src/main/java/com/ffii/fpsms/modules/jobOrder/service/PSService.kt
  82. +7
    -4
      src/main/java/com/ffii/fpsms/modules/jobOrder/service/PlasticBagPrinterService.kt
  83. +24
    -8
      src/main/java/com/ffii/fpsms/modules/jobOrder/web/JobOrderController.kt
  84. +42
    -1
      src/main/java/com/ffii/fpsms/modules/jobOrder/web/PSController.kt
  85. +37
    -1
      src/main/java/com/ffii/fpsms/modules/jobOrder/web/model/CreateJobOrderRequest.kt
  86. +4
    -0
      src/main/java/com/ffii/fpsms/modules/jobOrder/web/model/ExportPickRecordRequest.kt
  87. +7
    -0
      src/main/java/com/ffii/fpsms/modules/jobOrder/web/model/PickRecordPlasticBoxCartonQtyResponse.kt
  88. +9
    -0
      src/main/java/com/ffii/fpsms/modules/jobOrder/web/model/PlasticBoxCartonQtyDashboardRecord.kt
  89. +4
    -0
      src/main/java/com/ffii/fpsms/modules/jobOrder/web/model/PrintPickRecordRequest.kt
  90. +33
    -0
      src/main/java/com/ffii/fpsms/modules/logistic/entity/Logistic.kt
  91. +12
    -0
      src/main/java/com/ffii/fpsms/modules/logistic/entity/LogisticRepository.kt
  92. +82
    -0
      src/main/java/com/ffii/fpsms/modules/logistic/service/LogisticService.kt
  93. +65
    -0
      src/main/java/com/ffii/fpsms/modules/logistic/web/LogisticController.kt
  94. +9
    -0
      src/main/java/com/ffii/fpsms/modules/logistic/web/models/DeleteLogisticRequest.kt
  95. +10
    -0
      src/main/java/com/ffii/fpsms/modules/logistic/web/models/LogisticResponse.kt
  96. +21
    -0
      src/main/java/com/ffii/fpsms/modules/logistic/web/models/SaveLogisticRequest.kt
  97. +12
    -0
      src/main/java/com/ffii/fpsms/modules/logistic/web/models/SaveLogisticsBatchRequest.kt
  98. +6
    -0
      src/main/java/com/ffii/fpsms/modules/master/entity/Bom.kt
  99. +14
    -0
      src/main/java/com/ffii/fpsms/modules/master/entity/BomMaterialRepository.kt
  100. +20
    -0
      src/main/java/com/ffii/fpsms/modules/master/entity/BomRepository.kt

+ 33
- 0
.cursor/rules/frontend-prevent-duplicate-api-calls.mdc Voir le fichier

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


+ 785
- 0
.cursor/skills/fp-mtms-version-checklist/SKILL.md Voir le fichier

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

# FP-MTMS Version Checklist Excel

## Purpose

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

## Language

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

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

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

**Exception — do NOT bilingualize:**

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

### Format

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

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

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

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

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

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

## Excel formatting (required on every write)

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

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

## Excel path (per developer)

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

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

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

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

### Archive Excel path

Same folder as the main checklist. Default:

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

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

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

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

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

Instead:

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

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

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

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

Before drafting or writing any row:

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

## History shift (required on every update)

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

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

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

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

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

Treat each “slot” as a triple:

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

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

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

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

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

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

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

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

Rules:

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

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

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

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

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

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

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

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

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

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

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

### Archive write rules

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

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

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

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

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

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

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

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

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

**Cell layout example:**

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

**Rules:**

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

## Batch updates

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

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

## Sheets and columns

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

### Sheet: `Page&Program Name`

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

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

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

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

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

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

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

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

Format:

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

Examples:

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

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

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

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

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

Format (one file per line):

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

Multi-file example:

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

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

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

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

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

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

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

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

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

Examples:

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

## Preview format (required before save)

Show a clear preview, for example:

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

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

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

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

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

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

## Workflow

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

## Code comments on every updated function (required)

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

### Comment contents (required fields)

Must include:

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

### Canonical format

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

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

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

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

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

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

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

### Placement rules

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

### Preview

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

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

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

## Inference hints (for filling the preview)

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

## Do not

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

+ 5
- 1
.gitignore Voir le fichier

@@ -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 Voir le fichier

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

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


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


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


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


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


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

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

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

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

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


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

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


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


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

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

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

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

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

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

root.after(0, _done)

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


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

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

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

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

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

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

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

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

root.after(0, _done)

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


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


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

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

BIN
python/__pycache__/Bag3.cpython-313.pyc Voir le fichier


+ 32
- 2
python/installAndExe.txt Voir le fichier

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

+ 26
- 0
src/main/java/com/ffii/fpsms/config/security/SecurityConfig.java Voir le fichier

@@ -91,6 +91,32 @@ 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")
.requestMatchers(HttpMethod.GET, "/inventoryLotLine/trace")
.hasAuthority("ITEM_TRACING")
.requestMatchers(HttpMethod.GET, "/inventoryLotLine/trace/location/**")
.hasAuthority("ITEM_TRACING")
.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 Voir le fichier

@@ -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 Voir le fichier

@@ -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 Voir le fichier

@@ -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 Voir le fichier

@@ -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 Voir le fichier

@@ -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 Voir le fichier

@@ -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 Voir le fichier

@@ -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 Voir le fichier

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

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

+ 714
- 0
src/main/java/com/ffii/fpsms/m18/service/M18BomForShopService.kt Voir le fichier

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

return PoSupplierUnitContext(supplierM18Id, purchaseUnitM18Id)
}

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

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

if (supplierCode == null) {
return directM18Id
}

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

+ 41
- 0
src/main/java/com/ffii/fpsms/m18/service/M18BomHeaderLookupService.kt Voir le fichier

@@ -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 Voir le fichier

@@ -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 Voir le fichier

@@ -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 Voir le fichier

@@ -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 Voir le fichier

@@ -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 Voir le fichier

@@ -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 Voir le fichier

@@ -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 Voir le fichier

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

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

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

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

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

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

+ 187
- 117
src/main/java/com/ffii/fpsms/modules/chart/service/ChartService.kt Voir le fichier

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

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

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

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

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

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

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

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

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

+ 11
- 5
src/main/java/com/ffii/fpsms/modules/chart/web/ChartController.kt Voir le fichier

@@ -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 Voir le fichier

@@ -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 Voir le fichier

@@ -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 Voir le fichier

@@ -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 Voir le fichier

@@ -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 Voir le fichier

@@ -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 Voir le fichier

@@ -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 Voir le fichier

@@ -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 Voir le fichier

@@ -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 Voir le fichier

@@ -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 Voir le fichier

@@ -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 Voir le fichier

@@ -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 Voir le fichier

@@ -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 Voir le fichier

@@ -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 Voir le fichier

@@ -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 Voir le fichier

@@ -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 Voir le fichier

@@ -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 Voir le fichier

@@ -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 Voir le fichier

@@ -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 Voir le fichier

@@ -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 Voir le fichier

@@ -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 Voir le fichier

@@ -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 Voir le fichier

@@ -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 Voir le fichier

@@ -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
Fichier diff supprimé car celui-ci est trop grand
Voir le fichier


+ 95
- 0
src/main/java/com/ffii/fpsms/modules/deliveryOrder/service/DoFloorSupplierSettingsService.kt Voir le fichier

@@ -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 Voir le fichier

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

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

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

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

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


+ 41
- 23
src/main/java/com/ffii/fpsms/modules/deliveryOrder/service/DoReleaseCoordinatorService.kt Voir le fichier

@@ -103,6 +103,7 @@ class DoReleaseCoordinatorService(
private val userRepository: UserRepository,
private val pickOrderRepository: PickOrderRepository,
private val doPickOrderRecordRepository: DoPickOrderRecordRepository,
private val doFloorSupplierSettingsService: DoFloorSupplierSettingsService,
) {
private val poolSize = Runtime.getRuntime().availableProcessors()
private val executor = Executors.newFixedThreadPool(min(poolSize, 4))
@@ -140,22 +141,15 @@ class DoReleaseCoordinatorService(
private fun updateBatchTicketNumbers() {
try {
val dayOfWeekSql = getDayOfWeekAbbrSql("do.estimatedArrivalDate")
val pfCases = doFloorSupplierSettingsService.sqlPreferredFloorCases("s.code")
val updateSql = """
UPDATE fpsmsdb.do_pick_order dpo
INNER JOIN (
WITH PreferredFloor AS (
SELECT
do.id AS deliveryOrderId,
CASE
WHEN s.code = 'P06B' THEN '4F'
WHEN s.code = 'P07' OR s.code = 'P06D' THEN '2F'
ELSE NULL
END AS preferred_floor,
CASE
WHEN s.code = 'P06B' THEN 4
WHEN s.code = 'P07' OR s.code = 'P06D' THEN 2
ELSE NULL
END AS preferred_store_id
${pfCases.floorStringCase} AS preferred_floor,
${pfCases.storeIdNumericCase} AS preferred_store_id
FROM fpsmsdb.delivery_order do
LEFT JOIN fpsmsdb.shop s ON s.id = do.supplierId AND s.deleted = 0
WHERE do.deleted = 0
@@ -307,20 +301,13 @@ class DoReleaseCoordinatorService(
println(" DEBUG: Getting ordered IDs for ${ids.size} orders")
println(" DEBUG: First 5 IDs: ${ids.take(5)}")
val dayOfWeekSql = getDayOfWeekAbbrSql("do.estimatedArrivalDate")
val pfCases = doFloorSupplierSettingsService.sqlPreferredFloorCases("s.code")
val sql = """
WITH PreferredFloor AS (
SELECT
do.id AS deliveryOrderId,
CASE
WHEN s.code = 'P06B' THEN '4F'
WHEN s.code = 'P07' OR s.code = 'P06D' THEN '2F'
ELSE NULL
END AS preferred_floor,
CASE
WHEN s.code = 'P06B' THEN 4
WHEN s.code = 'P07' OR s.code = 'P06D' THEN 2
ELSE NULL
END AS preferred_store_id
${pfCases.floorStringCase} AS preferred_floor,
${pfCases.storeIdNumericCase} AS preferred_store_id
FROM fpsmsdb.delivery_order do
LEFT JOIN fpsmsdb.shop s ON s.id = do.supplierId AND s.deleted = 0
WHERE do.id IN (${ids.joinToString(",")})
@@ -719,8 +706,11 @@ class DoReleaseCoordinatorService(
val existing = candidates.firstOrNull { c ->
c.truckId == first.truckId &&
c.truckDepartureTime == first.truckDepartureTime &&
c.truckLanceCode == first.truckLanceCode
} ?: candidates.minByOrNull { it.id ?: Long.MAX_VALUE }
c.truckLanceCode == first.truckLanceCode &&
(storeId != null || doPickOrderMatchesTruckXPreferredFloor(c, first.preferredFloor))
} ?: candidates
.filter { storeId != null || doPickOrderMatchesTruckXPreferredFloor(it, first.preferredFloor) }
.minByOrNull { it.id ?: Long.MAX_VALUE }
if (existing != null) {
println(
" DEBUG: Merging batch into existing DoPickOrder id=${existing.id}, ticket=${existing.ticketNo} " +
@@ -767,6 +757,33 @@ class DoReleaseCoordinatorService(

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

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

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


+ 583
- 0
src/main/java/com/ffii/fpsms/modules/deliveryOrder/service/DoReplenishmentService.kt Voir le fichier

@@ -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 Voir le fichier

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


+ 635
- 213
src/main/java/com/ffii/fpsms/modules/deliveryOrder/service/DoWorkbenchMainService.kt
Fichier diff supprimé car celui-ci est trop grand
Voir le fichier


+ 1139
- 98
src/main/java/com/ffii/fpsms/modules/deliveryOrder/service/DoWorkbenchReleaseService.kt
Fichier diff supprimé car celui-ci est trop grand
Voir le fichier


+ 55
- 0
src/main/java/com/ffii/fpsms/modules/deliveryOrder/service/TruckLaneSearchSpec.kt Voir le fichier

@@ -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 Voir le fichier

@@ -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 Voir le fichier

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

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

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

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

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

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

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

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

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

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


+ 91
- 8
src/main/java/com/ffii/fpsms/modules/deliveryOrder/web/DoWorkbenchController.kt Voir le fichier

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

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

/** Past-date backlog tickets from `delivery_order_pick_order` (not `do_pick_order`). */
@GetMapping("/released")
fun getWorkbenchReleasedDoPickOrders(
@RequestParam(required = false) shopName: String?,
@RequestParam(required = false) storeId: String?,
@RequestParam(required = false) truck: String?
@RequestParam(required = false) truck: String?,
@RequestParam(required = false) releaseType: String?,
/** Optional `2F`/`4F`: Truck X list split by DO supplier preferred floor (storeId stays null). */
@RequestParam(required = false) floor: String?,
): List<ReleasedDoPickOrderListItem> {
return doWorkbenchMainService.findWorkbenchReleasedDeliveryOrderPickOrdersForSelection(shopName, storeId, truck)
return doWorkbenchMainService.findWorkbenchReleasedDeliveryOrderPickOrdersForSelection(
shopName,
storeId,
truck,
releaseTypeFilter = releaseType,
floor = floor,
)
}

@GetMapping("/released-today")
fun getWorkbenchReleasedDoPickOrdersToday(
@RequestParam(required = false) shopName: String?,
@RequestParam(required = false) storeId: String?,
@RequestParam(required = false) truck: String?
@RequestParam(required = false) truck: String?,
@RequestParam(required = false) @DateTimeFormat(iso = DateTimeFormat.ISO.DATE) requiredDate: LocalDate?,
@RequestParam(required = false) releaseType: String?,
/** Optional `2F`/`4F`: Truck X list split by DO supplier preferred floor (storeId stays null). */
@RequestParam(required = false) floor: String?,
): List<ReleasedDoPickOrderListItem> {
return doWorkbenchMainService.findWorkbenchReleasedDeliveryOrderPickOrdersForSelectionToday(shopName, storeId, truck)
return doWorkbenchMainService.findWorkbenchReleasedDeliveryOrderPickOrdersForSelectionToday(
shopName,
storeId,
truck,
requiredDeliveryDate = requiredDate,
releaseTypeFilter = releaseType,
floor = floor,
)
}

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

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

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

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

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

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

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

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


+ 30
- 2
src/main/java/com/ffii/fpsms/modules/deliveryOrder/web/models/DoDetailResponse.kt Voir le fichier

@@ -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 Voir le fichier

@@ -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 Voir le fichier

@@ -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 Voir le fichier

@@ -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 Voir le fichier

@@ -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 Voir le fichier

@@ -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 Voir le fichier

@@ -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 Voir le fichier

@@ -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 Voir le fichier

@@ -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 Voir le fichier

@@ -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 Voir le fichier

@@ -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 Voir le fichier

@@ -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 Voir le fichier

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


+ 30
- 0
src/main/java/com/ffii/fpsms/modules/jobOrder/service/JoPickOrderService.kt Voir le fichier

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


+ 20
- 20
src/main/java/com/ffii/fpsms/modules/jobOrder/service/JoWorkbenchMainService.kt Voir le fichier

@@ -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,7 +192,7 @@ 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 {
open fun getJobOrderLotsHierarchicalByPickOrderIdWorkbench(pickOrderId: Long): JobOrderLotsHierarchicalWorkbenchResponse {
println("=== JoWorkbenchMainService.getJobOrderLotsHierarchicalByPickOrderIdWorkbench ===")
println("pickOrderId: $pickOrderId")

@@ -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 Voir le fichier

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

+ 243
- 0
src/main/java/com/ffii/fpsms/modules/jobOrder/service/JobOrderPlanStartAutoService.kt Voir le fichier

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

+ 104
- 3
src/main/java/com/ffii/fpsms/modules/jobOrder/service/JobOrderService.kt Voir le fichier

@@ -737,6 +737,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 +907,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 +921,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



+ 1
- 2
src/main/java/com/ffii/fpsms/modules/jobOrder/service/LaserBag2AutoSendService.kt Voir le fichier

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


+ 196
- 0
src/main/java/com/ffii/fpsms/modules/jobOrder/service/PSService.kt Voir le fichier

@@ -7,6 +7,13 @@ 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(
@@ -160,6 +167,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.description = '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 +264,150 @@ 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 DISTINCT
items.code AS itemCode,
items.name AS itemName,
uc_stock.udfudesc AS stockUnit
FROM bom
INNER JOIN items ON bom.itemId = items.id 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
WHERE bom.deleted = 0
ORDER BY items.code
""".trimIndent()
return jdbcDao.queryForList(sql, emptyMap<String, Any>())
}

/**
* Sum of [delivery_order_line.qty] (already stored in stock unit) by item and ETA date.
* Only items that have a BOM.
*/
fun sumDeliveryOrderQtyByItemAndDate(fromDate: LocalDate, toDate: LocalDate): List<Map<String, Any>> {
val args = mapOf(
"fromDate" to fromDate.toString(),
"toDate" to toDate.toString(),
)
val sql = """
SELECT
items.code AS itemCode,
DATE(do.estimatedArrivalDate) AS shipDate,
SUM(COALESCE(dol.qty, 0)) AS qtySum
FROM delivery_order 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
WHERE do.deleted = 0
AND do.estimatedArrivalDate IS NOT NULL
AND DATE(do.estimatedArrivalDate) >= :fromDate
AND DATE(do.estimatedArrivalDate) <= :toDate
AND EXISTS (
SELECT 1 FROM bom b
WHERE b.itemId = items.id AND b.deleted = 0
)
GROUP BY items.code, DATE(do.estimatedArrivalDate)
ORDER BY items.code, shipDate
""".trimIndent()
return jdbcDao.queryForList(sql, args)
}

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 qtyRows = sumDeliveryOrderQtyByItemAndDate(fromDate, toDate)

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 numberStyle = workbook.createCellStyle().apply {
verticalAlignment = VerticalAlignment.CENTER
dataFormat = workbook.createDataFormat().getFormat("#,##0")
}

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).apply {
setCellValue(qty.toLong().toDouble())
cellStyle = numberStyle
}
}
}

for (col in 0 until headers.size) {
sheet.autoSizeColumn(col)
}

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 Voir le fichier

@@ -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 Voir le fichier

@@ -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 Voir le fichier

@@ -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 Voir le fichier

@@ -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 Voir le fichier

@@ -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 Voir le fichier

@@ -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 Voir le fichier

@@ -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 Voir le fichier

@@ -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 Voir le fichier

@@ -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 Voir le fichier

@@ -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 Voir le fichier

@@ -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 Voir le fichier

@@ -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 Voir le fichier

@@ -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 Voir le fichier

@@ -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 Voir le fichier

@@ -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 Voir le fichier

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

+ 6
- 0
src/main/java/com/ffii/fpsms/modules/master/entity/Bom.kt Voir le fichier

@@ -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
@@ -87,4 +89,8 @@ 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
}

+ 14
- 0
src/main/java/com/ffii/fpsms/modules/master/entity/BomMaterialRepository.kt Voir le fichier

@@ -1,6 +1,7 @@
package com.ffii.fpsms.modules.master.entity

import com.ffii.core.support.AbstractRepository
import org.springframework.data.jpa.repository.Query
import org.springframework.stereotype.Repository
import java.io.Serializable

@@ -15,4 +16,17 @@ interface BomMaterialRepository : AbstractRepository<BomMaterial, Long> {
fun findAllByBomIdAndDeletedIsFalse(bomId: Long): List<BomMaterial>

fun findByBomIdAndItemId(bomId: Long, itemId: Long): BomMaterial?

/** Single round-trip for master-data scans (avoids per-bom N+1). */
@Query(
"""
SELECT bm FROM BomMaterial bm
JOIN FETCH bm.bom b
LEFT JOIN FETCH bm.item
LEFT JOIN FETCH bm.uom
LEFT JOIN FETCH bm.salesUnit
WHERE bm.deleted = false AND b.deleted = false
""",
)
fun findAllActiveForActiveBoms(): List<BomMaterial>
}

+ 20
- 0
src/main/java/com/ffii/fpsms/modules/master/entity/BomRepository.kt Voir le fichier

@@ -2,6 +2,7 @@ package com.ffii.fpsms.modules.master.entity

import com.ffii.core.support.AbstractRepository
import com.ffii.fpsms.modules.master.entity.projections.BomCombo
import com.ffii.fpsms.modules.master.enums.BomStatus
import org.springframework.stereotype.Repository
import java.io.Serializable
import org.springframework.data.jpa.repository.Query
@@ -10,6 +11,16 @@ import org.springframework.data.repository.query.Param
interface BomRepository : AbstractRepository<Bom, Long> {
fun findAllByDeletedIsFalse(): List<Bom>

@Query(
"""
SELECT b FROM Bom b
LEFT JOIN FETCH b.item
LEFT JOIN FETCH b.uom
WHERE b.deleted = false
""",
)
fun findAllActiveWithItemAndUom(): List<Bom>

fun findByIdAndDeletedIsFalse(id: Serializable): Bom?

fun findByM18IdAndDeletedIsFalse(m18Id: Long): Bom?
@@ -17,8 +28,17 @@ interface BomRepository : AbstractRepository<Bom, Long> {
fun findByItemIdAndDeletedIsFalse(itemId: Serializable): Bom?

fun findBomComboByDeletedIsFalse(): List<BomCombo>

fun findBomComboByDeletedIsFalseAndStatus(status: BomStatus): List<BomCombo>
fun findAllByItemIdAndDeletedIsFalse(itemId: Long): List<Bom>
fun findAllByItemIdAndStatusAndDeletedIsFalse(itemId: Long, status: BomStatus): List<Bom>
@Query("SELECT b.id FROM Bom b WHERE b.deleted = false ORDER BY b.id")
fun findAllIdsByDeletedIsFalse(): List<Long>

fun findByCodeAndDeletedIsFalse(code: String): Bom?

fun findByCodeAndDescriptionIgnoreCaseAndDeletedIsFalse(code: String, description: String): Bom?

@Query("""
select b.item.id
from Bom b


Certains fichiers n'ont pas été affichés car il y a eu trop de fichiers modifiés dans ce diff

Chargement…
Annuler
Enregistrer