Confronta commit

...

105 Commit

Autore SHA1 Messaggio Data
  tommy 8aa202052b label printer tracking update 4 giorni fa
  tommy 76ff1cd22f label printer 4 giorni fa
  tommy ac238e7ba3 stocktake report update 4 giorni fa
  [email protected] 7db4286368 no message 4 giorni fa
  [email protected] 1035482880 no message 4 giorni fa
  [email protected] 4399ac6d22 no message 4 giorni fa
  [email protected] a7d3f156a5 no message 4 giorni fa
  [email protected] 5eba1a42f2 no message 4 giorni fa
  [email protected] f08f06da0f try to fix the bom item error 4 giorni fa
  CANCERYS\kw093 44646254f6 do skip isfee item 5 giorni fa
  [email protected] 4d38dcff74 no message 6 giorni fa
  B.E.N.S.O.N 172d32f613 成品出倉執貨時 標籤列印時頁數顯示空白 6 giorni fa
  [email protected] a111a80bf6 handle the laser printer that failed to print 1175 full name with (uom) 6 giorni fa
  [email protected] 193a1824a2 added a report to check bom sync history 1 settimana fa
  [email protected] 4d01f1d84f fixing the non-unique vendor result of bom finding 1 settimana fa
  [email protected] 506d39c7f3 try to update the m18 bom with a new version number 1 settimana fa
  kelvin.yau b705a8acb6 replenishment update 1 settimana fa
  CANCERYS\kw093 a0b3a58d65 report fix 2 1 settimana fa
  CANCERYS\kw093 76a2ace7f9 rpeort fix 1 settimana fa
  CANCERYS\kw093 88e36de88c 補貨V1 1 settimana fa
  [email protected] cdbfb157ca no message 1 settimana fa
  [email protected] 13d646af6e Merge branch 'production' of https://git.2fi-solutions.com/jason/FPSMS-backend into production 1 settimana fa
  [email protected] 1c1eadc71d alter for the BOM sync finding PP purchase order supplier and also others for supplier id by code 1 settimana fa
  tommy 22e982fea1 補貨 + truck scheduler update 1 settimana fa
  [email protected] 306a8474c6 updates for some items missing the () uom of job order list which using by python 1 settimana fa
  [email protected] 064d379229 Bag3 1 settimana fa
  kelvin.yau f74dc566ee replenishment setup 1 settimana fa
  tommy 5f816fcc92 re-schedule truck 1 settimana fa
  tommy 2987847917 add precon 1 settimana fa
  tommy 66f05c1e75 report permission 1 settimana fa
  tommy e438ae1d29 Merge branch 'production' of https://git.2fi-solutions.com/jason/FPSMS-backend into production 1 settimana fa
  tommy 0bc0e86933 DeliveryNotePdf update 1 settimana fa
  kelvin.yau e1ecc73f33 pick record download + precondition checking 1 settimana fa
  CANCERYS\kw093 076ff1c555 ti-M merge 1 settimana fa
  CANCERYS\kw093 b02f176e08 job order bom status 1 settimana fa
  CANCERYS\kw093 9bbd43aade update do is extra same order 1 settimana fa
  tommy 65ed99ba39 translate and route schedule 2 settimane fa
  CANCERYS\kw093 51bb08e2f2 improt bom fix 2 settimane fa
  CANCERYS\kw093 9331b7ebdd bom import fix 2 settimane fa
  CANCERYS\kw093 1bd3af87b1 do search fix 3 settimane fa
  CANCERYS\kw093 46b5b9f8c8 do PDF fix 3 settimane fa
  CANCERYS\kw093 3b7199cd90 stock take batch save fix 3 settimane fa
  CANCERYS\kw093 76cab93f57 s 3 settimane fa
  CANCERYS\kw093 e67440f56f search stock take fix 3 settimane fa
  CANCERYS\kw093 9b787159ef update stock take 3 settimane fa
  CANCERYS\kw093 7bafe58558 update stock tkae fix 3 settimane fa
  CANCERYS\kw093 2383b62ad0 stock take update 3 settimane fa
  CANCERYS\kw093 03663cc801 update new stock take 3 settimane fa
  CANCERYS\kw093 9ef208fd75 bom combo fix 3 settimane fa
  [email protected] edc2f8c0f2 no message 1 mese fa
  [email protected] f2fbc5d7dc no message 1 mese fa
  [email protected] 9dbd06e338 added for bom sync to m18 1 mese fa
  CANCERYS\kw093 6c095b3a29 product process fix 1 mese fa
  B.E.N.S.O.N b180b73c97 膠茜數目使用數量 Update 1 mese fa
  [email protected] 6dc9d30292 no message 1 mese fa
  [email protected] a951485df2 no message 1 mese fa
  [email protected] 02811c401b no message 1 mese fa
  CANCERYS\kw093 cf8f1564d1 Merge remote-tracking branch 'origin/production' into production 1 mese fa
  CANCERYS\kw093 95f20378ea jo dashbaord update 1 mese fa
  [email protected] 74312d84bd don't overwrite the purchase order when sync from m18 if the status is not "pending" anymore 1 mese fa
  CANCERYS\kw093 4cd0e5479a Merge remote-tracking branch 'origin/production' into production 1 mese fa
  CANCERYS\kw093 c57bec2f74 update TRF fix 1 mese fa
  [email protected] 3f70bc475c refining the device monitoring page 1 mese fa
  [email protected] 9cdb1f71b8 added export do qty in /ps for daily delivery qty; added monitor page for production use 1 mese fa
  CANCERYS\kw093 b8c608c5b4 chart improve 1 mese fa
  kelvin.yau c5ea9b5f05 merge fix 1 mese fa
  kelvin.yau 5b61294a53 Merge branch 'production' of https://git.2fi-solutions.com/derek/FPSMS-backend into production 1 mese fa
  tommy e9f1f48edb routeboard 1 mese fa
  CANCERYS\kw093 e78971d7b2 job order auto cancel and delay 1 mese fa
  CANCERYS\kw093 f1a9d63a99 Merge remote-tracking branch 'origin/production' into production 1 mese fa
  CANCERYS\kw093 1a6cb36897 inventory search fix 1 mese fa
  tommy 57ab57dd65 routeboard 1 mese fa
  CANCERYS\kw093 7141c0f6b4 chart sql improt 1 mese fa
  CANCERYS\kw093 1d971256c4 fix bag lot line function slow query 1 mese fa
  CANCERYS\kw093 870fbca20e new supplier 1 mese fa
  CANCERYS\kw093 9dd08d6a70 update Report and stock ledger search 1 mese fa
  kelvin.yau d4dc79d6fa UPDATE CODE FOR ENSON (DO MARK COMPLETED) 1 mese fa
  [email protected] dd348f36ae adding for bom sync 1 mese fa
  [email protected] 4b83633f28 updated Bag3, adding m18 BOM syn, delete the DO2_EXTRA syn, move the DO2 sync to 1pm 1 mese fa
  B.E.N.S.O.N 891a929e1f User Page Update 1 mese fa
  CANCERYS\kw093 078965686f Merge remote-tracking branch 'origin/production' into production 1 mese fa
  CANCERYS\kw093 1b1f23c283 update pick order complete logci and shortner Transactional 1 mese fa
  kelvin.yau 042828d96f fix 1 mese fa
  CANCERYS\kw093 9491f0f32c update bom import 1 mese fa
  [email protected] 1472a05830 added isExtra and sync extra DO from m18 1 mese fa
  tommy 5cb1989ad6 truck dashboard update 1 mese fa
  CANCERYS\kw093 d4af229304 update do search and jo bom name coe 1 mese fa
  CANCERYS\kw093 b5af7aad05 update do finish jump page, 1 mese fa
  kelvin.yau 5be61f895d Stock Adj fix 1 mese fa
  CANCERYS\kw093 a10f069a4f update index again 1 mese fa
  CANCERYS\kw093 2d738e9714 added index for delivery_order_pick_order 1 mese fa
  [email protected] 24ee1d8f11 revert the index 1 mese fa
  [email protected] e1902f3b0e added index to hep querying 1 mese fa
  CANCERYS\kw093 15c961d543 update truck X and singal relesae 1 mese fa
  CANCERYS\kw093 d5b94751e7 update scan pick reject list 1 mese fa
  CANCERYS\kw093 ee2b4d255a update 2F assign by lance 1 mese fa
  tommy 66df3b1db6 add logistic table , chnaged district reference type 1 mese fa
  CANCERYS\kw093 292ae22a7e update do 4F assign by lance 1 mese fa
  B.E.N.S.O.N 0a992c381d Merge remote-tracking branch 'origin/production' into production 1 mese fa
  B.E.N.S.O.N 8002b6d621 QR Code Printing Update 1 mese fa
  CANCERYS\kw093 777d962f12 update stock take batch handle efficient 1 mese fa
  CANCERYS\kw093 31abe1b05a update stock take and stock take report and improved checkAndCompletePickOrderByConsoCode 1 mese fa
  CANCERYS\kw093 d04e864323 fix worknbench floor problem 1 mese fa
  DESKTOP-064TTA1\Fai LUK 4cb8d0d6de Merge branch 'master' into production 1 mese fa
  kelvin.yau 59757eeacf New PO Workbench, Improve loading speed. 1 mese fa
100 ha cambiato i file con 8598 aggiunte e 1514 eliminazioni
  1. +33
    -0
      .cursor/rules/frontend-prevent-duplicate-api-calls.mdc
  2. +5
    -1
      .gitignore
  3. +387
    -173
      python/Bag3.py
  4. BIN
      python/__pycache__/Bag3.cpython-313.pyc
  5. +32
    -2
      python/installAndExe.txt
  6. +18
    -0
      src/main/java/com/ffii/fpsms/config/security/SecurityConfig.java
  7. +48
    -0
      src/main/java/com/ffii/fpsms/m18/entity/M18BomShopSyncLog.kt
  8. +49
    -0
      src/main/java/com/ffii/fpsms/m18/entity/M18BomShopSyncLogRepository.kt
  9. +14
    -0
      src/main/java/com/ffii/fpsms/m18/model/M18BomForShopSaveAttemptResult.kt
  10. +96
    -0
      src/main/java/com/ffii/fpsms/m18/model/M18BomForShopSaveRequest.kt
  11. +18
    -0
      src/main/java/com/ffii/fpsms/m18/model/M18BomShopBatchSyncSummary.kt
  12. +14
    -0
      src/main/java/com/ffii/fpsms/m18/model/M18BomShopSyncTriggerResult.kt
  13. +648
    -0
      src/main/java/com/ffii/fpsms/m18/service/M18BomForShopService.kt
  14. +41
    -0
      src/main/java/com/ffii/fpsms/m18/service/M18BomHeaderLookupService.kt
  15. +107
    -25
      src/main/java/com/ffii/fpsms/m18/service/M18DeliveryOrderService.kt
  16. +32
    -8
      src/main/java/com/ffii/fpsms/m18/service/M18MasterDataService.kt
  17. +32
    -9
      src/main/java/com/ffii/fpsms/m18/service/M18PurchaseOrderService.kt
  18. +43
    -0
      src/main/java/com/ffii/fpsms/m18/service/M18VendorLookupService.kt
  19. +19
    -2
      src/main/java/com/ffii/fpsms/m18/web/M18TestController.kt
  20. +1
    -1
      src/main/java/com/ffii/fpsms/modules/bag/service/bagService.kt
  21. +13
    -27
      src/main/java/com/ffii/fpsms/modules/bag/web/bagController.kt
  22. +187
    -117
      src/main/java/com/ffii/fpsms/modules/chart/service/ChartService.kt
  23. +11
    -5
      src/main/java/com/ffii/fpsms/modules/chart/web/ChartController.kt
  24. +15
    -0
      src/main/java/com/ffii/fpsms/modules/common/SettingNames.java
  25. +312
    -24
      src/main/java/com/ffii/fpsms/modules/common/scheduler/service/SchedulerService.kt
  26. +28
    -0
      src/main/java/com/ffii/fpsms/modules/common/scheduler/web/SchedulerController.kt
  27. +4
    -0
      src/main/java/com/ffii/fpsms/modules/deliveryOrder/entity/DeliveryOrder.kt
  28. +4
    -0
      src/main/java/com/ffii/fpsms/modules/deliveryOrder/entity/DeliveryOrderPickOrder.kt
  29. +6
    -0
      src/main/java/com/ffii/fpsms/modules/deliveryOrder/entity/DeliveryOrderRepository.kt
  30. +2
    -0
      src/main/java/com/ffii/fpsms/modules/deliveryOrder/entity/DoPickOrderRecordRepository.kt
  31. +2
    -0
      src/main/java/com/ffii/fpsms/modules/deliveryOrder/entity/DoPickOrderRepository.kt
  32. +106
    -0
      src/main/java/com/ffii/fpsms/modules/deliveryOrder/entity/DoReplenishment.kt
  33. +84
    -0
      src/main/java/com/ffii/fpsms/modules/deliveryOrder/entity/DoReplenishmentRepository.kt
  34. +5
    -1
      src/main/java/com/ffii/fpsms/modules/deliveryOrder/entity/models/DeliveryOrderInfo.kt
  35. +766
    -277
      src/main/java/com/ffii/fpsms/modules/deliveryOrder/service/DeliveryOrderService.kt
  36. +95
    -0
      src/main/java/com/ffii/fpsms/modules/deliveryOrder/service/DoFloorSupplierSettingsService.kt
  37. +80
    -129
      src/main/java/com/ffii/fpsms/modules/deliveryOrder/service/DoPickOrderService.kt
  38. +7
    -20
      src/main/java/com/ffii/fpsms/modules/deliveryOrder/service/DoReleaseCoordinatorService.kt
  39. +556
    -0
      src/main/java/com/ffii/fpsms/modules/deliveryOrder/service/DoReplenishmentService.kt
  40. +37
    -4
      src/main/java/com/ffii/fpsms/modules/deliveryOrder/service/DoWorkbenchDopoAssignmentService.kt
  41. +491
    -205
      src/main/java/com/ffii/fpsms/modules/deliveryOrder/service/DoWorkbenchMainService.kt
  42. +1083
    -98
      src/main/java/com/ffii/fpsms/modules/deliveryOrder/service/DoWorkbenchReleaseService.kt
  43. +55
    -0
      src/main/java/com/ffii/fpsms/modules/deliveryOrder/service/TruckLaneSearchSpec.kt
  44. +65
    -0
      src/main/java/com/ffii/fpsms/modules/deliveryOrder/service/WorkbenchReleaseTypeSupport.kt
  45. +52
    -2
      src/main/java/com/ffii/fpsms/modules/deliveryOrder/web/DeliveryOrderController.kt
  46. +85
    -8
      src/main/java/com/ffii/fpsms/modules/deliveryOrder/web/DoWorkbenchController.kt
  47. +30
    -2
      src/main/java/com/ffii/fpsms/modules/deliveryOrder/web/models/DoDetailResponse.kt
  48. +60
    -0
      src/main/java/com/ffii/fpsms/modules/deliveryOrder/web/models/DoReplenishmentModels.kt
  49. +1
    -0
      src/main/java/com/ffii/fpsms/modules/deliveryOrder/web/models/ExportDNLabelsRequest.kt
  50. +2
    -1
      src/main/java/com/ffii/fpsms/modules/deliveryOrder/web/models/PrintDNLabelsRequest.kt
  51. +7
    -2
      src/main/java/com/ffii/fpsms/modules/deliveryOrder/web/models/ReleaseDoRequest.kt
  52. +1
    -0
      src/main/java/com/ffii/fpsms/modules/deliveryOrder/web/models/SaveDeliveryOrderRequest.kt
  53. +5
    -0
      src/main/java/com/ffii/fpsms/modules/deliveryOrder/web/models/TicketReleaseTableResponse.kt
  54. +4
    -0
      src/main/java/com/ffii/fpsms/modules/deliveryOrder/web/models/TruckScheduleDashboardResponse.kt
  55. +11
    -0
      src/main/java/com/ffii/fpsms/modules/deliveryOrder/web/models/WorkbenchBatchReleaseRequest.kt
  56. +35
    -0
      src/main/java/com/ffii/fpsms/modules/deliveryOrder/web/models/WorkbenchMergeTicketModels.kt
  57. +5
    -0
      src/main/java/com/ffii/fpsms/modules/deliveryOrder/web/models/WorkbenchTicketReleaseTableResponse.kt
  58. +1
    -7
      src/main/java/com/ffii/fpsms/modules/jobOrder/scheduler/LaserBag2AutoSendScheduler.kt
  59. +30
    -0
      src/main/java/com/ffii/fpsms/modules/jobOrder/service/JoPickOrderService.kt
  60. +42
    -15
      src/main/java/com/ffii/fpsms/modules/jobOrder/service/JoWorkbenchMainService.kt
  61. +243
    -0
      src/main/java/com/ffii/fpsms/modules/jobOrder/service/JobOrderPlanStartAutoService.kt
  62. +104
    -3
      src/main/java/com/ffii/fpsms/modules/jobOrder/service/JobOrderService.kt
  63. +1
    -2
      src/main/java/com/ffii/fpsms/modules/jobOrder/service/LaserBag2AutoSendService.kt
  64. +196
    -0
      src/main/java/com/ffii/fpsms/modules/jobOrder/service/PSService.kt
  65. +7
    -4
      src/main/java/com/ffii/fpsms/modules/jobOrder/service/PlasticBagPrinterService.kt
  66. +24
    -8
      src/main/java/com/ffii/fpsms/modules/jobOrder/web/JobOrderController.kt
  67. +42
    -1
      src/main/java/com/ffii/fpsms/modules/jobOrder/web/PSController.kt
  68. +37
    -1
      src/main/java/com/ffii/fpsms/modules/jobOrder/web/model/CreateJobOrderRequest.kt
  69. +4
    -0
      src/main/java/com/ffii/fpsms/modules/jobOrder/web/model/ExportPickRecordRequest.kt
  70. +7
    -0
      src/main/java/com/ffii/fpsms/modules/jobOrder/web/model/PickRecordPlasticBoxCartonQtyResponse.kt
  71. +9
    -0
      src/main/java/com/ffii/fpsms/modules/jobOrder/web/model/PlasticBoxCartonQtyDashboardRecord.kt
  72. +4
    -0
      src/main/java/com/ffii/fpsms/modules/jobOrder/web/model/PrintPickRecordRequest.kt
  73. +33
    -0
      src/main/java/com/ffii/fpsms/modules/logistic/entity/Logistic.kt
  74. +12
    -0
      src/main/java/com/ffii/fpsms/modules/logistic/entity/LogisticRepository.kt
  75. +82
    -0
      src/main/java/com/ffii/fpsms/modules/logistic/service/LogisticService.kt
  76. +65
    -0
      src/main/java/com/ffii/fpsms/modules/logistic/web/LogisticController.kt
  77. +9
    -0
      src/main/java/com/ffii/fpsms/modules/logistic/web/models/DeleteLogisticRequest.kt
  78. +10
    -0
      src/main/java/com/ffii/fpsms/modules/logistic/web/models/LogisticResponse.kt
  79. +21
    -0
      src/main/java/com/ffii/fpsms/modules/logistic/web/models/SaveLogisticRequest.kt
  80. +12
    -0
      src/main/java/com/ffii/fpsms/modules/logistic/web/models/SaveLogisticsBatchRequest.kt
  81. +6
    -0
      src/main/java/com/ffii/fpsms/modules/master/entity/Bom.kt
  82. +14
    -0
      src/main/java/com/ffii/fpsms/modules/master/entity/BomMaterialRepository.kt
  83. +20
    -0
      src/main/java/com/ffii/fpsms/modules/master/entity/BomRepository.kt
  84. +22
    -0
      src/main/java/com/ffii/fpsms/modules/master/entity/ItemUomRespository.kt
  85. +4
    -3
      src/main/java/com/ffii/fpsms/modules/master/entity/ShopAndTruck.kt
  86. +12
    -0
      src/main/java/com/ffii/fpsms/modules/master/entity/ShopRepository.kt
  87. +20
    -0
      src/main/java/com/ffii/fpsms/modules/master/entity/WarehouseRepository.kt
  88. +3
    -1
      src/main/java/com/ffii/fpsms/modules/master/entity/projections/BomCombo.kt
  89. +1
    -1
      src/main/java/com/ffii/fpsms/modules/master/entity/projections/ShopAndTruck.kt
  90. +12
    -0
      src/main/java/com/ffii/fpsms/modules/master/enums/BomStatus.kt
  91. +12
    -0
      src/main/java/com/ffii/fpsms/modules/master/enums/BomStatusConverter.kt
  92. +53
    -0
      src/main/java/com/ffii/fpsms/modules/master/service/BomM18ShopBulkPushService.kt
  93. +421
    -284
      src/main/java/com/ffii/fpsms/modules/master/service/BomService.kt
  94. +52
    -19
      src/main/java/com/ffii/fpsms/modules/master/service/EquipmentQrCodeService.kt
  95. +14
    -0
      src/main/java/com/ffii/fpsms/modules/master/service/ItemUomService.kt
  96. +15
    -1
      src/main/java/com/ffii/fpsms/modules/master/service/ItemsService.kt
  97. +888
    -0
      src/main/java/com/ffii/fpsms/modules/master/service/MasterDataIssueService.kt
  98. +15
    -2
      src/main/java/com/ffii/fpsms/modules/master/service/ProductionScheduleService.kt
  99. +22
    -0
      src/main/java/com/ffii/fpsms/modules/master/service/ShopService.kt
  100. +48
    -19
      src/main/java/com/ffii/fpsms/modules/master/service/WarehouseQrCodeService.kt

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

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

# Prevent duplicated API calls (frontend)

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

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

Example pattern:

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

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


+ 5
- 1
.gitignore Vedi File

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

### VS Code ###
.vscode/

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


+ 387
- 173
python/Bag3.py Vedi File

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

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


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


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


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


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


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

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

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

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

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


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

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


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


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

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

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

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

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

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

root.after(0, _done)

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


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

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

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

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

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

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

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

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

root.after(0, _done)

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


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


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

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

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


+ 32
- 2
python/installAndExe.txt Vedi File

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

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

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

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

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

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

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

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

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

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

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

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

+ 18
- 0
src/main/java/com/ffii/fpsms/config/security/SecurityConfig.java Vedi File

@@ -91,6 +91,24 @@ 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")
.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 Vedi File

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

/**
* Line payload for `udfproduct.values[]`. **`udfBaseUnit`** is the FPSMS UOM **code** for the line.
* **`udfPackingUnit`** / **`udfPackingQty`** / **`udfproremark`** are not sent.
*/
@JsonInclude(JsonInclude.Include.NON_NULL)
data class M18UdfProductSaveValue(
/** Line id in M18 when updating */
val id: Long? = null,
val udfqty: Number? = null,
val udfProduct: Long? = null,
val udfIngredients: String? = null,
/** Line UOM: [com.ffii.fpsms.modules.master.entity.UomConversion.code] (same unit as [udfqty]). */
val udfBaseUnit: String? = null,
/** M18 vendor id ([StSearchType.VENDOR]) for the BOM business entity: PP → [M18Config.BEID_PP], PF → [M18Config.BEID_PF]. */
val udfSupplier: Long? = null,
/**
* M18 UOM id for price/purchase unit on the **M18-linked** PO line (`m18DataLog` present):
* [com.ffii.fpsms.modules.purchaseOrder.entity.PurchaseOrderLine.uomM18] (M18 `unitId`) then
* [com.ffii.fpsms.modules.purchaseOrder.entity.PurchaseOrderLine.uom] → [com.ffii.fpsms.modules.master.entity.UomConversion.m18Id].
*/
@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 Vedi File

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

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

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

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

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

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

@@ -0,0 +1,648 @@
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 latestPoLine = itemId?.let { id ->
pickPreferredPoLine(
purchaseOrderLineRepository.findLatestLinesForBomM18ByItemId(id, PageRequest.of(0, 10)),
targetBeId,
)
}
val supplierM18Id = resolveSupplierM18Id(latestPoLine, flowTypeId, supplierCache)
/**
* M18 line price unit id ([M18PurchaseOrderPot.unitId]): prefer [PurchaseOrderLine.uomM18] from M18 PO sync,
* else [PurchaseOrderLine.uom] when uomM18 is missing.
*/
val purchaseUnitM18Id =
latestPoLine?.uomM18?.m18Id?.takeIf { it > 0L }
?: latestPoLine?.uom?.m18Id?.takeIf { it > 0L }
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 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 Vedi File

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

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

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

private val fetchListApi = "/search/search"

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

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

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

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

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

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

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

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

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

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

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

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

val deliveryOrderDetail = getDeliveryOrder(deliveryOrder.id)

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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


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

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

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

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

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

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

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

if (itemId == null) {
failDetailList.add(line.id)
logger.error(
"${poLineRefType}: Cannot resolve local item for M18 proId=${line.proId}, skipping line ${line.id}"
)
val errorSaveM18PurchaseOrderLineLogRequest = SaveM18DataLogRequest(
id = saveM18PurchaseOrderLineLog.id,
dataLog = mutableMapOf(
"Exception Message" to "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 Vedi File

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

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

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

private val fetchListApi = "/search/search"

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

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

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

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

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

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

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

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

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


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

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


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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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



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

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

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

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

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

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

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

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

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

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


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

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

import com.ffii.core.utils.JwtTokenUtil
import com.ffii.fpsms.m18.service.M18DeliveryOrderService
import com.ffii.fpsms.m18.service.M18GrnCodeSyncService
import com.ffii.fpsms.m18.service.M18MasterDataService
@@ -10,6 +9,8 @@ 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.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 +26,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 +44,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 +67,22 @@ open class SchedulerService(
val searchCompletedDnService: SearchCompletedDnService,
val m18GrnCodeSyncService: M18GrnCodeSyncService,
val inventoryLotLineService: InventoryLotLineService,
val jobOrderPlanStartAutoService: JobOrderPlanStartAutoService,
private val bomM18ShopBulkPushService: BomM18ShopBulkPushService,
) {
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 +95,8 @@ open class SchedulerService(
var scheduledM18Do1Sat: ScheduledFuture<*>? = null
var scheduledM18Do2: ScheduledFuture<*>? = null

var scheduledM18BomShop: ScheduledFuture<*>? = null

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

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

var scheduledJobOrderPlanStart: ScheduledFuture<*>? = null

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

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

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

/**
* 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 +390,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 +482,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 +642,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 +705,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 +715,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 +743,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,


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

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

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

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

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

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

@GetMapping("/trigger/master-data")
fun triggerMasterData(): String {
schedulerService.getM18MasterData()
@@ -88,4 +111,9 @@ 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"
}
}

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

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

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

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

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

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

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

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


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

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

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

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


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

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

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

@Query("""


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

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

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

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


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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

@@ -0,0 +1,84 @@
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?

@Query(
"""
SELECT r FROM DoReplenishment r
LEFT JOIN DeliveryOrder targetDo ON targetDo.id = r.targetDoId AND targetDo.deleted = false
WHERE r.deleted = false
AND (
:deliveryDate IS NULL
OR (
targetDo.id IS NOT NULL
AND DATE(targetDo.estimatedArrivalDate) = :deliveryDate
)
OR (
targetDo.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 Vedi File

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

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

+ 766
- 277
src/main/java/com/ffii/fpsms/modules/deliveryOrder/service/DeliveryOrderService.kt
File diff soppresso perché troppo grande
Vedi File


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

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

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

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

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

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

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


+ 7
- 20
src/main/java/com/ffii/fpsms/modules/deliveryOrder/service/DoReleaseCoordinatorService.kt Vedi File

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


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

@@ -0,0 +1,556 @@
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.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.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 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)
}

/**
* 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 targetDoIds = entities.mapNotNull { it.targetDoId }.distinct()
val targetDoEtaById = if (targetDoIds.isEmpty()) {
emptyMap()
} else {
deliveryOrderRepository.findAllById(targetDoIds).associate { deliveryOrder ->
deliveryOrder.id!! to deliveryOrder.estimatedArrivalDate?.toLocalDate()
}
}

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

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!!],
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.targetDoId?.let { targetDoEtaById[it] },
pickOrderLineId = row.pickOrderLineId,
deliveryOrderPickOrderId = row.deliveryOrderPickOrderId,
status = row.status,
reason = row.reason,
created = row.created,
)
}
}
}

+ 37
- 4
src/main/java/com/ffii/fpsms/modules/deliveryOrder/service/DoWorkbenchDopoAssignmentService.kt Vedi File

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


+ 491
- 205
src/main/java/com/ffii/fpsms/modules/deliveryOrder/service/DoWorkbenchMainService.kt
File diff soppresso perché troppo grande
Vedi File


+ 1083
- 98
src/main/java/com/ffii/fpsms/modules/deliveryOrder/service/DoWorkbenchReleaseService.kt
File diff soppresso perché troppo grande
Vedi File


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

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

import java.util.Locale

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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


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

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

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

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

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

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

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

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

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

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

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

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

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


+ 30
- 2
src/main/java/com/ffii/fpsms/modules/deliveryOrder/web/models/DoDetailResponse.kt Vedi File

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

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

/** 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,前端換行顯示
)


+ 60
- 0
src/main/java/com/ffii/fpsms/modules/deliveryOrder/web/models/DoReplenishmentModels.kt Vedi File

@@ -0,0 +1,60 @@
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 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 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 Vedi File

@@ -3,4 +3,5 @@ package com.ffii.fpsms.modules.deliveryOrder.web.models
data class ExportDNLabelsRequest (
val doPickOrderId: Long,
val numOfCarton: Int,
val blankCartonNumber: Boolean? = false,
)

+ 2
- 1
src/main/java/com/ffii/fpsms/modules/deliveryOrder/web/models/PrintDNLabelsRequest.kt Vedi File

@@ -4,5 +4,6 @@ data class PrintDNLabelsRequest (
val doPickOrderId: Long,
val printerId: Long,
val printQty: Int?,
val numOfCarton: Int
val numOfCarton: Int,
val blankCartonNumber: Boolean? = false,
)

+ 7
- 2
src/main/java/com/ffii/fpsms/modules/deliveryOrder/web/models/ReleaseDoRequest.kt Vedi File

@@ -21,7 +21,8 @@ data class ReleaseDoResult(

val truckDepartureTime: LocalTime?,
val truckLanceCode: String?,
val loadingSequence: Int?
val loadingSequence: Int?,
val isExtra: Boolean = false,
)
data class SearchDeliveryOrderInfoRequest(
val code: String?,
@@ -30,5 +31,9 @@ data class SearchDeliveryOrderInfoRequest(
val estimatedArrivalDate: LocalDateTime?,
val pageSize: Int?,
val pageNum: Int?,
val truckLanceCode: String?
val truckLanceCode: String?,
/** `ALL`/`All`/null:P06B+P07+P06D+P06Y;`2F`:P07+P06D+P06Y ;`4F`:P06B。車線-X 亦依供應商歸屬出現在對應樓層。 */
val floor: String? = null,
/** null:不篩 isExtra;true/false:只顯示加單或非加單 DO */
val isExtra: Boolean? = null,
)

+ 1
- 0
src/main/java/com/ffii/fpsms/modules/deliveryOrder/web/models/SaveDeliveryOrderRequest.kt Vedi File

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

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

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

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

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

@@ -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
- 7
src/main/java/com/ffii/fpsms/modules/jobOrder/scheduler/LaserBag2AutoSendScheduler.kt Vedi File

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

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


+ 42
- 15
src/main/java/com/ffii/fpsms/modules/jobOrder/service/JoWorkbenchMainService.kt Vedi File

@@ -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
@@ -53,6 +54,30 @@ open class JoWorkbenchMainService(
private val workbenchDefaultExcludeWarehouseCodes: Set<String> = setOf(
//"2F-W202-01-00",
//"2F-W200-#A-00",
"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"
)

private fun debugPrintSuggestionNullReasons(pickOrderId: Long) {
@@ -194,7 +219,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 +324,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 +335,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 +369,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 +456,7 @@ open class JoWorkbenchMainService(
)
}

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

JobOrderLotsHierarchicalResponse(
JobOrderLotsHierarchicalWorkbenchResponse(
pickOrder = pickOrderInfo,
pickOrderLines = pickOrderLinesResult
)
@@ -456,10 +483,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 +494,7 @@ open class JoWorkbenchMainService(
type = null,
status = null,
assignTo = null,
jobOrder = JobOrderBasicInfoResponse(0, "", "")
jobOrder = JobOrderBasicInfoWorkbenchResponse(0, "", "",null,null)
),
pickOrderLines = emptyList()
)


+ 243
- 0
src/main/java/com/ffii/fpsms/modules/jobOrder/service/JobOrderPlanStartAutoService.kt Vedi File

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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


+ 22
- 0
src/main/java/com/ffii/fpsms/modules/master/entity/ItemUomRespository.kt Vedi File

@@ -9,6 +9,28 @@ import java.io.Serializable
interface ItemUomRespository : AbstractRepository<ItemUom, Long> {
fun findAllByItemIdAndDeletedIsFalse(itemId: Serializable): List<ItemUom>

/** All active item_uom rows with uom_conversion (single round-trip for master-data scans). */
@Query(
"""
SELECT iu FROM ItemUom iu
JOIN FETCH iu.uom
JOIN FETCH iu.item i
WHERE iu.deleted = false AND i.deleted = false
""",
)
fun findAllActiveWithUom(): List<ItemUom>

/** Soft-deleted item_uom rows (for master-data issue snapshots). */
@Query(
"""
SELECT iu FROM ItemUom iu
JOIN FETCH iu.uom
JOIN FETCH iu.item i
WHERE iu.deleted = true AND i.deleted = false
""",
)
fun findAllDeletedWithUom(): List<ItemUom>

fun findByIdAndDeletedIsFalse(id: Serializable): ItemUom?

fun findByM18IdAndDeletedIsFalse(m18Id: Serializable): ItemUom?


+ 4
- 3
src/main/java/com/ffii/fpsms/modules/master/entity/ShopAndTruck.kt Vedi File

@@ -15,7 +15,7 @@ import java.time.LocalTime

@Entity
@Table(name = "shop")
@SecondaryTable(name="Truck", pkJoinColumns = [PrimaryKeyJoinColumn(name = "shopId", referencedColumnName = "id")])
@SecondaryTable(name = "truck", pkJoinColumns = [PrimaryKeyJoinColumn(name = "shopId", referencedColumnName = "id")])
open class ShopAndTruck : BaseEntity<Long>() {

// --- Shop fields ---
@@ -49,8 +49,9 @@ open class ShopAndTruck : BaseEntity<Long>() {
@Column(table = "truck", name = "LoadingSequence")
open var loadingSequence: Long? = null

@Column(table = "truck", name = "districtReference")
open var districtReference: Long? = null
@Size(max = 255)
@Column(table = "truck", name = "districtReference", length = 255)
open var districtReference: String? = null

@Column(table = "truck", name = "Store_id")
open var storeId: String? = null


+ 12
- 0
src/main/java/com/ffii/fpsms/modules/master/entity/ShopRepository.kt Vedi File

@@ -6,6 +6,7 @@ import com.ffii.fpsms.modules.master.entity.projections.ShopCombo
import com.ffii.fpsms.modules.master.enums.ShopType
import com.ffii.fpsms.modules.pickOrder.entity.Truck
import org.springframework.data.jpa.repository.Query
import org.springframework.data.repository.query.Param
import org.springframework.stereotype.Repository

@Repository
@@ -30,6 +31,17 @@ interface ShopRepository : AbstractRepository<Shop, Long> {

fun findByCode(code: String): Shop?

fun findAllByCodeAndTypeAndDeletedIsFalseOrderByIdDesc(code: String, type: ShopType): List<Shop>

@Query(
"""
SELECT s FROM Shop s
WHERE s.deleted = false
AND s.code IN :codes
"""
)
fun findAllByCodeInAndDeletedIsFalse(@Param("codes") codes: Collection<String>): List<Shop>

@Query(
nativeQuery = true,
value = """


+ 20
- 0
src/main/java/com/ffii/fpsms/modules/master/entity/WarehouseRepository.kt Vedi File

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

import com.ffii.core.support.AbstractRepository
import com.ffii.fpsms.modules.master.entity.projections.WarehouseCombo
import org.springframework.data.domain.Page
import org.springframework.data.domain.Pageable
import org.springframework.data.jpa.repository.Query
import org.springframework.stereotype.Repository
import java.io.Serializable
@@ -19,4 +21,22 @@ interface WarehouseRepository : AbstractRepository<Warehouse, Long> {
fun findDistinctStockTakeSectionsByDeletedIsFalse(): List<String>;
fun findAllByIdIn(ids: List<Long>): List<Warehouse>;
fun findAllByCodeAndDeletedIsFalse(code: String): List<Warehouse>

@Query(
"""
SELECT COUNT(w) FROM Warehouse w
WHERE w.deleted = false
AND (w.stockTakeSection IS NULL OR TRIM(w.stockTakeSection) = '')
"""
)
fun countMissingStockTakeSection(): Long

@Query(
"""
SELECT w FROM Warehouse w
WHERE w.deleted = false
AND (w.stockTakeSection IS NULL OR TRIM(w.stockTakeSection) = '')
"""
)
fun findMissingStockTakeSection(pageable: Pageable): Page<Warehouse>
}

+ 3
- 1
src/main/java/com/ffii/fpsms/modules/master/entity/projections/BomCombo.kt Vedi File

@@ -7,10 +7,12 @@ interface BomCombo {
val id: Long;
@get:Value("#{target.id}")
val value: Long;
@get:Value("#{target.code} - #{target.name} - #{target.item.itemUoms.^[salesUnit == true && deleted == false]?.uom.udfudesc}")
@get:Value("#{target.code ?: ''} - #{target.name ?: ''} - #{target.item?.itemUoms?.^[salesUnit == true && deleted == false]?.uom?.udfudesc ?: ''}")
val label: String;
val outputQty: BigDecimal;
val outputQtyUom: String?;
@get:Value("#{target.description}")
val description: String?;
@get:Value("#{target.status?.value}")
val status: String?;
}

+ 1
- 1
src/main/java/com/ffii/fpsms/modules/master/entity/projections/ShopAndTruck.kt Vedi File

@@ -16,7 +16,7 @@ interface ShopAndTruck {
val truckLanceCode: String?
val departureTime: LocalTime?
val LoadingSequence: Long?
val districtReference: Long?
val districtReference: String?
val Store_id: String?
val remark: String?
val truckId: Long?


+ 12
- 0
src/main/java/com/ffii/fpsms/modules/master/enums/BomStatus.kt Vedi File

@@ -0,0 +1,12 @@
package com.ffii.fpsms.modules.master.enums

enum class BomStatus(val value: String) {
ACTIVE("active"),
INACTIVE("inactive");

companion object {
fun fromValue(value: String): BomStatus =
entries.find { it.value == value }
?: throw IllegalArgumentException("Unknown BOM status: $value")
}
}

+ 12
- 0
src/main/java/com/ffii/fpsms/modules/master/enums/BomStatusConverter.kt Vedi File

@@ -0,0 +1,12 @@
package com.ffii.fpsms.modules.master.enums

import jakarta.persistence.AttributeConverter
import jakarta.persistence.Converter

@Converter(autoApply = true)
class BomStatusConverter : AttributeConverter<BomStatus, String> {
override fun convertToDatabaseColumn(attribute: BomStatus?): String? = attribute?.value

override fun convertToEntityAttribute(dbData: String?): BomStatus? =
dbData?.let { BomStatus.fromValue(it) }
}

+ 53
- 0
src/main/java/com/ffii/fpsms/modules/master/service/BomM18ShopBulkPushService.kt Vedi File

@@ -0,0 +1,53 @@
package com.ffii.fpsms.modules.master.service

import com.ffii.fpsms.m18.model.M18BomShopBatchSyncSummary
import com.ffii.fpsms.modules.common.SettingNames
import com.ffii.fpsms.modules.master.entity.BomRepository
import com.ffii.fpsms.modules.settings.entity.Settings
import com.ffii.fpsms.modules.settings.service.SettingsService
import org.springframework.stereotype.Service

/**
* Calls [BomService.pushBomToM18ShopIfAllowed] for each BOM id via the injected proxied bean
* so `@Transactional` applies per BOM (avoids same-class self-invocation).
*
* BOM shop toggle is read via [settingsService] here (not [BomService]) so callers never hit Kotlin
* `internal` accessors on [BomService] CGLIB proxies, which could leave delegated dependencies null.
*/
@Service
open class BomM18ShopBulkPushService(
private val bomRepository: BomRepository,
private val bomService: BomService,
private val settingsService: SettingsService,
) {

/** Pushes all non-deleted BOMs to M18 when {@link SettingNames#M18_BOM_SHOP_SYNC_ENABLED} is true. */
open fun pushAllBomsToM18ShopIfAllowed(): M18BomShopBatchSyncSummary {
if (!isM18BomShopSyncEnabledSetting()) {
return M18BomShopBatchSyncSummary(
totalProcessed = 0,
synced = 0,
notSynced = 0,
skippedBecauseFeatureDisabled = true,
)
}
val ids = bomRepository.findAllIdsByDeletedIsFalse()
var synced = 0
var notSynced = 0
for (id in ids) {
val result = bomService.pushBomToM18ShopIfAllowed(id)
if (result.synced) synced++ else notSynced++
}
return M18BomShopBatchSyncSummary(
totalProcessed = ids.size,
synced = synced,
notSynced = notSynced,
skippedBecauseFeatureDisabled = false,
)
}

private fun isM18BomShopSyncEnabledSetting(): Boolean =
settingsService.findByName(SettingNames.M18_BOM_SHOP_SYNC_ENABLED)
.map { Settings.VALUE_BOOLEAN_TRUE == it.value }
.orElse(false)
}

+ 421
- 284
src/main/java/com/ffii/fpsms/modules/master/service/BomService.kt Vedi File

@@ -34,6 +34,17 @@ import java.util.Comparator
import com.ffii.fpsms.modules.jobOrder.entity.JobOrderRepository
import com.ffii.fpsms.modules.productProcess.entity.ProductProcessRepository
import org.springframework.transaction.annotation.Transactional
import com.fasterxml.jackson.databind.ObjectMapper
import com.ffii.fpsms.m18.service.M18BomForShopService
import com.ffii.fpsms.m18.model.M18BomShopSyncTriggerResult
import com.ffii.fpsms.m18.model.GoodsReceiptNoteResponse
import com.ffii.fpsms.m18.entity.M18BomShopSyncLog
import com.ffii.fpsms.m18.entity.M18BomShopSyncLogRepository
import com.ffii.fpsms.modules.common.SettingNames
import com.ffii.fpsms.modules.settings.entity.Settings
import com.ffii.fpsms.modules.settings.service.SettingsService
import com.ffii.fpsms.modules.master.enums.BomStatus
import com.ffii.core.exception.BadRequestException

@Service
open class BomService(
@@ -50,10 +61,19 @@ open class BomService(
private val equipmentDetailRepository: EquipmentDetailRepository,
private val bomWeightingScoreRepository: BomWeightingScoreRepository,
private val itemUomService: ItemUomService,
private val masterDataIssueService: MasterDataIssueService,
private val jobOrderRepository: JobOrderRepository,
private val productProcessRepository: ProductProcessRepository,
private val m18BomForShopService: M18BomForShopService,
private val m18BomShopSyncLogRepository: M18BomShopSyncLogRepository,
private val objectMapper: ObjectMapper,
private val settingsService: SettingsService,
@Value("\${bom.import.temp-dir:\${java.io.tmpdir}/fpsms-bom-import}") private val bomImportTempDir: String,
) {
companion object {
private const val BOM_WIP_DESCRIPTION = "WIP"
}

open fun uploadBomFiles(files: List<MultipartFile>): BomUploadResponse {
val batchId = UUID.randomUUID().toString()
val batchDir = Paths.get(bomImportTempDir, batchId).toAbsolutePath()
@@ -105,6 +125,11 @@ open class BomService(
return bomRepository.findAll()
}

/** @deprecated Use [MasterDataIssueService.findBomMasterDataIssues]; kept for /bom/combo/issues. */
@Transactional(readOnly = true)
open fun findComboIssues(): List<MasterDataIssueResponse> =
masterDataIssueService.findBomMasterDataIssues()

open fun findById(id: Long): Bom? {
return bomRepository.findByIdAndDeletedIsFalse(id)
}
@@ -118,6 +143,34 @@ open class BomService(
.minByOrNull { if (it.description == "FG") 0 else 1 }
?: bomRepository.findAllByItemIdAndDeletedIsFalse(itemId).firstOrNull()
}
open fun findByItemIdAndStatus(itemId: Long, status: BomStatus): Bom? {
return bomRepository.findAllByItemIdAndStatusAndDeletedIsFalse(itemId, status)
.minByOrNull { if (it.description == "FG") 0 else 1 }
?: bomRepository.findAllByItemIdAndStatusAndDeletedIsFalse(itemId, status).firstOrNull()
}

/** Resolve BOM header for a finished-good item code ([Items.code] on [Bom.item]). */
open fun findBomSummaryByItemCode(itemCodeTrimmed: String): BomIdByItemCodeResponse {
val code = itemCodeTrimmed.trim()
val item = itemsRepository.findByCodeAndDeletedFalse(code)
?: return BomIdByItemCodeResponse(
itemCode = code,
message = "Item not found for code",
)
val bom = findByItemId(item.id!!)
?: return BomIdByItemCodeResponse(
itemCode = code,
itemId = item.id,
message = "No BOM linked to this item",
)
return BomIdByItemCodeResponse(
itemCode = code,
itemId = item.id,
bomId = bom.id,
bomCode = bom.code,
bomM18Id = bom.m18Id,
)
}

open fun saveBom(request: SaveBomRequest): SaveBomResponse {

@@ -187,6 +240,20 @@ open class BomService(
request.timeSequence?.let { bom.timeSequence = it }
request.complexity?.let { bom.complexity = it }
request.isDrink?.let { bom.isDrink = it }
if (request.isDrink != null || request.isPowderMixture != null) {
bom.type = when {
bom.isDrink == true -> "Drink"
request.isPowderMixture == true -> "Powder_Mixture"
else -> "Other"
}
}
request.status?.let { raw ->
bom.status = try {
BomStatus.fromValue(raw.trim().lowercase())
} catch (_: IllegalArgumentException) {
throw BadRequestException("Invalid BOM status: $raw")
}
}

val replaceMaterials = request.materials != null
val replaceProcesses = request.processes != null
@@ -371,6 +438,122 @@ open class BomService(
return getBomDetail(bom.id!!)
}

/**
* When {@link SettingNames#M18_BOM_SHOP_SYNC_ENABLED} is true, push BOM to M18 udfBomForShop.
* Use {@code POST /m18/test/bom-shop-sync/{bomId}} (or bulk job /scheduler/trigger/bom-shop-sync-all) to trigger explicitly.
* Optional [m18HeaderId]: M18 udfBomForShop **header** record id (e.g. [BomIdByItemCodeResponse.bomM18Id])
* to force **update** when [Bom.m18Id] is missing or stale. When null, uses [Bom.m18Id] if set.
*/
@Transactional
open fun pushBomToM18ShopIfAllowed(bomId: Long, m18HeaderId: Long? = null): M18BomShopSyncTriggerResult {
if (!isM18BomShopSyncEnabled()) {
return M18BomShopSyncTriggerResult(
bomId = bomId,
synced = false,
skippedReason = "M18 BOM shop sync disabled (${SettingNames.M18_BOM_SHOP_SYNC_ENABLED} is not true)",
)
}
val bom = bomRepository.findByIdAndDeletedIsFalse(bomId)
?: return M18BomShopSyncTriggerResult(bomId, false, skippedReason = "BOM not found")
val req = m18BomForShopService.buildSaveRequest(bom, m18HeaderId)
?: return M18BomShopSyncTriggerResult(bomId, false, skippedReason = "Cannot build M18 payload (missing m18 UOM/item ids or no materials)")

val saveAttempt = m18BomForShopService.saveBomForShopWithVersionRetry(req, bomId)
val reqFinal = saveAttempt.request
val requestJsonPayload = m18BomForShopService.toJson(reqFinal)
val resp = saveAttempt.response
val callError = saveAttempt.callError
val skippedUnchanged = saveAttempt.skippedUnchanged

val responseJsonPayload = when {
resp != null -> m18BomForShopService.toJson(resp)
callError != null ->
runCatching {
objectMapper.writeValueAsString(
mapOf(
"exceptionType" to callError.javaClass.name,
"message" to (callError.message ?: ""),
),
)
}.getOrElse { """{"error":"failed to serialize exception"}""" }
else -> """{"error":"M18 API returned null"}"""
}

val msgSummary = resp?.messages?.joinToString("; ") { it.msgDetail ?: it.msgCode ?: "" }.orEmpty()
val apiStatus = resp?.status == true
val recordId = when {
resp?.recordId != null && resp.recordId > 0L -> resp.recordId
skippedUnchanged -> reqFinal.udfbomforshop.values.firstOrNull()?.id?.toLongOrNull() ?: 0L
else -> 0L
}

val result = when {
callError != null ->
M18BomShopSyncTriggerResult(
bomId = bomId,
synced = false,
skippedReason = callError.message ?: "M18 API call failed",
status = false,
messageSummary = callError.message,
)
resp == null ->
M18BomShopSyncTriggerResult(bomId, false, skippedReason = "M18 API returned null")
skippedUnchanged || (resp.status == true && recordId > 0L) -> {
bom.m18Id = recordId
bomRepository.saveAndFlush(bom)
M18BomShopSyncTriggerResult(
bomId = bomId,
synced = true,
recordId = recordId,
status = true,
messageSummary = when {
skippedUnchanged -> "unchanged BOM details (skipped duplicate M18 save)"
else -> msgSummary.ifBlank { null }
},
)
}
else ->
M18BomShopSyncTriggerResult(
bomId = bomId,
synced = false,
skippedReason = "M18 save failed or status=false",
recordId = resp.recordId.takeIf { it > 0 },
status = resp.status,
messageSummary = msgSummary.ifBlank { null },
)
}

val logMessage = listOfNotNull(
msgSummary.ifBlank { null },
if (skippedUnchanged) "skippedUnchanged=true" else null,
if (saveAttempt.versionBumps > 0) "versionBumps=${saveAttempt.versionBumps}" else null,
callError?.message,
result.skippedReason?.takeIf { !result.synced },
).joinToString("; ").take(4000)

m18BomShopSyncLogRepository.save(
M18BomShopSyncLog().apply {
this.bomId = bomId
finishedItemCode = reqFinal.udfbomforshop.values.firstOrNull()?.udfBomCode
m18HeaderCode = reqFinal.udfbomforshop.values.firstOrNull()?.code
requestFingerprint = m18BomForShopService.contentFingerprint(reqFinal)
m18RecordId = recordId.takeIf { it > 0 }
m18ApiStatus = apiStatus
synced = result.synced
message = logMessage.ifBlank { null }
requestJson = requestJsonPayload
responseJson = responseJsonPayload
},
)

return result
}

private fun isM18BomShopSyncEnabled(): Boolean =
settingsService.findByName(SettingNames.M18_BOM_SHOP_SYNC_ENABLED)
.map { Settings.VALUE_BOOLEAN_TRUE == it.value }
.orElse(false)

private fun resolveEquipmentForBomProcess(pReq: EditBomProcessRequest): Equipment {
val equipmentId = pReq.equipmentId
val equipmentCode = pReq.equipmentCode?.trim().orEmpty()
@@ -422,7 +605,8 @@ open class BomService(
private fun saveBomEntity(req: ImportBomRequest): Bom {
val item = itemsRepository.findByCodeAndDeletedFalse(req.code) ?: itemsRepository.findByNameAndDeletedFalse(req.name)
val uom = if (req.uomId != null) uomConversionRepository.findById(req.uomId!!).orElseThrow() else null
val bom = bomRepository.findByCodeAndDeletedIsFalse(req.code) ?: Bom()
val fgDescription = req.description.trim().ifEmpty { "FG" }
val bom = bomRepository.findByCodeAndDescriptionIgnoreCaseAndDeletedIsFalse(req.code, fgDescription) ?: Bom()
bom.apply {
this.isDark = req.isDark
this.isFloat = req.isFloat
@@ -829,113 +1013,97 @@ open class BomService(
return bomProcessMaterialRepository.saveAndFlush(bomProcessMaterial)
}
private fun importExcelBomMaterial(bom: Bom, sheet: Sheet) {
var bomProcessMatRequest = ImportBomProcessMaterialRequest()
var startRowIndex = 10
val endRowIndex = 30
var startColumnIndex = 0
val endColumnIndex = 10
while (startRowIndex < endRowIndex) {
val tempRow = sheet.getRow(startRowIndex)
val tempCell = tempRow.getCell(startColumnIndex)
if (tempCell != null && tempCell.cellType == CellType.STRING && tempCell.stringCellValue.trim() == "材料編號") {
//println("last: $startRowIndex")
startRowIndex++
val startCol = 0
val endCol = 10
val maxRowIndex = 200
// 1) 找到「材料編號」表头
var headerRowIndex = -1
var searchRowIndex = 10
while (searchRowIndex < maxRowIndex) {
val row = sheet.getRow(searchRowIndex)
val cell = row?.getCell(startCol)
if (cell != null &&
cell.cellType == CellType.STRING &&
cell.stringCellValue.trim() == "材料編號"
) {
headerRowIndex = searchRowIndex
break
}
startRowIndex++
searchRowIndex++
}
var bomMatRequest = ImportBomMatRequest(
bom = bom
)
// println("starting new loop")
while (startRowIndex != endRowIndex || startColumnIndex != endColumnIndex) {
val tempRow = sheet.getRow(startRowIndex)
val tempCell = tempRow.getCell(startColumnIndex)
if (startColumnIndex == 0 && (tempCell == null || tempCell.cellType == CellType.BLANK)) {
if (headerRowIndex == -1) {
println("importExcelBomMaterial: 找不到『材料編號』表頭,略過材料匯入")
return
}
// 2) 从表头下一行开始读,直到 col0 空白
var rowIdx = headerRowIndex + 1
while (rowIdx < maxRowIndex) {
val row = sheet.getRow(rowIdx) ?: break
val firstCell = row.getCell(0)
if (firstCell == null || firstCell.cellType == CellType.BLANK) {
break
} else {
try {
when (startColumnIndex) {
}
val bomMatRequest = ImportBomMatRequest(bom = bom)
val bomProcessMatRequest = ImportBomProcessMaterialRequest()
try {
for (colIdx in startCol..endCol) {
val cell = row.getCell(colIdx) ?: continue
when (colIdx) {
0 -> {
// println("rowIndex: $startRowIndex")
val nameRow = sheet.getRow(startRowIndex)
val nameCell = nameRow.getCell(1)
println(tempCell.stringCellValue.trim())
val item = itemsRepository.findByCodeAndDeletedFalse(tempCell.stringCellValue.trim())
?: itemsRepository.findByNameAndDeletedFalse(nameCell.stringCellValue.trim())
// println("getting item.....:")
// println(item)
val nameCell = row.getCell(1)
val itemCode = cell.stringCellValue.trim()
val itemName = nameCell?.stringCellValue?.trim()
val item = itemsRepository.findByCodeAndDeletedFalse(itemCode)
?: itemName?.let { itemsRepository.findByNameAndDeletedFalse(it) }
bomMatRequest.item = item
}
2-> {
bomMatRequest.qty = tempCell.numericCellValue.toBigDecimal()
2 -> {
bomMatRequest.qty = cell.numericCellValue.toBigDecimal()
}
3 -> {
val uom = uomConversionRepository.findByCodeAndDeletedFalse(tempCell.stringCellValue.trim())
val uomCode = cell.stringCellValue.trim()
val uom = uomConversionRepository.findByCodeAndDeletedFalse(uomCode)
bomMatRequest.uom = uom
bomMatRequest.uomName = uom?.udfudesc
}
6 -> {
bomMatRequest.saleQty = tempCell.numericCellValue.toBigDecimal()
bomMatRequest.saleQty = cell.numericCellValue.toBigDecimal()
}
7 -> {
val salesUnitCodeStr = tempCell.stringCellValue.trim()
val normalizedCode = if (salesUnitCodeStr.equals("Litter", ignoreCase = true)) "L" else salesUnitCodeStr
val salesUnit = uomConversionRepository.findByCodeAndDeletedFalse(tempCell.stringCellValue.trim())
val salesUnitCodeStr = cell.stringCellValue.trim()
val salesUnit = uomConversionRepository.findByCodeAndDeletedFalse(salesUnitCodeStr)
bomMatRequest.salesUnit = salesUnit
// bomMatRequest.salesUnitCode = salesUnit?.udfudesc
bomMatRequest.salesUnitCode = salesUnitCodeStr
}
/*
2 -> {
val salesUnit = uomConversionRepository.findByCodeAndDeletedFalse(tempCell.stringCellValue.trim())
bomMatRequest.salesUnit = salesUnit
}*/

10 -> {
println("seqNo: ${tempCell.numericCellValue.toInt()}")
println("bomId: ${bom.id!!}")
val seqNo = cell.numericCellValue.toInt()
val bomProcess = bomProcessRepository.findBySeqNoAndBomIdAndDeletedIsFalse(
seqNo = tempCell.numericCellValue.toInt(),
seqNo = seqNo,
bomId = bom.id!!
)!! // if null = bugged
) // if null = bugged
bomProcessMatRequest.bomProcess = bomProcess
}
}

//println("startRowIndex: $startRowIndex")
//println("endRowIndex: $endRowIndex")

// println("first condition: ${startColumnIndex < endColumnIndex}")
// println("second condition: ${startRowIndex < endRowIndex}")
if (startColumnIndex < endColumnIndex) {
startColumnIndex++
} else if (startRowIndex < endRowIndex) {
startRowIndex++
// do save
println("req:")
println(bomMatRequest)
val bomMaterial = saveBomMaterial(bomMatRequest)
bomProcessMatRequest.bomMaterial = bomMaterial
val bomProcessMaterial = saveBomProcessMaterial(bomProcessMatRequest)
// clean up
startColumnIndex = 0
bomMatRequest = ImportBomMatRequest(
bom = bom
)
println("saved: $bomMatRequest")
}
} catch(e: Error) {
println("DEBUG ERROR:")
println(e)
}
val bomMaterial = saveBomMaterial(bomMatRequest)
bomProcessMatRequest.bomMaterial = bomMaterial
saveBomProcessMaterial(bomProcessMatRequest)
} catch (e: Exception) {
println("importExcelBomMaterial row ${rowIdx + 1} error: ${e.message}")
}
rowIdx++
}
}

@@ -1434,6 +1602,45 @@ open class BomService(
return null
}

/** Reads BOM 種類 (FG / WIP) from sheet without saving. */
private fun readBomDescriptionFromSheet(sheet: Sheet): String? {
for (r in 0..9) {
for (c in 0..9) {
val cell = sheet.getRow(r)?.getCell(c) ?: continue
if (cell.cellType != CellType.STRING) continue
if (cell.stringCellValue.trim() != "種類") continue
val valueRow = sheet.getRow(r + 1) ?: return null
val valueCell = valueRow.getCell(c) ?: return null
return when {
valueCell.cellType == CellType.STRING -> valueCell.stringCellValue.trim().takeIf { it.isNotEmpty() }
valueCell.cellType == CellType.FORMULA && valueCell.cachedFormulaResultType == CellType.STRING ->
valueCell.stringCellValue.trim().takeIf { it.isNotEmpty() }
else -> null
}
}
}
return null
}

private fun normalizeFgDescriptionForImport(description: String?): String =
description?.trim()?.takeIf { it.isNotEmpty() } ?: "FG"

/** Soft-delete FG (code + Excel 種類) and WIP (code + WIP) rows before re-import. */
private fun softDeleteExistingBomsForImport(code: String, fgDescription: String) {
val fgDesc = normalizeFgDescriptionForImport(fgDescription)
bomRepository.findByCodeAndDescriptionIgnoreCaseAndDeletedIsFalse(code, fgDesc)?.id?.let {
softDeleteBomAndRelated(it)
}
bomRepository.findByCodeAndDescriptionIgnoreCaseAndDeletedIsFalse(code, BOM_WIP_DESCRIPTION)?.id?.let {
softDeleteBomAndRelated(it)
}
}

private fun findFgBomIdForImport(code: String, fgDescription: String): Long? {
val fgDesc = normalizeFgDescriptionForImport(fgDescription)
return bomRepository.findByCodeAndDescriptionIgnoreCaseAndDeletedIsFalse(code, fgDesc)?.id
}

private fun softDeleteBomAndRelated(bomId: Long) {
val bom = bomRepository.findById(bomId).orElse(null) ?: return
bom.deleted = true
@@ -1481,8 +1688,9 @@ open class BomService(
.forEach { path ->
val filename = path.fileName.toString()
val isAlsoWip = items.find { it.fileName == filename }?.isAlsoWip == true
val isDrink= items.find { it.fileName == filename }?.isDrink == true
println("importBOM: filename='$filename', isAlsoWip=$isAlsoWip")
val isDrink = items.find { it.fileName == filename }?.isDrink == true
val isPowderMixture = items.find { it.fileName == filename }?.isPowderMixture == true
println("importBOM: filename='$filename', isAlsoWip=$isAlsoWip, isDrink=$isDrink, isPowderMixture=$isPowderMixture")
try {
FileInputStream(path.toFile()).use { input ->
val workbook2: Workbook = XSSFWorkbook(input)
@@ -1490,15 +1698,19 @@ open class BomService(
?: workbook2.getSheet("食物成品")
?: workbook2.getSheetAt(0)
val code = readBomCodeFromSheet(sheet)
val fgDescription = readBomDescriptionFromSheet(sheet)
var oldBomId: Long? = null
code?.let { c ->
bomRepository.findByCodeAndDeletedIsFalse(c)?.id?.let { existingId ->
softDeleteBomAndRelated(existingId)
oldBomId = existingId
}
oldBomId = findFgBomIdForImport(c, fgDescription ?: "FG")
softDeleteExistingBomsForImport(c, fgDescription ?: "FG")
}
val bom = importExcelBomBasicInfo(sheet)
bom.isDrink = isDrink
bom.type = when {
isDrink -> "Drink"
isPowderMixture -> "Powder_Mixture"
else -> "Other"
}
bomRepository.saveAndFlush(bom)
importExcelBomProcess(bom, sheet)
importExcelBomMaterial(bom, sheet)
@@ -1550,16 +1762,22 @@ open class BomService(
allergicSubstances = fgBom.allergicSubstances
uom = fgBom.uom
isDrink = fgBom.isDrink
type = fgBom.type
}
wipBom.baseScore = calculateBaseScore(wipBom)
bomRepository.saveAndFlush(wipBom)
}
/** 方案 A:複製 FG BOM 為一筆相同 code、相同 item、description=WIP 的 BOM,並複製 materials 與 processes。 */
private fun createWipCopyFromFgBom(fgBom: Bom) {
val code = fgBom.code ?: return
val existingWip = bomRepository.findByCodeAndDescriptionIgnoreCaseAndDeletedIsFalse(code, BOM_WIP_DESCRIPTION)
if (existingWip != null) {
softDeleteBomAndRelated(existingWip.id!!)
}
val wipBom = Bom().apply {
code = fgBom.code
this.code = code
name = fgBom.name
description = "WIP"
description = BOM_WIP_DESCRIPTION
item = fgBom.item
outputQty = fgBom.outputQty
outputQtyUom = fgBom.outputQtyUom
@@ -1573,6 +1791,7 @@ open class BomService(
allergicSubstances = fgBom.allergicSubstances
uom = fgBom.uom
isDrink = fgBom.isDrink
type = fgBom.type
}
wipBom.baseScore = calculateBaseScore(wipBom)
bomRepository.saveAndFlush(wipBom)
@@ -2015,128 +2234,115 @@ open class BomService(
}
}

private fun validateMaterialLikeImport(
sheet: Sheet,
fileName: String,
issues: MutableList<BomFormatIssue>
) {
var startRowIndex = 10
val endRowIndex = 30
var startColumnIndex = 0
val endColumnIndex = 10

var headerFound = false
while (startRowIndex < endRowIndex) {
val tempRow = sheet.getRow(startRowIndex)
val tempCell = tempRow?.getCell(startColumnIndex)
if (tempCell != null &&
tempCell.cellType == CellType.STRING &&
tempCell.stringCellValue.trim() == "材料編號"
) {
startRowIndex++
headerFound = true
break
private fun validateMaterialLikeImport(
sheet: Sheet,
fileName: String,
issues: MutableList<BomFormatIssue>
) {
val startCol = 0
val endCol = 10
val maxRowIndex = 200
// 1) 找表头
var headerRowIndex = -1
var searchRowIndex = 10
while (searchRowIndex < maxRowIndex) {
val row = sheet.getRow(searchRowIndex)
val cell = row?.getCell(startCol)
if (cell != null &&
cell.cellType == CellType.STRING &&
cell.stringCellValue.trim() == "材料編號"
) {
headerRowIndex = searchRowIndex
break
}
searchRowIndex++
}
startRowIndex++
}

if (!headerFound) {
issues += BomFormatIssue(fileName, "材料區:找不到『材料編號』表頭")
return
}

var bomMatRowIdx = startRowIndex

while (bomMatRowIdx != endRowIndex || startColumnIndex != endColumnIndex) {
val tempRow = sheet.getRow(bomMatRowIdx)
val tempCell = tempRow?.getCell(startColumnIndex)

if (startColumnIndex == 0 &&
(tempCell == null || tempCell.cellType == CellType.BLANK)
) {
break
if (headerRowIndex == -1) {
issues += BomFormatIssue(fileName, "材料區:找不到『材料編號』表頭")
return
}

val rowNum = bomMatRowIdx + 1
when (startColumnIndex) {
0 -> {
// 材料編號 — 必填,非空字串
if (tempCell == null || !isNonEmptyStringCell(tempCell)) {
issues += BomFormatIssue(fileName, "材料區:第${rowNum}行『材料編號』(欄1)不可為空")
}
}
1 -> {
// 材料 — 必填,非空字串
if (tempCell == null || !isNonEmptyStringCell(tempCell)) {
issues += BomFormatIssue(fileName, "材料區:第${rowNum}行『材料』(欄2)不可為空")
}
}
2 -> {
// 使用份量 — 必填,數值
if (tempCell == null || !isNumericLike(tempCell)) {
issues += BomFormatIssue(fileName, "材料區:第${rowNum}行『使用份量』(欄3)不可為空且須為數值")
}
}
3 -> {
// 使用單位 — 必填,非空字串
if (tempCell == null || !isNonEmptyStringCell(tempCell)) {
issues += BomFormatIssue(fileName, "材料區:第${rowNum}行『使用單位』(欄4)不可為空")
}
}
4 -> {
// 轉用單位份量 — 必填,數值
if (tempCell == null || !isNumericLike(tempCell)) {
issues += BomFormatIssue(fileName, "材料區:第${rowNum}行『轉用單位份量』(欄5)不可為空且須為數值")
}
}
5 -> {
// 轉用單位 — 必填,非空字串
if (tempCell == null || !isNonEmptyStringCell(tempCell)) {
issues += BomFormatIssue(fileName, "材料區:第${rowNum}行『轉用單位』(欄6)不可為空")
}
}
6 -> {
// 份量(銷售單位) — 必填,數值
if (tempCell == null || !isNumericLike(tempCell)) {
issues += BomFormatIssue(fileName, "材料區:第${rowNum}行『份量(銷售單位)』(欄7)不可為空且須為數值")
}
}
7 -> {
// 銷售單位 — 必填,非空字串
if (tempCell == null || !isNonEmptyStringCell(tempCell)) {
issues += BomFormatIssue(fileName, "材料區:第${rowNum}行『銷售單位』(欄8)不可為空")
}
}
8 -> {
// 採購單價 — 必填,數值
if (tempCell == null || !isNumericLike(tempCell)) {
issues += BomFormatIssue(fileName, "材料區:第${rowNum}行『採購單價』(欄9)不可為空且須為數值")
}
}
9 -> {
// 採購單位 — 必填,非空字串
if (tempCell == null || !isNonEmptyStringCell(tempCell)) {
issues += BomFormatIssue(fileName, "材料區:第${rowNum}行『採購單位』(欄10)不可為空")
}
// 2) 从表头下一行开始,读到 col0 空白
var bomMatRowIdx = headerRowIndex + 1
while (bomMatRowIdx < maxRowIndex) {
val row = sheet.getRow(bomMatRowIdx) ?: break
val firstCell = row.getCell(0)
if (firstCell == null || firstCell.cellType == CellType.BLANK) {
break
}
10 -> {
// 加入步驟 — 必填,數值
if (tempCell == null || !isNumericLike(tempCell)) {
issues += BomFormatIssue(fileName, "材料區:第${rowNum}行『加入步驟』(欄11)不可為空且須為數值")
for (startColumnIndex in 0..endCol) {
val tempCell = row.getCell(startColumnIndex)
val rowNum = bomMatRowIdx + 1
when (startColumnIndex) {
0 -> {
if (tempCell == null || !isNonEmptyStringCell(tempCell)) {
issues += BomFormatIssue(fileName, "材料區:第${rowNum}行『材料編號』(欄1)不可為空")
}
}
1 -> {
if (tempCell == null || !isNonEmptyStringCell(tempCell)) {
issues += BomFormatIssue(fileName, "材料區:第${rowNum}行『材料』(欄2)不可為空")
}
}
2 -> {
if (tempCell == null || !isNumericLike(tempCell)) {
issues += BomFormatIssue(fileName, "材料區:第${rowNum}行『使用份量』(欄3)不可為空且須為數值")
}
}
3 -> {
if (tempCell == null || !isNonEmptyStringCell(tempCell)) {
issues += BomFormatIssue(fileName, "材料區:第${rowNum}行『使用單位』(欄4)不可為空")
}
}
4 -> {
if (tempCell == null || !isNumericLike(tempCell)) {
issues += BomFormatIssue(fileName, "材料區:第${rowNum}行『轉用單位份量』(欄5)不可為空且須為數值")
}
}
5 -> {
if (tempCell == null || !isNonEmptyStringCell(tempCell)) {
issues += BomFormatIssue(fileName, "材料區:第${rowNum}行『轉用單位』(欄6)不可為空")
}
}
6 -> {
if (tempCell == null || !isNumericLike(tempCell)) {
issues += BomFormatIssue(fileName, "材料區:第${rowNum}行『份量(銷售單位)』(欄7)不可為空且須為數值")
}
}
7 -> {
if (tempCell == null || !isNonEmptyStringCell(tempCell)) {
issues += BomFormatIssue(fileName, "材料區:第${rowNum}行『銷售單位』(欄8)不可為空")
}
}
8 -> {
if (tempCell == null || !isNumericLike(tempCell)) {
issues += BomFormatIssue(fileName, "材料區:第${rowNum}行『採購單價』(欄9)不可為空且須為數值")
}
}
9 -> {
if (tempCell == null || !isNonEmptyStringCell(tempCell)) {
issues += BomFormatIssue(fileName, "材料區:第${rowNum}行『採購單位』(欄10)不可為空")
}
}
10 -> {
if (tempCell == null || !isNumericLike(tempCell)) {
issues += BomFormatIssue(fileName, "材料區:第${rowNum}行『加入步驟』(欄11)不可為空且須為數值")
}
}
}
}
}
if (startColumnIndex == endColumnIndex) {
// 這一列所有欄位型別都檢查完後,做 DB / 轉換檢查
val codeCell = tempRow?.getCell(0)
val uomCell = tempRow?.getCell(3)
val saleQtyCell = tempRow?.getCell(6)
val salesUnitCell = tempRow?.getCell(7)
// DB / 轉換檢查(保留你原本逻辑)
val codeCell = row.getCell(0)
val uomCell = row.getCell(3)
val saleQtyCell = row.getCell(6)
val salesUnitCell = row.getCell(7)
val rowNum = bomMatRowIdx + 1
// 1) Item 是否存在
val itemCode = codeCell?.stringCellValue?.trim().orEmpty()
if (itemCode.isNotEmpty()) {
val item = itemsRepository.findByCodeAndDeletedFalse(itemCode)
@@ -2147,8 +2353,7 @@ private fun validateMaterialLikeImport(
)
}
}
// 2) 使用單位 UOM 是否存在
val useUomCode = uomCell?.stringCellValue?.trim().orEmpty()
if (useUomCode.isNotEmpty()) {
val useUom = uomConversionRepository.findByCodeAndDeletedFalse(useUomCode)
@@ -2159,83 +2364,13 @@ private fun validateMaterialLikeImport(
)
}
}
// 3) 銷售單位 UOM 是否存在,以及轉換是否可行(對應 saveBomMaterial 的邏輯)
val saleQty = when {
saleQtyCell == null || !isNumericLike(saleQtyCell) -> null
saleQtyCell.cellType == CellType.NUMERIC ->
saleQtyCell.numericCellValue.toBigDecimal()
saleQtyCell.cellType == CellType.FORMULA &&
saleQtyCell.cachedFormulaResultType == CellType.NUMERIC ->
saleQtyCell.numericCellValue.toBigDecimal()
else -> saleQtyCell.stringCellValue.trim().toBigDecimalOrNull()
}
val salesUnitCode = salesUnitCell?.stringCellValue?.trim().orEmpty()
if (itemCode.isNotEmpty() && saleQty != null && salesUnitCode.isNotEmpty()) {
val item = itemsRepository.findByCodeAndDeletedFalse(itemCode)
val salesUnit = uomConversionRepository.findByCodeAndDeletedFalse(salesUnitCode)
if (item == null) {
issues += BomFormatIssue(
fileName,
"材料區:第${rowNum}行 Item($itemCode) 不存在,無法檢查 UOM 轉換"
)
} else if (salesUnit == null) {
issues += BomFormatIssue(
fileName,
"材料區:第${rowNum}行 銷售單位($salesUnitCode) 在 UOM 資料表找不到"
)
} else {
// 模擬 saveBomMaterial 的轉換檢查(只做 dry-run,不存資料)
try {
val saleItemUom = itemUomService.findSalesUnitByItemId(item.id!!)
val itemSaleUnit = saleItemUom?.uom
if (itemSaleUnit != null && salesUnit.id != itemSaleUnit.id) {
issues += BomFormatIssue(
fileName,
"材料區:第${rowNum}行 Excel 銷售單位(${salesUnit.code}) 與品項銷售單位(${itemSaleUnit.code}) 不一致"
)
}
val baseItemUom = itemUomService.findBaseUnitByItemId(item.id!!)
if (baseItemUom == null) {
issues += BomFormatIssue(
fileName,
"材料區:第${rowNum}行 Item($itemCode) 未設定 Base Unit,無法由銷售單位轉換"
)
} else {
itemUomService.convertUomByItem(
ConvertUomByItemRequest(
itemId = item.id!!,
qty = saleQty,
uomId = salesUnit.id!!,
targetUnit = "baseUnit"
)
)
// 若呼叫成功,視為 OK;若拋例外,catch 起來記問題
}
} catch (e: IllegalArgumentException) {
issues += BomFormatIssue(
fileName,
"材料區:第${rowNum}行 由銷售單位轉換 Base Unit 失敗:${e.message ?: "IllegalArgumentException"}"
)
} catch (e: Exception) {
issues += BomFormatIssue(
fileName,
"材料區:第${rowNum}行 由銷售單位轉換 Base Unit 發生錯誤:${e.javaClass.simpleName} - ${e.message ?: "Unknown error"}"
)
}
}
}
}
if (startColumnIndex < endColumnIndex) {
startColumnIndex++
} else if (bomMatRowIdx < endRowIndex) {
// 下面 sales unit / convert 檢查,直接沿用你原本 2136 行後的邏輯即可
// (你可把原区块整段搬进来,row / rowNum 变量名一致即可)
bomMatRowIdx++
startColumnIndex = 0
}
}
}
// ===================== 新增:Basic Info 區塊檢查 =====================
/**
@@ -2298,7 +2433,7 @@ private fun validateMaterialLikeImport(
var ColorDepthValueOk = false
var FloatingValueOk = false
var ConcentrationValueOk = false
println("=== Debug sheet content for $fileName ===")
// println("=== Debug sheet content for $fileName ===")
for (r in 0..20) {
val row = sheet.getRow(r) ?: continue
for (c in 0..20) {
@@ -2317,7 +2452,7 @@ for (r in 0..20) {
else -> cell.cellType.toString()
}
if (value.isNotBlank() && value != "BLANK") {
println("($r, $c) = $value")
// println("($r, $c) = $value")
}
}
}
@@ -2839,6 +2974,7 @@ println("=====================================")
isFloat = bom.isFloat,
isDense = bom.isDense,
isDrink = bom.isDrink,
isPowderMixture = bom.type?.equals("Powder_Mixture", ignoreCase = true) == true,
scrapRate = bom.scrapRate,
allergicSubstances = bom.allergicSubstances,
timeSequence = bom.timeSequence,
@@ -2847,6 +2983,7 @@ println("=====================================")
description = bom.description,
outputQty = bom.outputQty,
outputQtyUom = bom.outputQtyUom,
status = bom.status.value,
materials = materials,
processes = processes
)


+ 52
- 19
src/main/java/com/ffii/fpsms/modules/master/service/EquipmentQrCodeService.kt Vedi File

@@ -4,30 +4,52 @@ import com.ffii.core.utils.PdfUtils
import com.ffii.core.utils.QrCodeUtil
import com.ffii.fpsms.modules.master.entity.EquipmentDetailRepository
import com.ffii.fpsms.modules.master.web.ExportEquipmentQrCodeRequest
import com.ffii.fpsms.modules.master.web.PrintEquipmentQrCodeRequest
import com.ffii.fpsms.modules.master.print.A4PrintDriverRegistry
import net.sf.jasperreports.engine.JasperCompileManager
import net.sf.jasperreports.engine.JasperExportManager
import net.sf.jasperreports.engine.JasperReport
import net.sf.jasperreports.engine.JasperPrint
import org.springframework.core.io.ClassPathResource
import org.springframework.stereotype.Service
import java.io.FileNotFoundException
import java.io.File
import java.awt.GraphicsEnvironment
import kotlinx.serialization.json.Json
import kotlinx.serialization.encodeToString

@Service
class EquipmentQrCodeService(
private val equipmentDetailRepository: EquipmentDetailRepository
private val equipmentDetailRepository: EquipmentDetailRepository,
private val printerService: PrinterService,
) {
private val qrCodeHandleJrxmlPath = "qrCodeHandle/equipment_QrHandle.jrxml"

fun exportEquipmentQrCode(request: ExportEquipmentQrCodeRequest): Map<String, Any> {
val QRCODE_HANDLE_PDF = "qrCodeHandle/equipment_QrHandle.jrxml"
val resource = ClassPathResource(QRCODE_HANDLE_PDF)
/**
* Compile the Jasper template once; compiling per request is expensive.
*/
private val qrCodeHandleReport: JasperReport by lazy {
val resource = ClassPathResource(qrCodeHandleJrxmlPath)
if (!resource.exists()) {
throw FileNotFoundException("Report file not found: $QRCODE_HANDLE_PDF")
throw FileNotFoundException("Report file not found: $qrCodeHandleJrxmlPath")
}
val inputStream = resource.inputStream
val qrCodeHandleReport = JasperCompileManager.compileReport(inputStream)
resource.inputStream.use { JasperCompileManager.compileReport(it) }
}

/**
* Cache the chosen Chinese font family name (font scanning is expensive).
*/
private val chineseFontFamily: String by lazy {
val availableFonts = GraphicsEnvironment.getLocalGraphicsEnvironment().availableFontFamilyNames
availableFonts.find { family ->
family.contains("SimSun", ignoreCase = true) ||
family.contains("Microsoft YaHei", ignoreCase = true) ||
family.contains("STSong", ignoreCase = true) ||
family.contains("SimHei", ignoreCase = true)
} ?: "Arial Unicode MS"
}

fun exportEquipmentQrCode(request: ExportEquipmentQrCodeRequest): Map<String, Any> {
val equipmentDetails = equipmentDetailRepository.findAllById(request.equipmentDetailIds)
if (equipmentDetails.isEmpty()) {
throw IllegalArgumentException("No equipment details found for the provided equipment detail IDs: ${request.equipmentDetailIds}")
@@ -63,18 +85,10 @@ class EquipmentQrCodeService(
}
val params: MutableMap<String, Any> = mutableMapOf()
val availableFonts = GraphicsEnvironment.getLocalGraphicsEnvironment().availableFontFamilyNames
val chineseFont = availableFonts.find {
it.contains("SimSun", ignoreCase = true) ||
it.contains("Microsoft YaHei", ignoreCase = true) ||
it.contains("STSong", ignoreCase = true) ||
it.contains("SimHei", ignoreCase = true)
} ?: "Arial Unicode MS"

params["net.sf.jasperreports.default.pdf.encoding"] = "Identity-H"
params["net.sf.jasperreports.default.pdf.embedded"] = true
params["net.sf.jasperreports.default.pdf.font.name"] = chineseFont
params["net.sf.jasperreports.default.pdf.font.name"] = chineseFontFamily
val firstEquipmentDetail = equipmentDetails.firstOrNull()
@@ -83,4 +97,23 @@ class EquipmentQrCodeService(
"fileName" to (firstEquipmentDetail?.code ?: "equipment_qrcode")
)
}

fun printEquipmentQrCode(request: PrintEquipmentQrCodeRequest) {
val printer = printerService.findById(request.printerId) ?: throw NoSuchElementException("No such printer")
val pdf = exportEquipmentQrCode(ExportEquipmentQrCodeRequest(request.equipmentDetailIds))
val jasperPrint = pdf["report"] as JasperPrint
val tempPdfFile = File.createTempFile("print_job_", ".pdf")

try {
JasperExportManager.exportReportToPdfFile(jasperPrint, tempPdfFile.absolutePath)
val printQty = if (request.printQty == null || request.printQty <= 0) 1 else request.printQty
printer.ip?.let { ip ->
val port = printer.port ?: 9100
val driver = A4PrintDriverRegistry.getDriver(printer.brand)
driver.print(tempPdfFile, ip, port, printQty)
}
} finally {
tempPdfFile.delete()
}
}
}

+ 14
- 0
src/main/java/com/ffii/fpsms/modules/master/service/ItemUomService.kt Vedi File

@@ -282,6 +282,20 @@ open class ItemUomService(
return finalizePreciseStockQty(stockUnit, stockQty)
}

/**
* Convert quantity from [uomId] (must exist on `item_uom` for [itemId]) to the item's **base unit** quantity.
* Returns null when no `item_uom` row links the item to that UOM.
*/
open fun convertQtyToBaseQtyPrecise(itemId: Long, uomId: Long, sourceQty: BigDecimal): BigDecimal? {
val itemUom = findFirstByItemIdAndUomId(itemId, uomId) ?: return null
val one = BigDecimal.ONE
val calcScale = 10
return sourceQty
.multiply(itemUom.ratioN ?: one)
.divide(itemUom.ratioD ?: one, calcScale, RoundingMode.HALF_UP)
.stripTrailingZeros()
}

// See if need to update the response
open fun saveItemUom(request: ItemUomRequest): ItemUom {
val itemUom = request.m18Id?.let { itemUomRespository.findFirstByM18IdOrderByIdDesc(it) }


+ 15
- 1
src/main/java/com/ffii/fpsms/modules/master/service/ItemsService.kt Vedi File

@@ -340,6 +340,7 @@ open class ItemsService(
//println("Query result size: ${result.size}")
// result.forEach { row -> println("Result row: $row") }
return result
} catch (e: Exception) {
println("Error in getPickOrderItemsByPage: ${e.message}")
e.printStackTrace()
@@ -646,8 +647,21 @@ open class ItemsService(
open fun saveItem(request: NewItemRequest): MessageResponse {
val duplicatedItem = itemsRepository.findByCodeAndTypeAndDeletedFalse(request.code, request.type)
if (duplicatedItem != null && duplicatedItem.id != request.id) {
if (request.m18Id != null && request.id == null && duplicatedItem.m18Id == null) {
duplicatedItem.m18Id = request.m18Id
duplicatedItem.m18LastModifyDate = request.m18LastModifyDate
val linked = itemsRepository.saveAndFlush(duplicatedItem)
return MessageResponse(
id = linked.id,
code = linked.code,
name = linked.name,
type = linked.type.toString(),
message = "Linked m18Id to existing item with same code",
errorPosition = null,
)
}
return MessageResponse(
id = request.id,
id = request.id ?: duplicatedItem.id,
code = request.code,
name = request.name,
type = request.type.toString(),


+ 888
- 0
src/main/java/com/ffii/fpsms/modules/master/service/MasterDataIssueService.kt Vedi File

@@ -0,0 +1,888 @@
package com.ffii.fpsms.modules.master.service

import com.ffii.fpsms.modules.master.entity.Bom
import com.ffii.fpsms.modules.master.entity.BomMaterial
import com.ffii.fpsms.modules.master.entity.BomMaterialRepository
import com.ffii.fpsms.modules.master.entity.BomRepository
import com.ffii.fpsms.modules.master.entity.ItemUom
import com.ffii.fpsms.modules.master.entity.Items
import com.ffii.fpsms.modules.master.entity.ItemUomRespository
import com.ffii.fpsms.modules.master.entity.ItemsRepository
import com.ffii.fpsms.modules.master.entity.UomConversion
import com.ffii.fpsms.modules.master.web.models.MasterDataIssueResponse
import com.ffii.fpsms.modules.master.web.models.MasterDataIssueSummaryResponse
import com.ffii.fpsms.modules.master.web.models.MasterDataIssueSummaryTiming
import java.time.LocalDateTime
import org.springframework.stereotype.Service
import org.springframework.transaction.annotation.Transactional

@Service
open class MasterDataIssueService(
private val bomRepository: BomRepository,
private val bomMaterialRepository: BomMaterialRepository,
private val itemsRepository: ItemsRepository,
private val itemUomRespository: ItemUomRespository,
private val uomConversionService: UomConversionService,
) {
data class UnitHealth(
val value: String?,
val correct: Boolean,
val modifiedAt: String?,
/** OK | MISSING | DELETED */
val status: String,
)

data class UnitSnapshot(
val base: UnitHealth,
val stock: UnitHealth,
val purchase: UnitHealth,
val sales: UnitHealth,
)

data class IssueContext(
val scope: String,
val bomId: Long? = null,
val bomCode: String? = null,
val bomName: String? = null,
val bomMaterialId: Long? = null,
val description: String? = null,
)

@Transactional(readOnly = true)
open fun findBomMasterDataIssues(): List<MasterDataIssueResponse> {
val issues = mutableListOf<MasterDataIssueResponse>()
val uomsByItemId = loadActiveUomsByItemId()
val materials = bomMaterialRepository.findAllActiveForActiveBoms()
val materialsByBomId = materials.groupBy { it.bom?.id ?: -1L }
val uomCache = buildUomCache(uomsByItemId, emptyMap(), materials)
val boms = bomRepository.findAllActiveWithItemAndUom()
for (bom in boms) {
collectBomHeaderIssues(bom, issues, uomsByItemId)
val bomId = bom.id ?: continue
for (material in materialsByBomId[bomId].orEmpty()) {
collectBomMaterialIssues(bom, material, issues, uomsByItemId, uomCache)
}
}
return sortIssues(issues)
}

@Transactional(readOnly = true)
open fun findItemMasterDataIssues(): List<MasterDataIssueResponse> {
val issues = mutableListOf<MasterDataIssueResponse>()
val uomsByItemId = loadActiveUomsByItemId()
val deletedUomsByItemId = loadDeletedUomsByItemId()
val items = itemsRepository.findAllByDeletedFalse()
val ctx = IssueContext(scope = "ITEM")
for (item in items) {
collectItemUomIssues(item, ctx, issues, uomsByItemId, deletedUomsByItemId)
}
return sortIssues(issues)
}

@Transactional(readOnly = true)
open fun findMasterDataIssuesSummary(includeTiming: Boolean = false): MasterDataIssueSummaryResponse {
val totalStart = System.nanoTime()
var loadActiveUomsMs = 0L
var loadDeletedUomsMs = 0L
var loadMaterialsMs = 0L
var buildUomCacheMs = 0L
var loadBomsMs = 0L
var scanBomTabMs = 0L
var loadItemsMs = 0L
var scanItemTabMs = 0L

val uomsByItemId =
if (includeTiming) {
timed { loadActiveUomsByItemId() }.also { loadActiveUomsMs = it.second }.first
} else {
loadActiveUomsByItemId()
}
val deletedUomsByItemId =
if (includeTiming) {
timed { loadDeletedUomsByItemId() }.also { loadDeletedUomsMs = it.second }.first
} else {
loadDeletedUomsByItemId()
}
val materials =
if (includeTiming) {
timed { bomMaterialRepository.findAllActiveForActiveBoms() }
.also { loadMaterialsMs = it.second }
.first
} else {
bomMaterialRepository.findAllActiveForActiveBoms()
}
val materialsByBomId = materials.groupBy { it.bom?.id ?: -1L }
val uomCache =
if (includeTiming) {
timed { buildUomCache(uomsByItemId, deletedUomsByItemId, materials) }
.also { buildUomCacheMs = it.second }
.first
} else {
buildUomCache(uomsByItemId, deletedUomsByItemId, materials)
}
val boms =
if (includeTiming) {
timed { bomRepository.findAllActiveWithItemAndUom() }.also { loadBomsMs = it.second }.first
} else {
bomRepository.findAllActiveWithItemAndUom()
}
val bomGroupCount =
if (includeTiming) {
timed {
scanBomTabGroupCount(boms, uomsByItemId, materialsByBomId, uomCache)
}.also { scanBomTabMs = it.second }.first
} else {
scanBomTabGroupCount(boms, uomsByItemId, materialsByBomId, uomCache)
}
val items =
if (includeTiming) {
timed { itemsRepository.findAllByDeletedFalse() }.also { loadItemsMs = it.second }.first
} else {
itemsRepository.findAllByDeletedFalse()
}
val itemGroupCount =
if (includeTiming) {
timed { scanItemTabGroupCount(items, uomsByItemId, deletedUomsByItemId) }
.also { scanItemTabMs = it.second }
.first
} else {
scanItemTabGroupCount(items, uomsByItemId, deletedUomsByItemId)
}

val timing =
if (includeTiming) {
MasterDataIssueSummaryTiming(
totalMs = (System.nanoTime() - totalStart) / 1_000_000,
loadActiveUomsMs = loadActiveUomsMs,
loadDeletedUomsMs = loadDeletedUomsMs,
loadMaterialsMs = loadMaterialsMs,
buildUomCacheMs = buildUomCacheMs,
loadBomsMs = loadBomsMs,
scanBomTabMs = scanBomTabMs,
loadItemsMs = loadItemsMs,
scanItemTabMs = scanItemTabMs,
)
} else {
null
}

return MasterDataIssueSummaryResponse(
bomGroupCount = bomGroupCount,
itemGroupCount = itemGroupCount,
totalGroupCount = bomGroupCount + itemGroupCount,
timing = timing,
)
}

private inline fun <T> timed(block: () -> T): Pair<T, Long> {
val start = System.nanoTime()
val result = block()
return result to (System.nanoTime() - start) / 1_000_000
}

/**
* Fast BOM-tab group count: one bom fetch, one material batch, no issue DTOs.
* Group keys match [groupBomTabIssues] on the frontend.
*/
private fun scanBomTabGroupCount(
boms: List<Bom>,
uomsByItemId: Map<Long, List<ItemUom>>,
materialsByBomId: Map<Long, List<BomMaterial>>,
uomCache: Map<Long, UomConversion>,
): Int {
val headerKeys = mutableSetOf<String>()
val materialKeys = mutableSetOf<String>()
for (bom in boms) {
scanBomHeaderGroupKeys(bom, headerKeys, uomsByItemId)
val bomId = bom.id ?: continue
for (material in materialsByBomId[bomId].orEmpty()) {
scanBomMaterialGroupKeys(bom, material, materialKeys, uomsByItemId, uomCache)
}
}
return headerKeys.size + materialKeys.size
}

/** Fast item-tab group count; skips PICKING-only items (matches UI filter). */
private fun scanItemTabGroupCount(
items: List<Items>,
uomsByItemId: Map<Long, List<ItemUom>>,
deletedUomsByItemId: Map<Long, List<ItemUom>>,
): Int {
val keys = mutableSetOf<String>()
for (item in items) {
if (!itemHasNonPickingTabIssue(item, uomsByItemId, deletedUomsByItemId)) continue
val itemId = item.id
val key =
if (itemId != null) {
"item:$itemId"
} else {
"code:${item.code?.trim().orEmpty().ifBlank { "unknown" }}"
}
keys.add(key)
}
return keys.size
}

private fun scanBomHeaderGroupKeys(
bom: Bom,
headerKeys: MutableSet<String>,
uomsByItemId: Map<Long, List<ItemUom>>,
) {
val bomId = bom.id ?: return
val bomKey = "bom:$bomId"
var hasIssue = false

if (bom.code.isNullOrBlank()) hasIssue = true
if (bom.name.isNullOrBlank()) hasIssue = true

val item = bom.item
val itemId = item?.id
val unitSnapshot = buildUnitSnapshot(itemId, uomsByItemId)
if (item == null || item.deleted == true) {
hasIssue = true
} else {
if (itemHasAnyUnitIssue(item, uomsByItemId, unitSnapshot)) hasIssue = true
val bomUom = bom.uom
val salesConv = findSalesUnitRow(item.id!!, uomsByItemId)?.uom
if (bomUom != null && salesConv != null && bomUom.id != salesConv.id) hasIssue = true
if (bomUom != null) {
val uomDesc = bomUom.udfudesc?.trim().orEmpty()
val outputDesc = bom.outputQtyUom?.trim().orEmpty()
if (outputDesc.isNotEmpty() && uomDesc.isNotEmpty() && !outputDesc.equals(uomDesc, ignoreCase = true)) {
hasIssue = true
}
}
}
if (hasIssue) headerKeys.add(bomKey)
}

private fun scanBomMaterialGroupKeys(
bom: Bom,
material: BomMaterial,
materialKeys: MutableSet<String>,
uomsByItemId: Map<Long, List<ItemUom>>,
uomCache: Map<Long, UomConversion>,
) {
val matItem = material.item
val unitSnapshot = buildUnitSnapshot(matItem?.id, uomsByItemId)
val itemId = matItem?.id
val itemCode = matItem?.code
val bomMaterialId = material.id

fun addMaterialKey(issueCode: String, expected: String? = null, actual: String? = null) {
val itemKey =
if (itemId != null) {
"i:$itemId"
} else {
"ic:${itemCode?.trim().orEmpty().ifBlank { bomMaterialId?.toString() ?: "unknown" }}"
}
val patternKey = materialPatternKey(issueCode, expected, actual)
materialKeys.add("$itemKey|$patternKey")
}

if (matItem == null || matItem.deleted == true) {
addMaterialKey("BOM_MATERIAL_MISSING_ITEM")
return
}

val matUom = material.uom
if (matUom != null && matUom.deleted == true) {
addMaterialKey("BOM_MATERIAL_UOM_FK_INVALID", actual = "uomId=${matUom.id}")
}

val salesUnitFk = material.salesUnit
if (salesUnitFk != null && salesUnitFk.deleted == true) {
addMaterialKey("BOM_MATERIAL_UOM_FK_INVALID", actual = "salesUnitId=${salesUnitFk.id}")
}

val itemSales = findSalesUnitRow(matItem.id!!, uomsByItemId)?.uom
if (salesUnitFk != null && itemSales != null && salesUnitFk.id != itemSales.id) {
addMaterialKey(
"BOM_MATERIAL_SALES_UOM_MISMATCH",
formatUom(itemSales),
formatUom(salesUnitFk),
)
}

val itemBase = findBaseUnitRow(matItem.id!!, uomsByItemId)?.uom
val matBaseId = material.baseUnit?.toLong()
if (matBaseId != null && itemBase != null && matBaseId != itemBase.id) {
addMaterialKey(
"BOM_MATERIAL_BASE_UOM_MISMATCH",
formatUom(itemBase),
formatUomById(matBaseId, uomCache),
)
}

val itemStock = findStockUnitRow(matItem.id!!, uomsByItemId)?.uom
val matStockId = material.stockUnit?.toLong()
if (matStockId != null && itemStock != null && matStockId != itemStock.id) {
addMaterialKey(
"BOM_MATERIAL_STOCK_UOM_MISMATCH",
formatUom(itemStock),
formatUomById(matStockId, uomCache),
)
}
}

private fun materialPatternKey(issueCode: String, expected: String?, actual: String?): String {
val exp = expected ?: ""
val act = actual ?: ""
return when (issueCode) {
"BOM_MATERIAL_SALES_UOM_MISMATCH",
"BOM_MATERIAL_STOCK_UOM_MISMATCH",
-> "uom:$exp|$act"
"BOM_MATERIAL_BASE_UOM_MISMATCH" -> "base:$exp|$act"
else -> "$issueCode|$exp|$act"
}
}

private fun itemHasNonPickingTabIssue(
item: Items,
uomsByItemId: Map<Long, List<ItemUom>>,
deletedUomsByItemId: Map<Long, List<ItemUom>>,
): Boolean {
val itemId = item.id ?: return false
val snapshot = buildUnitSnapshot(itemId, uomsByItemId, deletedUomsByItemId) ?: return true
val allUoms = uomsForItem(itemId, uomsByItemId)
for (unitKey in ITEM_TAB_UNIT_KEYS) {
if (itemUnitWouldIssue(item, allUoms, unitKey, snapshot)) return true
}
return false
}

private fun itemHasAnyUnitIssue(
item: Items,
uomsByItemId: Map<Long, List<ItemUom>>,
unitSnapshot: UnitSnapshot?,
): Boolean {
val itemId = item.id ?: return false
val allUoms = uomsForItem(itemId, uomsByItemId)
for (unitKey in ALL_UNIT_KEYS) {
if (itemUnitWouldIssue(item, allUoms, unitKey, unitSnapshot)) return true
}
return false
}

private fun itemUnitWouldIssue(
item: Items,
allUoms: List<ItemUom>,
unitKey: String,
unitSnapshot: UnitSnapshot?,
): Boolean {
val flag = unitFlag(unitKey)
val rows = allUoms.filter(flag)
return when {
rows.isEmpty() -> true
rows.size > 1 -> true
else -> uomRowInvalid(rows.first())
}
}

private fun uomRowInvalid(row: ItemUom): Boolean {
val uom = row.uom
return uom == null || uom.deleted == true
}

private fun unitFlag(unitKey: String): (ItemUom) -> Boolean =
when (unitKey) {
"BASE" -> { it -> it.baseUnit == true }
"SALES" -> { it -> it.salesUnit == true }
"STOCK" -> { it -> it.stockUnit == true }
"PURCHASE" -> { it -> it.purchaseUnit == true }
"PICKING" -> { it -> it.pickingUnit == true }
else -> { _ -> false }
}

private fun buildUomCache(
uomsByItemId: Map<Long, List<ItemUom>>,
deletedUomsByItemId: Map<Long, List<ItemUom>>,
materials: List<BomMaterial> = emptyList(),
): Map<Long, UomConversion> {
val map = mutableMapOf<Long, UomConversion>()
fun put(uom: UomConversion?) {
val id = uom?.id ?: return
map[id] = uom
}
for (rows in uomsByItemId.values) {
for (row in rows) put(row.uom)
}
for (rows in deletedUomsByItemId.values) {
for (row in rows) put(row.uom)
}
for (material in materials) {
put(material.uom)
put(material.salesUnit)
}
return map
}

companion object {
private val ITEM_TAB_UNIT_KEYS = listOf("BASE", "SALES", "STOCK", "PURCHASE")
private val ALL_UNIT_KEYS = listOf("BASE", "SALES", "STOCK", "PURCHASE", "PICKING")
}

/** One query: all active item_uom + uom_conversion, grouped by itemId. */
private fun loadActiveUomsByItemId(): Map<Long, List<ItemUom>> {
val grouped = mutableMapOf<Long, MutableList<ItemUom>>()
for (row in itemUomRespository.findAllActiveWithUom()) {
val itemId = row.item?.id
?: continue
grouped.computeIfAbsent(itemId) { mutableListOf() }.add(row)
}
return grouped
}

private fun loadDeletedUomsByItemId(): Map<Long, List<ItemUom>> {
val grouped = mutableMapOf<Long, MutableList<ItemUom>>()
for (row in itemUomRespository.findAllDeletedWithUom()) {
val itemId = row.item?.id ?: continue
grouped.computeIfAbsent(itemId) { mutableListOf() }.add(row)
}
return grouped
}

private fun uomsForItem(itemId: Long, uomsByItemId: Map<Long, List<ItemUom>>): List<ItemUom> =
uomsByItemId[itemId] ?: emptyList()

private fun rowModifiedTime(row: ItemUom): LocalDateTime? =
row.modified ?: row.m18LastModifyDate

private fun newestRow(rows: List<ItemUom>): ItemUom? =
rows.maxByOrNull { rowModifiedTime(it) ?: LocalDateTime.MIN }

private fun findSalesUnitRow(itemId: Long, uomsByItemId: Map<Long, List<ItemUom>>): ItemUom? =
uomsForItem(itemId, uomsByItemId).firstOrNull { it.salesUnit == true }

private fun findStockUnitRow(itemId: Long, uomsByItemId: Map<Long, List<ItemUom>>): ItemUom? =
uomsForItem(itemId, uomsByItemId).firstOrNull { it.stockUnit == true }

private fun findBaseUnitRow(itemId: Long, uomsByItemId: Map<Long, List<ItemUom>>): ItemUom? =
uomsForItem(itemId, uomsByItemId).firstOrNull { it.baseUnit == true }

private fun evaluateUnitHealth(
activeUoms: List<ItemUom>,
deletedUoms: List<ItemUom>,
flag: (ItemUom) -> Boolean,
): UnitHealth {
val active = activeUoms.filter(flag)
when {
active.size == 1 -> {
val row = active.first()
val uom = row.uom
if (uom == null || uom.deleted == true) {
return UnitHealth(
value = formatUom(uom).takeIf { uom != null },
correct = false,
modifiedAt = rowModifiedTime(row)?.toString(),
status = "MISSING",
)
}
return UnitHealth(
value = formatUom(uom),
correct = true,
modifiedAt = rowModifiedTime(row)?.toString(),
status = "OK",
)
}
active.size > 1 -> {
val row = newestRow(active)
return UnitHealth(
value = row?.let { formatUom(it.uom) },
correct = false,
modifiedAt = active.mapNotNull { rowModifiedTime(it) }.maxOrNull()?.toString(),
status = "MISSING",
)
}
else -> {
val deleted = deletedUoms.filter(flag)
if (deleted.isNotEmpty()) {
val row = newestRow(deleted)!!
return UnitHealth(
value = formatUom(row.uom),
correct = false,
modifiedAt = rowModifiedTime(row)?.toString(),
status = "DELETED",
)
}
return UnitHealth(
value = null,
correct = false,
modifiedAt = null,
status = "MISSING",
)
}
}
}

private fun buildUnitSnapshot(
itemId: Long?,
uomsByItemId: Map<Long, List<ItemUom>>,
deletedUomsByItemId: Map<Long, List<ItemUom>> = emptyMap(),
): UnitSnapshot? {
if (itemId == null) return null
val active = uomsForItem(itemId, uomsByItemId)
val deleted = uomsForItem(itemId, deletedUomsByItemId)
return UnitSnapshot(
base = evaluateUnitHealth(active, deleted) { it.baseUnit == true },
stock = evaluateUnitHealth(active, deleted) { it.stockUnit == true },
purchase = evaluateUnitHealth(active, deleted) { it.purchaseUnit == true },
sales = evaluateUnitHealth(active, deleted) { it.salesUnit == true },
)
}

private fun collectBomHeaderIssues(
bom: Bom,
issues: MutableList<MasterDataIssueResponse>,
uomsByItemId: Map<Long, List<ItemUom>>,
) {
val bomId = bom.id ?: return
val bomCode = bom.code
val bomName = bom.name
val description = bom.description
val ctx = IssueContext(
scope = "BOM",
bomId = bomId,
bomCode = bomCode,
bomName = bomName,
description = description,
)

if (bomCode.isNullOrBlank()) {
issues.add(issue(ctx, null, "MISSING_BOM_CODE"))
}
if (bomName.isNullOrBlank()) {
issues.add(issue(ctx, null, "MISSING_BOM_NAME"))
}

val item = bom.item
val itemId = item?.id
val unitSnapshot = buildUnitSnapshot(itemId, uomsByItemId)
if (item == null || item.deleted == true) {
issues.add(issue(ctx, itemId, "MISSING_ITEM", unitSnapshot = unitSnapshot))
return
}

collectItemUomIssues(item, ctx, issues, uomsByItemId, unitSnapshot = unitSnapshot)

val bomUom = bom.uom
val salesConv = findSalesUnitRow(item.id!!, uomsByItemId)?.uom
if (bomUom != null && salesConv != null && bomUom.id != salesConv.id) {
issues.add(
issue(
ctx,
item.id,
"BOM_OUTPUT_UOM_MISMATCH_SALES",
itemCode = item.code,
itemName = item.name,
expectedValue = formatUom(salesConv),
actualValue = formatUom(bomUom),
unitSnapshot = unitSnapshot,
),
)
}
if (bomUom != null) {
val uomDesc = bomUom.udfudesc?.trim().orEmpty()
val outputDesc = bom.outputQtyUom?.trim().orEmpty()
if (outputDesc.isNotEmpty() && uomDesc.isNotEmpty() && !outputDesc.equals(uomDesc, ignoreCase = true)) {
issues.add(
issue(
ctx,
item.id,
"BOM_OUTPUT_UOM_TEXT_DRIFT",
itemCode = item.code,
itemName = item.name,
expectedValue = uomDesc,
actualValue = outputDesc,
unitSnapshot = unitSnapshot,
),
)
}
}
}

private fun collectBomMaterialIssues(
bom: Bom,
material: BomMaterial,
issues: MutableList<MasterDataIssueResponse>,
uomsByItemId: Map<Long, List<ItemUom>>,
uomCache: Map<Long, UomConversion> = emptyMap(),
) {
val bomId = bom.id ?: return
val materialId = material.id
val ctx = IssueContext(
scope = "BOM_MATERIAL",
bomId = bomId,
bomCode = bom.code,
bomName = bom.name,
bomMaterialId = materialId,
description = bom.description,
)

val matItem = material.item
val unitSnapshot = buildUnitSnapshot(matItem?.id, uomsByItemId)
if (matItem == null || matItem.deleted == true) {
issues.add(
issue(
ctx,
matItem?.id,
"BOM_MATERIAL_MISSING_ITEM",
itemCode = matItem?.code,
itemName = matItem?.name ?: material.itemName,
unitSnapshot = unitSnapshot,
),
)
return
}

// Item master UOM gaps belong on the Item tab only — not repeated per BOM here.
val matItemCode = matItem.code
val matItemName = matItem.name?.takeIf { it.isNotBlank() } ?: material.itemName

val matUom = material.uom
if (matUom != null && matUom.deleted == true) {
issues.add(
issue(
ctx,
matItem.id,
"BOM_MATERIAL_UOM_FK_INVALID",
itemCode = matItemCode,
itemName = matItemName,
actualValue = "uomId=${matUom.id}",
unitSnapshot = unitSnapshot,
),
)
}

val salesUnitFk = material.salesUnit
if (salesUnitFk != null && salesUnitFk.deleted == true) {
issues.add(
issue(
ctx,
matItem.id,
"BOM_MATERIAL_UOM_FK_INVALID",
itemCode = matItemCode,
itemName = matItemName,
actualValue = "salesUnitId=${salesUnitFk.id}",
unitSnapshot = unitSnapshot,
),
)
}

val itemSales = findSalesUnitRow(matItem.id!!, uomsByItemId)?.uom
if (salesUnitFk != null && itemSales != null && salesUnitFk.id != itemSales.id) {
issues.add(
issue(
ctx,
matItem.id,
"BOM_MATERIAL_SALES_UOM_MISMATCH",
itemCode = matItemCode,
itemName = matItemName,
expectedValue = formatUom(itemSales),
actualValue = formatUom(salesUnitFk),
unitSnapshot = unitSnapshot,
),
)
}

val itemBase = findBaseUnitRow(matItem.id!!, uomsByItemId)?.uom
val matBaseId = material.baseUnit?.toLong()
if (matBaseId != null && itemBase != null && matBaseId != itemBase.id) {
issues.add(
issue(
ctx,
matItem.id,
"BOM_MATERIAL_BASE_UOM_MISMATCH",
itemCode = matItemCode,
itemName = matItemName,
expectedValue = formatUom(itemBase),
actualValue = formatUomById(matBaseId, uomCache),
unitSnapshot = unitSnapshot,
),
)
}

val itemStock = findStockUnitRow(matItem.id!!, uomsByItemId)?.uom
val matStockId = material.stockUnit?.toLong()
if (matStockId != null && itemStock != null && matStockId != itemStock.id) {
issues.add(
issue(
ctx,
matItem.id,
"BOM_MATERIAL_STOCK_UOM_MISMATCH",
itemCode = matItemCode,
itemName = matItemName,
expectedValue = formatUom(itemStock),
actualValue = formatUomById(matStockId, uomCache),
unitSnapshot = unitSnapshot,
),
)
}
}

private fun collectItemUomIssues(
item: Items,
ctx: IssueContext,
issues: MutableList<MasterDataIssueResponse>,
uomsByItemId: Map<Long, List<ItemUom>>,
deletedUomsByItemId: Map<Long, List<ItemUom>> = emptyMap(),
unitSnapshot: UnitSnapshot? = null,
) {
val itemId = item.id ?: return
val allUoms = uomsForItem(itemId, uomsByItemId)
val snapshot = unitSnapshot ?: buildUnitSnapshot(itemId, uomsByItemId, deletedUomsByItemId)

checkUnitType(item, ctx, issues, allUoms, { it.baseUnit == true }, "BASE", snapshot)
checkUnitType(item, ctx, issues, allUoms, { it.salesUnit == true }, "SALES", snapshot)
checkUnitType(item, ctx, issues, allUoms, { it.stockUnit == true }, "STOCK", snapshot)
checkUnitType(item, ctx, issues, allUoms, { it.pickingUnit == true }, "PICKING", snapshot)
checkUnitType(item, ctx, issues, allUoms, { it.purchaseUnit == true }, "PURCHASE", snapshot)
}

private fun unitHealthForKey(snapshot: UnitSnapshot?, unitKey: String): UnitHealth? =
when (unitKey) {
"BASE" -> snapshot?.base
"STOCK" -> snapshot?.stock
"PURCHASE" -> snapshot?.purchase
"SALES" -> snapshot?.sales
else -> null
}

private fun checkUnitType(
item: Items,
ctx: IssueContext,
issues: MutableList<MasterDataIssueResponse>,
allUoms: List<ItemUom>,
flag: (ItemUom) -> Boolean,
unitKey: String,
unitSnapshot: UnitSnapshot?,
) {
val rows = allUoms.filter(flag)
when {
rows.isEmpty() -> {
val health = unitHealthForKey(unitSnapshot, unitKey)
val issueCode =
if (health?.status == "DELETED") "DELETED_${unitKey}_UOM" else "MISSING_${unitKey}_UOM"
issues.add(
issue(
ctx,
item.id,
issueCode,
itemCode = item.code,
itemName = item.name,
unitSnapshot = unitSnapshot,
),
)
}
rows.size > 1 -> {
issues.add(
issue(
ctx,
item.id,
"MULTIPLE_${unitKey}_UOM",
itemCode = item.code,
itemName = item.name,
actualValue = "count=${rows.size}",
unitSnapshot = unitSnapshot,
),
)
rows.forEach { row -> checkUomConversion(item, ctx, issues, row, unitKey, unitSnapshot) }
}
else -> checkUomConversion(item, ctx, issues, rows.first(), unitKey, unitSnapshot)
}
}

private fun checkUomConversion(
item: Items,
ctx: IssueContext,
issues: MutableList<MasterDataIssueResponse>,
row: ItemUom,
unitKey: String,
unitSnapshot: UnitSnapshot?,
) {
val uom = row.uom
if (uom == null || uom.deleted == true) {
issues.add(
issue(
ctx,
item.id,
"MISSING_${unitKey}_UOM_CONVERSION",
itemCode = item.code,
itemName = item.name,
unitSnapshot = unitSnapshot,
),
)
}
}

private fun issue(
ctx: IssueContext,
itemId: Long?,
issueCode: String,
itemCode: String? = null,
itemName: String? = null,
expectedValue: String? = null,
actualValue: String? = null,
unitSnapshot: UnitSnapshot? = null,
): MasterDataIssueResponse =
MasterDataIssueResponse(
scope = ctx.scope,
bomId = ctx.bomId,
bomCode = ctx.bomCode,
bomName = ctx.bomName,
bomMaterialId = ctx.bomMaterialId,
itemId = itemId,
itemCode = itemCode,
itemName = itemName,
description = ctx.description,
issueCode = issueCode,
expectedValue = expectedValue,
actualValue = actualValue,
baseUnitValue = unitSnapshot?.base?.value,
stockUnitValue = unitSnapshot?.stock?.value,
purchaseUnitValue = unitSnapshot?.purchase?.value,
salesUnitValue = unitSnapshot?.sales?.value,
baseUnitCorrect = unitSnapshot?.base?.correct,
stockUnitCorrect = unitSnapshot?.stock?.correct,
purchaseUnitCorrect = unitSnapshot?.purchase?.correct,
salesUnitCorrect = unitSnapshot?.sales?.correct,
baseUnitModifiedAt = unitSnapshot?.base?.modifiedAt,
stockUnitModifiedAt = unitSnapshot?.stock?.modifiedAt,
purchaseUnitModifiedAt = unitSnapshot?.purchase?.modifiedAt,
salesUnitModifiedAt = unitSnapshot?.sales?.modifiedAt,
baseUnitStatus = unitSnapshot?.base?.status,
stockUnitStatus = unitSnapshot?.stock?.status,
purchaseUnitStatus = unitSnapshot?.purchase?.status,
salesUnitStatus = unitSnapshot?.sales?.status,
)

private fun formatUom(uom: UomConversion?): String {
if (uom == null) return "-"
val code = uom.code?.trim().orEmpty()
val desc = uom.udfudesc?.trim().orEmpty()
return when {
code.isNotEmpty() && desc.isNotEmpty() -> "$code / $desc"
desc.isNotEmpty() -> desc
code.isNotEmpty() -> code
else -> "-"
}
}

private fun formatUomById(uomId: Long?, uomCache: Map<Long, UomConversion> = emptyMap()): String {
if (uomId == null) return "-"
val uom = uomCache[uomId] ?: uomConversionService.findById(uomId)
return formatUom(uom)
}

private fun sortIssues(issues: List<MasterDataIssueResponse>): List<MasterDataIssueResponse> =
issues.sortedWith(
compareBy(
{ it.scope },
{ it.bomCode?.uppercase() ?: "" },
{ it.itemCode?.uppercase() ?: "" },
{ it.issueCode },
{ it.bomId ?: 0L },
{ it.itemId ?: 0L },
),
)
}

+ 15
- 2
src/main/java/com/ffii/fpsms/modules/master/service/ProductionScheduleService.kt Vedi File

@@ -726,7 +726,7 @@ open class ProductionScheduleService(
LEFT JOIN items ON bom.itemId = items.id
LEFT JOIN inventory ON items.id = inventory.itemId
left join item_fake_onhand on items.code = item_fake_onhand.itemCode
WHERE bom.deleted = 0 and bom.description = 'FG'
WHERE bom.deleted = 0 and bom.description = 'FG' and bom.status = 'active'
-- and bom.itemId != 16771
) AS i
WHERE 1
@@ -1464,6 +1464,15 @@ open class ProductionScheduleService(
dataFormat = workbook.createDataFormat().getFormat("#,##0.0")
}

val daysLeftLowStyle = workbook.createCellStyle().apply {
cloneStyleFrom(numberDigitStyle)
fillForegroundColor = IndexedColors.RED.index
fillPattern = FillPatternType.SOLID_FOREGROUND
val font = workbook.createFont()
font.color = IndexedColors.WHITE.index
font.bold = true
setFont(font)
}

// ── Group production lines by date ──
val groupedData = lines.groupBy {
@@ -1505,7 +1514,11 @@ open class ProductionScheduleService(
row.createCell(j++).apply { setCellValue(line["itemName"]?.toString() ?: ""); cellStyle = wrapStyle }
row.createCell(j++).apply { setCellValue(asDouble(line["avgQtyLastMonth"])); cellStyle = numberStyle }
row.createCell(j++).apply { setCellValue(asDouble(line["stockQty"])); cellStyle = numberStyle }
row.createCell(j++).apply { setCellValue(asDouble(line["daysLeft"])); cellStyle = numberDigitStyle }
val daysLeftVal = asDouble(line["daysLeft"])
row.createCell(j++).apply {
setCellValue(daysLeftVal)
cellStyle = if (daysLeftVal < 1.0) daysLeftLowStyle else numberDigitStyle
}
row.createCell(j++).apply { setCellValue(asDouble(line["outputdQty"] ?: line["outputQty"])); cellStyle = numberStyle }
row.createCell(j++).apply { setCellValue(asDouble(line["batchNeed"])); cellStyle = numberStyle }
row.createCell(j++).apply { setCellValue(asDouble(line["itemPriority"])); cellStyle = numberStyle }


+ 22
- 0
src/main/java/com/ffii/fpsms/modules/master/service/ShopService.kt Vedi File

@@ -7,6 +7,7 @@ import com.ffii.fpsms.modules.master.entity.projections.ShopCombo
import com.ffii.fpsms.modules.master.enums.ShopType
import com.ffii.fpsms.modules.master.web.models.SaveShopRequest
import com.ffii.fpsms.modules.master.web.models.SaveShopResponse
import org.slf4j.LoggerFactory
import org.springframework.stereotype.Service
import kotlin.jvm.optionals.getOrDefault

@@ -14,6 +15,8 @@ import kotlin.jvm.optionals.getOrDefault
open class ShopService(
val shopRepository: ShopRepository
) {
private val logger = LoggerFactory.getLogger(ShopService::class.java)

open fun findAll(): List<Shop> {
return shopRepository.findAllByDeletedIsFalse()
}
@@ -26,6 +29,25 @@ open class ShopService(
return shopRepository.findByM18IdAndTypeAndDeletedIsFalse(m18Id, ShopType.SUPPLIER)
}

/**
* Supplier by code. [shop] may contain duplicate codes (e.g. PF/PP vendor rows); picks one with
* [Shop.m18Id] when present, else the newest row by id.
*/
open fun findVendorByCode(code: String): Shop? {
val trimmed = code.trim()
if (trimmed.isEmpty()) return null
val matches = shopRepository.findAllByCodeAndTypeAndDeletedIsFalseOrderByIdDesc(trimmed, ShopType.SUPPLIER)
if (matches.isEmpty()) return null
if (matches.size > 1) {
logger.warn(
"Multiple supplier shop rows for code={} (count={}); using row with m18Id or newest id",
trimmed,
matches.size,
)
}
return matches.firstOrNull { (it.m18Id ?: 0L) > 0L } ?: matches.first()
}

open fun findShopByM18Id(m18Id: Long): Shop? {
return shopRepository.findByM18IdAndTypeAndDeletedIsFalse(m18Id, ShopType.SHOP)
}


+ 48
- 19
src/main/java/com/ffii/fpsms/modules/master/service/WarehouseQrCodeService.kt Vedi File

@@ -2,12 +2,17 @@ package com.ffii.fpsms.modules.master.service

import com.ffii.core.utils.PdfUtils
import com.ffii.core.utils.QrCodeUtil
import com.ffii.fpsms.modules.master.print.A4PrintDriverRegistry
import com.ffii.fpsms.modules.master.entity.WarehouseRepository
import com.ffii.fpsms.modules.master.web.ExportWarehouseQrCodeRequest
import com.ffii.fpsms.modules.master.web.PrintWarehouseQrCodeRequest
import net.sf.jasperreports.engine.JasperCompileManager
import net.sf.jasperreports.engine.JasperExportManager
import net.sf.jasperreports.engine.JasperReport
import net.sf.jasperreports.engine.JasperPrint
import org.springframework.core.io.ClassPathResource
import org.springframework.stereotype.Service
import java.io.File
import java.io.FileNotFoundException
import java.awt.GraphicsEnvironment
import kotlinx.serialization.json.Json
@@ -15,19 +20,32 @@ import kotlinx.serialization.encodeToString

@Service
class WarehouseQrCodeService(
private val warehouseRepository: WarehouseRepository
private val warehouseRepository: WarehouseRepository,
private val printerService: PrinterService,
) {
private val qrCodeHandleJrxmlPath = "qrCodeHandle/warehouse_QrHandle.jrxml"

fun exportWarehouseQrCode(request: ExportWarehouseQrCodeRequest): Map<String, Any> {
val QRCODE_HANDLE_PDF = "qrCodeHandle/warehouse_QrHandle.jrxml"
val resource = ClassPathResource(QRCODE_HANDLE_PDF)
/** Compile the Jasper template once; compiling per request is expensive. */
private val qrCodeHandleReport: JasperReport by lazy {
val resource = ClassPathResource(qrCodeHandleJrxmlPath)
if (!resource.exists()) {
throw FileNotFoundException("Report file not found: $QRCODE_HANDLE_PDF")
throw FileNotFoundException("Report file not found: $qrCodeHandleJrxmlPath")
}
val inputStream = resource.inputStream
val qrCodeHandleReport = JasperCompileManager.compileReport(inputStream)
resource.inputStream.use { JasperCompileManager.compileReport(it) }
}

/** Cache the chosen Chinese font family name (font scanning is expensive). */
private val chineseFontFamily: String by lazy {
val availableFonts = GraphicsEnvironment.getLocalGraphicsEnvironment().availableFontFamilyNames
availableFonts.find { family ->
family.contains("SimSun", ignoreCase = true) ||
family.contains("Microsoft YaHei", ignoreCase = true) ||
family.contains("STSong", ignoreCase = true) ||
family.contains("SimHei", ignoreCase = true)
} ?: "Arial Unicode MS"
}

fun exportWarehouseQrCode(request: ExportWarehouseQrCodeRequest): Map<String, Any> {
val warehouses = warehouseRepository.findAllById(request.warehouseIds)
if (warehouses.isEmpty()) {
throw IllegalArgumentException("No warehouses found for the provided warehouse IDs: ${request.warehouseIds}")
@@ -68,18 +86,10 @@ class WarehouseQrCodeService(
}
val params: MutableMap<String, Any> = mutableMapOf()
val availableFonts = GraphicsEnvironment.getLocalGraphicsEnvironment().availableFontFamilyNames
val chineseFont = availableFonts.find {
it.contains("SimSun", ignoreCase = true) ||
it.contains("Microsoft YaHei", ignoreCase = true) ||
it.contains("STSong", ignoreCase = true) ||
it.contains("SimHei", ignoreCase = true)
} ?: "Arial Unicode MS"

params["net.sf.jasperreports.default.pdf.encoding"] = "Identity-H"
params["net.sf.jasperreports.default.pdf.embedded"] = true
params["net.sf.jasperreports.default.pdf.font.name"] = chineseFont
params["net.sf.jasperreports.default.pdf.font.name"] = chineseFontFamily
val firstWarehouse = warehouses.firstOrNull()
@@ -88,4 +98,23 @@ class WarehouseQrCodeService(
"fileName" to (firstWarehouse?.code ?: "warehouse_qrcode")
)
}

fun printWarehouseQrCode(request: PrintWarehouseQrCodeRequest) {
val printer = printerService.findById(request.printerId) ?: throw NoSuchElementException("No such printer")
val pdf = exportWarehouseQrCode(ExportWarehouseQrCodeRequest(request.warehouseIds))
val jasperPrint = pdf["report"] as JasperPrint
val tempPdfFile = File.createTempFile("print_job_", ".pdf")

try {
JasperExportManager.exportReportToPdfFile(jasperPrint, tempPdfFile.absolutePath)
val printQty = if (request.printQty == null || request.printQty <= 0) 1 else request.printQty
printer.ip?.let { ip ->
val port = printer.port ?: 9100
val driver = A4PrintDriverRegistry.getDriver(printer.brand)
driver.print(tempPdfFile, ip, port, printQty)
}
} finally {
tempPdfFile.delete()
}
}
}

Dato che sono stati cambiati molti file in questo diff, alcuni di essi non verranno mostrati

Caricamento…
Annulla
Salva