Przeglądaj źródła

Merge branch 'production' of https://git.2fi-solutions.com/derek/FPSMS-backend into production

# Conflicts:
#	src/main/java/com/ffii/fpsms/modules/deliveryOrder/service/DoWorkbenchMainService.kt
production
kelvin.yau 1 miesiąc temu
rodzic
commit
5b61294a53
98 zmienionych plików z 5857 dodań i 469 usunięć
  1. +2
    -1
      .gitignore
  2. +78
    -19
      python/Bag3.py
  3. BIN
      python/__pycache__/Bag3.cpython-313.pyc
  4. +48
    -0
      src/main/java/com/ffii/fpsms/m18/entity/M18BomShopSyncLog.kt
  5. +12
    -0
      src/main/java/com/ffii/fpsms/m18/entity/M18BomShopSyncLogRepository.kt
  6. +96
    -0
      src/main/java/com/ffii/fpsms/m18/model/M18BomForShopSaveRequest.kt
  7. +14
    -0
      src/main/java/com/ffii/fpsms/m18/model/M18BomShopSyncTriggerResult.kt
  8. +394
    -0
      src/main/java/com/ffii/fpsms/m18/service/M18BomForShopService.kt
  9. +6
    -6
      src/main/java/com/ffii/fpsms/m18/service/M18DeliveryOrderService.kt
  10. +14
    -4
      src/main/java/com/ffii/fpsms/m18/web/M18TestController.kt
  11. +1
    -1
      src/main/java/com/ffii/fpsms/modules/bag/service/bagService.kt
  12. +81
    -49
      src/main/java/com/ffii/fpsms/modules/chart/service/ChartService.kt
  13. +11
    -5
      src/main/java/com/ffii/fpsms/modules/chart/web/ChartController.kt
  14. +10
    -0
      src/main/java/com/ffii/fpsms/modules/common/SettingNames.java
  15. +56
    -30
      src/main/java/com/ffii/fpsms/modules/common/scheduler/service/SchedulerService.kt
  16. +5
    -0
      src/main/java/com/ffii/fpsms/modules/common/scheduler/web/SchedulerController.kt
  17. +2
    -2
      src/main/java/com/ffii/fpsms/modules/deliveryOrder/entity/DeliveryOrder.kt
  18. +4
    -4
      src/main/java/com/ffii/fpsms/modules/deliveryOrder/entity/DeliveryOrderRepository.kt
  19. +3
    -3
      src/main/java/com/ffii/fpsms/modules/deliveryOrder/entity/models/DeliveryOrderInfo.kt
  20. +52
    -82
      src/main/java/com/ffii/fpsms/modules/deliveryOrder/service/DeliveryOrderService.kt
  21. +95
    -0
      src/main/java/com/ffii/fpsms/modules/deliveryOrder/service/DoFloorSupplierSettingsService.kt
  22. +7
    -20
      src/main/java/com/ffii/fpsms/modules/deliveryOrder/service/DoReleaseCoordinatorService.kt
  23. +11
    -0
      src/main/java/com/ffii/fpsms/modules/deliveryOrder/service/DoWorkbenchDopoAssignmentService.kt
  24. +178
    -10
      src/main/java/com/ffii/fpsms/modules/deliveryOrder/service/DoWorkbenchMainService.kt
  25. +20
    -9
      src/main/java/com/ffii/fpsms/modules/deliveryOrder/service/DoWorkbenchReleaseService.kt
  26. +3
    -3
      src/main/java/com/ffii/fpsms/modules/deliveryOrder/web/DeliveryOrderController.kt
  27. +17
    -2
      src/main/java/com/ffii/fpsms/modules/deliveryOrder/web/DoWorkbenchController.kt
  28. +16
    -3
      src/main/java/com/ffii/fpsms/modules/deliveryOrder/web/models/DoDetailResponse.kt
  29. +5
    -4
      src/main/java/com/ffii/fpsms/modules/deliveryOrder/web/models/ReleaseDoRequest.kt
  30. +1
    -1
      src/main/java/com/ffii/fpsms/modules/deliveryOrder/web/models/SaveDeliveryOrderRequest.kt
  31. +243
    -0
      src/main/java/com/ffii/fpsms/modules/jobOrder/service/JobOrderPlanStartAutoService.kt
  32. +6
    -3
      src/main/java/com/ffii/fpsms/modules/jobOrder/service/PlasticBagPrinterService.kt
  33. +33
    -0
      src/main/java/com/ffii/fpsms/modules/logistic/entity/Logistic.kt
  34. +12
    -0
      src/main/java/com/ffii/fpsms/modules/logistic/entity/LogisticRepository.kt
  35. +82
    -0
      src/main/java/com/ffii/fpsms/modules/logistic/service/LogisticService.kt
  36. +65
    -0
      src/main/java/com/ffii/fpsms/modules/logistic/web/LogisticController.kt
  37. +9
    -0
      src/main/java/com/ffii/fpsms/modules/logistic/web/models/DeleteLogisticRequest.kt
  38. +10
    -0
      src/main/java/com/ffii/fpsms/modules/logistic/web/models/LogisticResponse.kt
  39. +21
    -0
      src/main/java/com/ffii/fpsms/modules/logistic/web/models/SaveLogisticRequest.kt
  40. +12
    -0
      src/main/java/com/ffii/fpsms/modules/logistic/web/models/SaveLogisticsBatchRequest.kt
  41. +10
    -0
      src/main/java/com/ffii/fpsms/modules/master/entity/ShopRepository.kt
  42. +144
    -0
      src/main/java/com/ffii/fpsms/modules/master/service/BomService.kt
  43. +14
    -0
      src/main/java/com/ffii/fpsms/modules/master/service/ItemUomService.kt
  44. +12
    -0
      src/main/java/com/ffii/fpsms/modules/master/web/BomController.kt
  45. +14
    -0
      src/main/java/com/ffii/fpsms/modules/master/web/models/BomIdByItemCodeResponse.kt
  46. +6
    -1
      src/main/java/com/ffii/fpsms/modules/pickOrder/entity/Truck.kt
  47. +19
    -0
      src/main/java/com/ffii/fpsms/modules/pickOrder/entity/TruckLaneVersion.kt
  48. +55
    -0
      src/main/java/com/ffii/fpsms/modules/pickOrder/entity/TruckLaneVersionLine.kt
  49. +10
    -0
      src/main/java/com/ffii/fpsms/modules/pickOrder/entity/TruckLaneVersionLineRepository.kt
  50. +12
    -0
      src/main/java/com/ffii/fpsms/modules/pickOrder/entity/TruckLaneVersionRepository.kt
  51. +81
    -4
      src/main/java/com/ffii/fpsms/modules/pickOrder/entity/TruckRepository.kt
  52. +3
    -0
      src/main/java/com/ffii/fpsms/modules/pickOrder/service/HierarchicalFgPayloadAssembler.kt
  53. +202
    -0
      src/main/java/com/ffii/fpsms/modules/pickOrder/service/RouteLaneExcelSupport.kt
  54. +359
    -0
      src/main/java/com/ffii/fpsms/modules/pickOrder/service/RouteReportExcelSupport.kt
  55. +194
    -0
      src/main/java/com/ffii/fpsms/modules/pickOrder/service/TruckLaneVersionReportExcelSupport.kt
  56. +300
    -0
      src/main/java/com/ffii/fpsms/modules/pickOrder/service/TruckLaneVersionRouteReportExcelSupport.kt
  57. +308
    -0
      src/main/java/com/ffii/fpsms/modules/pickOrder/service/TruckLaneVersionService.kt
  58. +1311
    -31
      src/main/java/com/ffii/fpsms/modules/pickOrder/service/TruckService.kt
  59. +218
    -15
      src/main/java/com/ffii/fpsms/modules/pickOrder/web/TruckController.kt
  60. +73
    -0
      src/main/java/com/ffii/fpsms/modules/pickOrder/web/TruckLaneVersionController.kt
  61. +5
    -0
      src/main/java/com/ffii/fpsms/modules/pickOrder/web/models/ExportRouteLanesRequest.kt
  62. +11
    -0
      src/main/java/com/ffii/fpsms/modules/pickOrder/web/models/ExportRouteReportRequest.kt
  63. +7
    -0
      src/main/java/com/ffii/fpsms/modules/pickOrder/web/models/ExportTruckLaneVersionReportExcelRequest.kt
  64. +22
    -0
      src/main/java/com/ffii/fpsms/modules/pickOrder/web/models/ParseRouteLanesExcelModels.kt
  65. +15
    -1
      src/main/java/com/ffii/fpsms/modules/pickOrder/web/models/SaveTruckRequest.kt
  66. +34
    -0
      src/main/java/com/ffii/fpsms/modules/pickOrder/web/models/TruckLaneCombinationResponse.kt
  67. +68
    -0
      src/main/java/com/ffii/fpsms/modules/pickOrder/web/models/TruckLaneVersionModels.kt
  68. +8
    -0
      src/main/java/com/ffii/fpsms/modules/pickOrder/web/models/UpdateTruckLaneVersionNoteRequest.kt
  69. +12
    -12
      src/main/java/com/ffii/fpsms/modules/productProcess/service/ProductProcessService.kt
  70. +32
    -0
      src/main/java/com/ffii/fpsms/modules/purchaseOrder/entity/PurchaseOrderLineRepository.kt
  71. +40
    -30
      src/main/java/com/ffii/fpsms/modules/report/service/FGStockOutTraceabilityReportService.kt
  72. +44
    -43
      src/main/java/com/ffii/fpsms/modules/report/service/ReportService.kt
  73. +14
    -2
      src/main/java/com/ffii/fpsms/modules/settings/web/SettingsController.java
  74. +16
    -5
      src/main/java/com/ffii/fpsms/modules/stock/entity/InventoryRepository.kt
  75. +13
    -12
      src/main/java/com/ffii/fpsms/modules/stock/entity/StockLedgerRepository.kt
  76. +19
    -34
      src/main/java/com/ffii/fpsms/modules/stock/service/StockTakeRecordService.kt
  77. +10
    -6
      src/main/java/com/ffii/fpsms/modules/stock/service/SuggestedPickLotService.kt
  78. +14
    -1
      src/main/java/com/ffii/fpsms/modules/stock/web/StockTakeRecordController.kt
  79. +8
    -0
      src/main/java/com/ffii/fpsms/modules/stock/web/model/CreateStockTakeForSectionsRequest.kt
  80. +10
    -0
      src/main/java/com/ffii/fpsms/modules/user/service/GroupService.java
  81. +4
    -4
      src/main/java/com/ffii/fpsms/modules/user/service/UserService.java
  82. +9
    -1
      src/main/java/com/ffii/fpsms/modules/user/web/GroupController.java
  83. +1
    -6
      src/main/java/com/ffii/fpsms/modules/user/web/UserController.java
  84. +4
    -0
      src/main/resources/application.yml
  85. +130
    -0
      src/main/resources/db/changelog/changes/20260430_02_2fi/01_truck_lane_version_snapshot.sql
  86. +46
    -0
      src/main/resources/db/changelog/changes/20260430_02_2fi/02_truck_lane_version_snapshot_patch.sql
  87. +10
    -0
      src/main/resources/db/changelog/changes/20260504_01_2fi/01_truck_add_logistic_id.sql
  88. +52
    -0
      src/main/resources/db/changelog/changes/20260505_01_2fi/01_truck_lane_version_drop_store_id.sql
  89. +37
    -0
      src/main/resources/db/changelog/changes/20260507_01_2fi/01_truck_lane_version_line_add_logistic_id.sql
  90. +20
    -0
      src/main/resources/db/changelog/changes/20260512_fai/01_m18_bom_shop_sync_settings.sql
  91. +24
    -0
      src/main/resources/db/changelog/changes/20260512_fai/02_m18_bom_shop_sync_log.sql
  92. +4
    -0
      src/main/resources/db/changelog/changes/20260513_do2_1pm/01_update_do2_schedule_to_13.sql
  93. +18
    -0
      src/main/resources/db/changelog/changes/20260514_Enson/01_setting.sql
  94. +6
    -0
      src/main/resources/db/changelog/changes/20260514_Enson/02_setting.sql
  95. +7
    -0
      src/main/resources/db/changelog/changes/20260515_fpsms/01_m18_bom_shop_sync_log_columns.sql
  96. +1
    -0
      src/main/resources/log4j2-prod-linux.yml
  97. +1
    -0
      src/main/resources/log4j2-prod-win.yml
  98. +1
    -0
      src/main/resources/log4j2.yml

+ 2
- 1
.gitignore Wyświetl plik

@@ -37,4 +37,5 @@ out/
.vscode/ .vscode/
package-lock.json package-lock.json
python/Bag3.spec python/Bag3.spec
python/dist/Bag3.exe
python/dist


+ 78
- 19
python/Bag3.py Wyświetl plik

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


import errno
import json import json
import os import os
import select import select
@@ -344,6 +345,25 @@ DATAFLEX_UI_PROGRESS_EVERY = max(
DATAFLEX_SINGLE_TCP_JOB = _dataflex_bool_env( DATAFLEX_SINGLE_TCP_JOB = _dataflex_bool_env(
"FPSMS_DATAFLEX_SINGLE_TCP_JOB", False "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 # Full recovery (~JR soft reset) — used by「打袋重設」only; longer delay for firmware
DATAFLEX_POST_FULL_RECOVERY_DELAY_SEC = 1.2 DATAFLEX_POST_FULL_RECOVERY_DELAY_SEC = 1.2
# Zebra ~RO only (used when FPSMS_DATAFLEX_NO_JR is set for full recovery) # Zebra ~RO only (used when FPSMS_DATAFLEX_NO_JR is set for full recovery)
@@ -364,12 +384,56 @@ def _zpl_escape(s: str) -> str:
return s.replace("\\", "\\\\").replace("^", "\\^") 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: def _dataflex_zpl_bytes(zpl: str) -> bytes:
"""UTF-8 ZPL with one trailing CRLF so the printer sees a clear job boundary.""" """UTF-8 ZPL with one trailing CRLF so the printer sees a clear job boundary."""
s = (zpl or "").rstrip("\r\n") s = (zpl or "").rstrip("\r\n")
return (s + "\r\n").encode("utf-8") 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( def generate_zpl_dataflex(
batch_no: str, batch_no: str,
item_code: str, item_code: str,
@@ -377,6 +441,7 @@ def generate_zpl_dataflex(
item_id: Optional[int] = None, item_id: Optional[int] = None,
stock_in_line_id: Optional[int] = None, stock_in_line_id: Optional[int] = None,
lot_no: Optional[str] = None, lot_no: Optional[str] = None,
job_order_id: Optional[int] = None,
font_regular: str = "E:STXihei.ttf", font_regular: str = "E:STXihei.ttf",
font_bold: str = "E:STXihei.ttf", font_bold: str = "E:STXihei.ttf",
) -> str: ) -> str:
@@ -398,11 +463,12 @@ def generate_zpl_dataflex(
qr_value = _zpl_escape(qr_payload) qr_value = _zpl_escape(qr_payload)
# Explicit ^PQ1: each ^XA…^XZ is exactly one bag. Avoids E1005 "over quantity" on some Zebra/DataFlex # 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. # 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 ^PQ1,0,1,N
^CI28 ^CI28
^PW700
^LL500
^PW{DATAFLEX_LABEL_PW}
^LL{DATAFLEX_LABEL_LL}
^PO N ^PO N
^FO10,20 ^FO10,20
^BQN,2,4^FDQA,{qr_value}^FS ^BQN,2,4^FDQA,{qr_value}^FS
@@ -447,10 +513,7 @@ def send_dataflex_preprint_reset(ip: str, port: int, *, force: bool = False) ->
sock.connect((ip, port)) sock.connect((ip, port))
sock.sendall(DATAFLEX_PREPRINT_BYTES) sock.sendall(DATAFLEX_PREPRINT_BYTES)
time.sleep(DATAFLEX_POST_PREPRINT_DELAY_SEC) time.sleep(DATAFLEX_POST_PREPRINT_DELAY_SEC)
try:
sock.shutdown(socket.SHUT_WR)
except OSError:
pass
_dataflex_shutdown_write_maybe(sock)
finally: finally:
sock.close() sock.close()


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


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


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


@@ -907,6 +961,10 @@ def query_dataflex_host_status(ip: str, port: int) -> str:
data = sock.recv(4096) data = sock.recv(4096)
except socket.timeout: except socket.timeout:
break break
except OSError as ex:
if _dataflex_is_benign_tcp_reset(ex):
break
raise
if not data: if not data:
break break
chunks.append(data) chunks.append(data)
@@ -2451,6 +2509,7 @@ def main() -> None:
item_id=item_id, item_id=item_id,
stock_in_line_id=stock_in_line_id, stock_in_line_id=stock_in_line_id,
lot_no=lot_no, lot_no=lot_no,
job_order_id=j.get("id"),
) )
label_text = (lot_no or b).strip() label_text = (lot_no or b).strip()
if continuous: if continuous:


BIN
python/__pycache__/Bag3.cpython-313.pyc Wyświetl plik


+ 48
- 0
src/main/java/com/ffii/fpsms/m18/entity/M18BomShopSyncLog.kt Wyświetl plik

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

+ 12
- 0
src/main/java/com/ffii/fpsms/m18/entity/M18BomShopSyncLogRepository.kt Wyświetl plik

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

import com.ffii.core.support.AbstractRepository

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

+ 96
- 0
src/main/java/com/ffii/fpsms/m18/model/M18BomForShopSaveRequest.kt Wyświetl plik

@@ -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,
/** PO supplier [com.ffii.fpsms.modules.master.entity.Shop.m18Id] (via `purchase_order.supplierId`) for latest PO line matching material [com.ffii.fpsms.modules.master.entity.Items.code]. */
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,
)

+ 14
- 0
src/main/java/com/ffii/fpsms/m18/model/M18BomShopSyncTriggerResult.kt Wyświetl plik

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

+ 394
- 0
src/main/java/com/ffii/fpsms/m18/service/M18BomForShopService.kt Wyświetl plik

@@ -0,0 +1,394 @@
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.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.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 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
private val m18Tz: ZoneId = ZoneId.of("Asia/Hong_Kong")

private fun formatBomShopHeaderCode(itemCode: String, version: Int): String =
"BOM${itemCode}V${version.toString().padStart(3, '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 V000 vs V001+.
*/
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}Vnnn`.
*/
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 lines = bom.bomMaterials
.filter { it.deleted != true }
.sortedBy { it.id ?: 0L }
.mapIndexedNotNull { idx, mat -> toProductLine(mat, idx + 1) }

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) ?: "000"
return Triple(codeForUpdate, forcedRev, forcedId)
}

val latestLog = m18BomShopSyncLogRepository.findFirstByBomIdOrderByIdDesc(bomId)
val prevFp = resolveLogFingerprint(latestLog)
val prevCodeTrimmed = latestLog?.m18HeaderCode?.trim().orEmpty()
val samePayload = latestLog != null && prevFp != null && prevFp == fp && prevCodeTrimmed.isNotEmpty()

if (samePayload) {
val revReuse = parseTrailingVersion(prevCodeTrimmed) ?: "000"
return Triple(prevCodeTrimmed, revReuse, bomM18Id)
}

val maxV = maxVersionFromLogs(bomId, itemCode)
val nextV = maxV + 1
val newCode = formatBomShopHeaderCode(itemCode, nextV)
val rev = nextV.toString().padStart(3, '0')
return Triple(newCode, rev, null)
}

private fun resolveLogFingerprint(log: M18BomShopSyncLog?): String? {
if (log == null) return null
log.requestFingerprint?.takeIf { it.isNotBlank() }?.let { return it }
val json = log.requestJson ?: return null
return runCatching {
val prev = objectMapper.readValue(json, M18BomForShopSaveRequest::class.java)
contentFingerprint(prev)
}.getOrNull()
}

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 Vnnn.
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)?.padStart(3, '0')

/**
* 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): 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 ->
purchaseOrderLineRepository.findLatestLinesForBomM18ByItemId(id, PageRequest.of(0, 1)).firstOrNull()
}
val itemCode = mat.item?.code?.trim()?.takeIf { it.isNotEmpty() }
val supplierM18Id = itemCode?.let { code ->
purchaseOrderLineRepository.findLatestPoSupplierM18IdByItemCodeNative(code)
.firstOrNull()
?.takeIf { it > 0L }
}
/**
* 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,
)
}

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
}

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

+ 6
- 6
src/main/java/com/ffii/fpsms/m18/service/M18DeliveryOrderService.kt Wyświetl plik

@@ -154,20 +154,20 @@ open class M18DeliveryOrderService(


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


/** /**
* Sync a single M18 shop PO / delivery order by document [code], same search pattern as * Sync a single M18 shop PO / delivery order by document [code], same search pattern as
* [com.ffii.fpsms.m18.service.M18PurchaseOrderService.savePurchaseOrderByCode]. * [com.ffii.fpsms.m18.service.M18PurchaseOrderService.savePurchaseOrderByCode].
* *
* @param isEtraSync when true, persist local `delivery_order.isEtra=true` (manual DO(加單) sync).
* @param isExtraSync when true, persist local `delivery_order.isExtra=true` (manual DO(加單) sync).
* No M18-side "加單" filtering is used. * No M18-side "加單" filtering is used.
* @param newOnly when true, skip if a non-deleted local DO already exists with the same `code`. * @param newOnly when true, skip if a non-deleted local DO already exists with the same `code`.
*/ */
open fun saveDeliveryOrderByCode( open fun saveDeliveryOrderByCode(
code: String, code: String,
isEtraSync: Boolean = false,
isExtraSync: Boolean = false,
newOnly: Boolean = false, newOnly: Boolean = false,
): SyncResult { ): SyncResult {
if (newOnly && deliveryOrderRepository.existsByCodeAndDeletedIsFalse(code)) { if (newOnly && deliveryOrderRepository.existsByCodeAndDeletedIsFalse(code)) {
@@ -210,12 +210,12 @@ open class M18DeliveryOrderService(
query = conds query = conds
) )


return saveDeliveryOrdersWithPreparedList(prepared, syncIsEtra = isEtraSync)
return saveDeliveryOrdersWithPreparedList(prepared, syncisExtra = isExtraSync)
} }


private fun saveDeliveryOrdersWithPreparedList( private fun saveDeliveryOrdersWithPreparedList(
deliveryOrdersWithType: M18PurchaseOrderListResponseWithType?, deliveryOrdersWithType: M18PurchaseOrderListResponseWithType?,
syncIsEtra: Boolean = false,
syncisExtra: Boolean = false,
): SyncResult { ): SyncResult {
logger.info("--------------------------------------------Start - Saving M18 Delivery Order--------------------------------------------") logger.info("--------------------------------------------Start - Saving M18 Delivery Order--------------------------------------------")


@@ -303,7 +303,7 @@ open class M18DeliveryOrderService(
handlerId = null, handlerId = null,
m18BeId = mainpo.beId, m18BeId = mainpo.beId,
deleted = mainpo.udfIsVoid == true, deleted = mainpo.udfIsVoid == true,
isEtra = syncIsEtra,
isExtra = syncisExtra,
) )


val saveDeliveryOrderResponse = val saveDeliveryOrderResponse =


+ 14
- 4
src/main/java/com/ffii/fpsms/m18/web/M18TestController.kt Wyświetl plik

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


@@ -65,6 +67,14 @@ class M18TestController (
return schedulerService.getM18Pos(); 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") @GetMapping("/test/po-by-code")
fun testSyncPoByCode(@RequestParam code: String): SyncResult { fun testSyncPoByCode(@RequestParam code: String): SyncResult {
return m18PurchaseOrderService.savePurchaseOrderByCode(code) return m18PurchaseOrderService.savePurchaseOrderByCode(code)
@@ -72,14 +82,14 @@ class M18TestController (


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


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


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


+ 1
- 1
src/main/java/com/ffii/fpsms/modules/bag/service/bagService.kt Wyświetl plik

@@ -29,7 +29,7 @@ open class BagService(
) { ) {
open fun createBagLotLinesByBagId(request: CreateBagLotLineRequest): MessageResponse { open fun createBagLotLinesByBagId(request: CreateBagLotLineRequest): MessageResponse {
val bag = bagRepository.findById(request.bagId).orElse(null) 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 BaseUnitOfMeasure= itemUomRepository.findByItemIdAndStockUnitIsTrueAndDeletedIsFalse(request.itemId)
val baseRatioN = BaseUnitOfMeasure?.ratioN ?: BigDecimal.ONE val baseRatioN = BaseUnitOfMeasure?.ratioN ?: BigDecimal.ONE
println("baseRatioN: $baseRatioN") println("baseRatioN: $baseRatioN")


+ 81
- 49
src/main/java/com/ffii/fpsms/modules/chart/service/ChartService.kt Wyświetl plik

@@ -40,27 +40,28 @@ open class ChartService(


/** /**
* Delivery orders: order count and total line qty by date. * 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>> { fun getDeliveryOrderByDate(startDate: LocalDate?, endDate: LocalDate?): List<Map<String, Any>> {
val args = mutableMapOf<String, Any>() val args = mutableMapOf<String, Any>()
val startSql = if (startDate != null) { val startSql = if (startDate != null) {
args["startDate"] = startDate.toString() args["startDate"] = startDate.toString()
"AND DATE(COALESCE(do.completeDate, do.estimatedArrivalDate, do.orderDate)) >= :startDate"
"AND DATE(do.estimatedArrivalDate) >= :startDate"
} else "" } else ""
val endSql = if (endDate != null) { val endSql = if (endDate != null) {
args["endDate"] = endDate.toString() args["endDate"] = endDate.toString()
"AND DATE(COALESCE(do.completeDate, do.estimatedArrivalDate, do.orderDate)) <= :endDate"
"AND DATE(do.estimatedArrivalDate) <= :endDate"
} else "" } else ""
val sql = """ val sql = """
SELECT 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, COUNT(DISTINCT do.id) AS orderCount,
COALESCE(SUM(dol.qty), 0) AS totalQty COALESCE(SUM(dol.qty), 0) AS totalQty
FROM delivery_order do FROM delivery_order do
LEFT JOIN delivery_order_line dol ON dol.deliveryOrderId = do.id AND dol.deleted = 0 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 $startSql $endSql
GROUP BY DATE(do.estimatedArrivalDate)
ORDER BY date ORDER BY date
""".trimIndent() """.trimIndent()
return jdbcDao.queryForList(sql, args) return jdbcDao.queryForList(sql, args)
@@ -529,17 +530,32 @@ open class ChartService(
* Stock in vs stock out by date. * Stock in vs stock out by date.
* Stock in: stock_in_line.acceptedQty, date from stock_in.completeDate or receiptDate/created. * 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. * 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>> { fun getStockInOutByDate(startDate: LocalDate?, endDate: LocalDate?): List<Map<String, Any>> {
val args = mutableMapOf<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 ""
if (startDate != null) args["startDate"] = startDate.toString()
if (endDate != null) args["endDate"] = endDate.toString()
val inDateFilter = buildString {
if (startDate != null) {
append(" AND DATE(COALESCE(si.completeDate, sil.receiptDate, si.created)) >= :startDate")
}
if (endDate != null) {
append(" AND DATE(COALESCE(si.completeDate, sil.receiptDate, si.created)) <= :endDate")
}
}
val outDateFilter = buildString {
if (startDate != null) {
append(" AND DATE(COALESCE(so.completeDate, so.created)) >= :startDate")
}
if (endDate != null) {
append(" AND DATE(COALESCE(so.completeDate, so.created)) <= :endDate")
}
}
val startSql = if (startDate != null) "AND u.dt >= :startDate" else ""
val endSql = if (endDate != null) "AND u.dt <= :endDate" else ""
val sql = """ val sql = """
SELECT DATE_FORMAT(u.dt, '%Y-%m-%d') AS date, SELECT DATE_FORMAT(u.dt, '%Y-%m-%d') AS date,
COALESCE(SUM(u.inQty), 0) AS inQty, COALESCE(SUM(u.inQty), 0) AS inQty,
@@ -547,16 +563,16 @@ open class ChartService(
FROM ( FROM (
SELECT DATE(COALESCE(si.completeDate, sil.receiptDate, si.created)) AS dt, SELECT DATE(COALESCE(si.completeDate, sil.receiptDate, si.created)) AS dt,
SUM(COALESCE(sil.acceptedQty, 0)) AS inQty, 0 AS outQty 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
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(COALESCE(si.completeDate, sil.receiptDate, si.created)) GROUP BY DATE(COALESCE(si.completeDate, sil.receiptDate, si.created))
UNION ALL UNION ALL
SELECT DATE(COALESCE(so.completeDate, so.created)) AS dt, SELECT DATE(COALESCE(so.completeDate, so.created)) AS dt,
0 AS inQty, SUM(COALESCE(sol.qty, 0)) AS outQty 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
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(COALESCE(so.completeDate, so.created)) GROUP BY DATE(COALESCE(so.completeDate, so.created))
) u ) u
WHERE 1=1 $startSql $endSql WHERE 1=1 $startSql $endSql
@@ -568,23 +584,25 @@ open class ChartService(


/** /**
* Distinct items that appear in delivery_order_line in the period (for multi-select options). * 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>> { fun getTopDeliveryItemsItemOptions(startDate: LocalDate?, endDate: LocalDate?): List<Map<String, Any>> {
val args = mutableMapOf<String, Any>() val args = mutableMapOf<String, Any>()
val startSql = if (startDate != null) { val startSql = if (startDate != null) {
args["startDate"] = startDate.toString() args["startDate"] = startDate.toString()
"AND DATE(COALESCE(do.completeDate, do.estimatedArrivalDate, do.orderDate)) >= :startDate"
"AND DATE(do.estimatedArrivalDate) >= :startDate"
} else "" } else ""
val endSql = if (endDate != null) { val endSql = if (endDate != null) {
args["endDate"] = endDate.toString() args["endDate"] = endDate.toString()
"AND DATE(COALESCE(do.completeDate, do.estimatedArrivalDate, do.orderDate)) <= :endDate"
"AND DATE(do.estimatedArrivalDate) <= :endDate"
} else "" } else ""
val sql = """ val sql = """
SELECT DISTINCT it.code AS itemCode, COALESCE(it.name, '') AS itemName 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 $startSql $endSql
ORDER BY it.code ORDER BY it.code
""".trimIndent() """.trimIndent()
return jdbcDao.queryForList(sql, args) return jdbcDao.queryForList(sql, args)
@@ -592,6 +610,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). * 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( fun getTopDeliveryItems(
startDate: LocalDate?, startDate: LocalDate?,
@@ -602,11 +622,11 @@ open class ChartService(
val args = mutableMapOf<String, Any>("limit" to limit) val args = mutableMapOf<String, Any>("limit" to limit)
val startSql = if (startDate != null) { val startSql = if (startDate != null) {
args["startDate"] = startDate.toString() args["startDate"] = startDate.toString()
"AND DATE(COALESCE(do.completeDate, do.estimatedArrivalDate, do.orderDate)) >= :startDate"
"AND DATE(do.estimatedArrivalDate) >= :startDate"
} else "" } else ""
val endSql = if (endDate != null) { val endSql = if (endDate != null) {
args["endDate"] = endDate.toString() args["endDate"] = endDate.toString()
"AND DATE(COALESCE(do.completeDate, do.estimatedArrivalDate, do.orderDate)) <= :endDate"
"AND DATE(do.estimatedArrivalDate) <= :endDate"
} else "" } else ""
val itemSql = if (!itemCodes.isNullOrEmpty()) { val itemSql = if (!itemCodes.isNullOrEmpty()) {
val codes = itemCodes.map { it.trim() }.filter { it.isNotBlank() } val codes = itemCodes.map { it.trim() }.filter { it.isNotBlank() }
@@ -620,10 +640,10 @@ open class ChartService(
it.code AS itemCode, it.code AS itemCode,
it.name AS itemName, it.name AS itemName,
SUM(COALESCE(dol.qty, 0)) AS totalQty 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 $startSql $endSql $itemSql
GROUP BY dol.itemId, it.code, it.name GROUP BY dol.itemId, it.code, it.name
ORDER BY totalQty DESC ORDER BY totalQty DESC
LIMIT :limit LIMIT :limit
@@ -721,23 +741,27 @@ open class ChartService(


/** /**
* Staff delivery performance: daily pick ticket count and total time per staff. * 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). * 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).
*/ */
fun getStaffDeliveryPerformance( fun getStaffDeliveryPerformance(
startDate: LocalDate?, startDate: LocalDate?,
endDate: LocalDate?, endDate: LocalDate?,
staffNos: List<String>?
staffNos: List<String>?,
storeId: String?,
storeIdNull: Boolean?,
): List<Map<String, Any>> { ): List<Map<String, Any>> {
val args = mutableMapOf<String, Any>() val args = mutableMapOf<String, Any>()
val startSql = if (startDate != null) { val startSql = if (startDate != null) {
args["startDate"] = startDate.toString() args["startDate"] = startDate.toString()
"AND DATE(dpor.ticketCompleteDateTime) >= :startDate"
"AND DATE(dop.ticketCompleteDateTime) >= :startDate"
} else "" } else ""
val endSql = if (endDate != null) { val endSql = if (endDate != null) {
args["endDate"] = endDate.toString() args["endDate"] = endDate.toString()
"AND DATE(dpor.ticketCompleteDateTime) <= :endDate"
"AND DATE(dop.ticketCompleteDateTime) <= :endDate"
} else "" } else ""
val staffSql = if (!staffNos.isNullOrEmpty()) { val staffSql = if (!staffNos.isNullOrEmpty()) {
val nos = staffNos.map { it.trim() }.filter { it.isNotBlank() } val nos = staffNos.map { it.trim() }.filter { it.isNotBlank() }
@@ -746,25 +770,33 @@ open class ChartService(
"AND u.staffNo IN (:staffNos)" "AND u.staffNo IN (:staffNos)"
} }
} else "" } else ""
val storeSql = when {
storeIdNull == true -> "AND dop.storeId IS NULL"
!storeId.isNullOrBlank() -> {
args["filterStoreId"] = storeId.trim()
"AND dop.storeId = :filterStoreId"
}
else -> ""
}
val sql = """ val sql = """
SELECT 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( COALESCE(SUM(
CASE 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 ELSE 0
END END
), 0) AS totalMinutes ), 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
FROM delivery_order_pick_order dop
LEFT JOIN user u ON dop.handledBy = u.id AND u.deleted = 0
WHERE dop.deleted = 0
AND LOWER(COALESCE(dop.ticketStatus, '')) = 'completed'
AND dop.ticketCompleteDateTime IS NOT NULL
$startSql $endSql $staffSql $storeSql
GROUP BY DATE(dop.ticketCompleteDateTime), dop.handledBy, u.name, dop.handlerName
ORDER BY date, orderCount DESC ORDER BY date, orderCount DESC
""".trimIndent() """.trimIndent()
return jdbcDao.queryForList(sql, args) return jdbcDao.queryForList(sql, args)


+ 11
- 5
src/main/java/com/ffii/fpsms/modules/chart/web/ChartController.kt Wyświetl plik

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


/** /**
* GET /chart/delivery-order-by-date?startDate=&endDate= * 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") @GetMapping("/delivery-order-by-date")
fun getDeliveryOrderByDate( fun getDeliveryOrderByDate(
@@ -129,7 +129,7 @@ class ChartController(


/** /**
* GET /chart/stock-in-out-by-date?startDate=&endDate= * 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") @GetMapping("/stock-in-out-by-date")
fun getStockInOutByDate( fun getStockInOutByDate(
@@ -140,6 +140,7 @@ class ChartController(
/** /**
* GET /chart/top-delivery-items-item-options?startDate=&endDate= * GET /chart/top-delivery-items-item-options?startDate=&endDate=
* Returns [{ itemCode, itemName }] — distinct items in delivery lines in the period (for multi-select). * 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") @GetMapping("/top-delivery-items-item-options")
fun getTopDeliveryItemsItemOptions( fun getTopDeliveryItemsItemOptions(
@@ -150,6 +151,7 @@ class ChartController(
/** /**
* GET /chart/top-delivery-items?startDate=&endDate=&limit=20&itemCode=A&itemCode=B * 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). * 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") @GetMapping("/top-delivery-items")
fun getTopDeliveryItems( fun getTopDeliveryItems(
@@ -192,16 +194,20 @@ class ChartController(
chartService.getStaffDeliveryPerformanceHandlers() 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") @GetMapping("/staff-delivery-performance")
fun getStaffDeliveryPerformance( fun getStaffDeliveryPerformance(
@RequestParam(required = false) @DateTimeFormat(iso = DateTimeFormat.ISO.DATE) startDate: LocalDate?, @RequestParam(required = false) @DateTimeFormat(iso = DateTimeFormat.ISO.DATE) startDate: LocalDate?,
@RequestParam(required = false) @DateTimeFormat(iso = DateTimeFormat.ISO.DATE) endDate: LocalDate?, @RequestParam(required = false) @DateTimeFormat(iso = DateTimeFormat.ISO.DATE) endDate: LocalDate?,
@RequestParam(required = false) staffNo: List<String>?, @RequestParam(required = false) staffNo: List<String>?,
@RequestParam(required = false) storeId: String?,
@RequestParam(required = false) storeIdNull: Boolean?,
): List<Map<String, Any>> = ): List<Map<String, Any>> =
chartService.getStaffDeliveryPerformance(startDate, endDate, staffNo)
chartService.getStaffDeliveryPerformance(startDate, endDate, staffNo, storeId, storeIdNull)


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




+ 10
- 0
src/main/java/com/ffii/fpsms/modules/common/SettingNames.java Wyświetl plik

@@ -41,6 +41,11 @@ public abstract class SettingNames {
*/ */
public static final String M18_UNITS_SYNC_INITIAL_FULL_SYNC_DONE = "M18.units.sync.initialFullSyncDone"; 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) */ /** 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"; public static final String SCHEDULE_POST_COMPLETED_DN_GRN = "SCHEDULE.postCompletedDn.grn";


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


public static final String SCHEDULE_PROD_DETAILED = "SCHEDULE.prod.detailed"; 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 * Mail settings
*/ */


+ 56
- 30
src/main/java/com/ffii/fpsms/modules/common/scheduler/service/SchedulerService.kt Wyświetl plik

@@ -10,6 +10,7 @@ import com.ffii.fpsms.m18.entity.SchedulerSyncLog
import com.ffii.fpsms.m18.entity.SchedulerSyncLogRepository import com.ffii.fpsms.m18.entity.SchedulerSyncLogRepository
import com.ffii.fpsms.m18.model.SyncResult import com.ffii.fpsms.m18.model.SyncResult
import com.ffii.fpsms.modules.common.SettingNames import com.ffii.fpsms.modules.common.SettingNames
import com.ffii.fpsms.modules.jobOrder.service.JobOrderPlanStartAutoService
import com.ffii.fpsms.modules.master.service.ProductionScheduleService import com.ffii.fpsms.modules.master.service.ProductionScheduleService
import com.ffii.fpsms.modules.stock.service.SearchCompletedDnService import com.ffii.fpsms.modules.stock.service.SearchCompletedDnService
import com.ffii.fpsms.modules.stock.service.InventoryLotLineService import com.ffii.fpsms.modules.stock.service.InventoryLotLineService
@@ -42,6 +43,7 @@ open class SchedulerService(
@Value("\${scheduler.inventoryLotExpiry.enabled:true}") val inventoryLotExpiryEnabled: Boolean, @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. */ /** 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.m18Sync.enabled:false}") val m18SyncEnabled: Boolean,
@Value("\${scheduler.jo.planStart.enabled:true}") val jobOrderPlanStartAutoEnabled: Boolean,
val settingsService: SettingsService, val settingsService: SettingsService,
/** /**
* Lookback window for GRN code sync: rows with `created` from **start of (today − N days)** through **now**, * Lookback window for GRN code sync: rows with `created` from **start of (today − N days)** through **now**,
@@ -56,7 +58,16 @@ open class SchedulerService(
val searchCompletedDnService: SearchCompletedDnService, val searchCompletedDnService: SearchCompletedDnService,
val m18GrnCodeSyncService: M18GrnCodeSyncService, val m18GrnCodeSyncService: M18GrnCodeSyncService,
val inventoryLotLineService: InventoryLotLineService, val inventoryLotLineService: InventoryLotLineService,
val jobOrderPlanStartAutoService: JobOrderPlanStartAutoService,
) { ) {
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 * * *"
/** Daily 00:00:15 — process job orders whose planStart was yesterday. */
const val JO_PLAN_START_DEFAULT_CRON: String = "15 0 0 * * *"
}

var logger: Logger = LoggerFactory.getLogger(JwtTokenUtil::class.java) var logger: Logger = LoggerFactory.getLogger(JwtTokenUtil::class.java)
val dataStringFormat = DateTimeFormatter.ofPattern("yyyy-MM-dd") val dataStringFormat = DateTimeFormatter.ofPattern("yyyy-MM-dd")
val dateTimeStringFormat = DateTimeFormatter.ofPattern("yyyy-MM-dd HH:mm:ss") val dateTimeStringFormat = DateTimeFormatter.ofPattern("yyyy-MM-dd HH:mm:ss")
@@ -80,6 +91,8 @@ open class SchedulerService(
var scheduledGrnCodeSync: ScheduledFuture<*>? = null var scheduledGrnCodeSync: ScheduledFuture<*>? = null
var scheduledInventoryLotExpiry: ScheduledFuture<*>? = null var scheduledInventoryLotExpiry: ScheduledFuture<*>? = null


var scheduledJobOrderPlanStart: ScheduledFuture<*>? = null

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


@@ -169,6 +182,7 @@ open class SchedulerService(
schedulePostCompletedDnGrn(); schedulePostCompletedDnGrn();
scheduleGrnCodeSync(); scheduleGrnCodeSync();
scheduleInventoryLotExpiry(); scheduleInventoryLotExpiry();
scheduleJobOrderPlanStartAuto();
//scheduleRoughProd(); //scheduleRoughProd();
//scheduleDetailedProd(); //scheduleDetailedProd();
} }
@@ -206,7 +220,7 @@ open class SchedulerService(
logger.info("M18 DO2 scheduler disabled (scheduler.m18Sync.enabled=false)") logger.info("M18 DO2 scheduler disabled (scheduler.m18Sync.enabled=false)")
return return
} }
scheduledM18Do2 = commonSchedule(scheduledM18Do2, SettingNames.SCHEDULE_M18_DO2, ::getM18Dos2)
scheduledM18Do2 = commonSchedule(scheduledM18Do2, SettingNames.SCHEDULE_M18_DO2, DO2_DEFAULT_CRON, ::getM18Dos2)
} }


fun scheduleM18MasterData() { fun scheduleM18MasterData() {
@@ -286,6 +300,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. */ /** Mark expired inventory lot lines as unavailable daily. Set scheduler.inventoryLotExpiry.enabled=false to disable. */
fun scheduleInventoryLotExpiry() { fun scheduleInventoryLotExpiry() {
if (!inventoryLotExpiryEnabled) { if (!inventoryLotExpiryEnabled) {
@@ -455,7 +505,7 @@ open class SchedulerService(
val ysd = today.minusDays(1L) val ysd = today.minusDays(1L)
val tmr = today.plusDays(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 // 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). // (otherwise Sat 03:00–18:59 would be skipped until a much later sync).
val isSundayDo2 = runDate.dayOfWeek == DayOfWeek.SUNDAY val isSundayDo2 = runDate.dayOfWeek == DayOfWeek.SUNDAY
@@ -465,21 +515,21 @@ open class SchedulerService(
ysd.withHour(19).withMinute(0).withSecond(0) 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( logger.info(
"DO2 modifiedDateFrom={} ({}), modifiedDateTo={}", "DO2 modifiedDateFrom={} ({}), modifiedDateTo={}",
modifiedFromStart.format(dateTimeStringFormat), modifiedFromStart.format(dateTimeStringFormat),
if (isSundayDo2) "Sunday window from Sat 03:00" else "from yesterday 19:00", if (isSundayDo2) "Sunday window from Sat 03:00" else "from yesterday 19:00",
todayEleven.format(dateTimeStringFormat),
modifiedDateToEnd.format(dateTimeStringFormat),
) )


val requestDO = M18CommonRequest( val requestDO = M18CommonRequest(
// These will now produce "yyyy-MM-dd HH:mm:ss" // These will now produce "yyyy-MM-dd HH:mm:ss"
dDateTo = tmr.format(dateTimeStringFormat), // e.g. 2026-01-19 00:00:00 dDateTo = tmr.format(dateTimeStringFormat), // e.g. 2026-01-19 00:00:00
dDateFrom = 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), modifiedDateFrom = modifiedFromStart.format(dateTimeStringFormat),
) )
@@ -491,30 +541,6 @@ open class SchedulerService(
result = result, result = result,
start = currentTime start = currentTime
) )

// Extra DO sync window: after DO2, also sync ETA = today or tomorrow (normal sync; does NOT set isEtra).
try {
val extraStart = LocalDateTime.now()
val requestExtra = M18CommonRequest(
dDateFrom = today.format(dateTimeStringFormat),
dDateTo = tmr.format(dateTimeStringFormat),
)
val extraResult = m18DeliveryOrderService.saveDeliveryOrders(requestExtra)
saveSyncLog(
type = "DO2_EXTRA",
status = "SUCCESS",
result = extraResult,
start = extraStart,
)
} catch (e: Exception) {
logger.error("DO2_EXTRA sync failed: ${e.message}", e)
saveSyncLog(
type = "DO2_EXTRA",
status = "FAIL",
error = e.message,
start = LocalDateTime.now(),
)
}
} }


open fun getPostCompletedDnAndProcessGrn( open fun getPostCompletedDnAndProcessGrn(


+ 5
- 0
src/main/java/com/ffii/fpsms/modules/common/scheduler/web/SchedulerController.kt Wyświetl plik

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

+ 2
- 2
src/main/java/com/ffii/fpsms/modules/deliveryOrder/entity/DeliveryOrder.kt Wyświetl plik

@@ -64,6 +64,6 @@ open class DeliveryOrder: BaseEntity<Long>() {
open var m18BeId: Long? = null open var m18BeId: Long? = null


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

+ 4
- 4
src/main/java/com/ffii/fpsms/modules/deliveryOrder/entity/DeliveryOrderRepository.kt Wyświetl plik

@@ -111,7 +111,7 @@ fun searchDoLite(
and (:status is null or d.status = :status) and (:status is null or d.status = :status)
and (:etaStart is null or d.estimatedArrivalDate >= :etaStart) and (:etaStart is null or d.estimatedArrivalDate >= :etaStart)
and (:etaEnd is null or d.estimatedArrivalDate < :etaEnd) and (:etaEnd is null or d.estimatedArrivalDate < :etaEnd)
and (:isEtra is null or d.isEtra = :isEtra)
and (:isExtra is null or d.isExtra = :isExtra)
order by d.id desc order by d.id desc
""") """)
fun searchDoLitePage( fun searchDoLitePage(
@@ -120,7 +120,7 @@ fun searchDoLitePage(
@Param("status") status: DeliveryOrderStatus?, @Param("status") status: DeliveryOrderStatus?,
@Param("etaStart") etaStart: LocalDateTime?, @Param("etaStart") etaStart: LocalDateTime?,
@Param("etaEnd") etaEnd: LocalDateTime?, @Param("etaEnd") etaEnd: LocalDateTime?,
@Param("isEtra") isEtra: Boolean?,
@Param("isExtra") isExtra: Boolean?,
pageable: Pageable pageable: Pageable
): Page<DeliveryOrderInfoLite> ): Page<DeliveryOrderInfoLite>


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


+ 3
- 3
src/main/java/com/ffii/fpsms/modules/deliveryOrder/entity/models/DeliveryOrderInfo.kt Wyświetl plik

@@ -48,8 +48,8 @@ interface DeliveryOrderInfoLite {
@get:Value("#{target.shop?.addr3}") @get:Value("#{target.shop?.addr3}")
val shopAddress: String? val shopAddress: String?


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

+ 52
- 82
src/main/java/com/ffii/fpsms/modules/deliveryOrder/service/DeliveryOrderService.kt Wyświetl plik

@@ -90,7 +90,6 @@ import com.ffii.fpsms.modules.stock.entity.InventoryLotLine
import com.ffii.fpsms.modules.stock.entity.projection.StockOutLineInfo import com.ffii.fpsms.modules.stock.entity.projection.StockOutLineInfo
import java.util.Locale import java.util.Locale
import org.slf4j.Logger import org.slf4j.Logger

@Service @Service
open class DeliveryOrderService( open class DeliveryOrderService(
private val deliveryOrderRepository: DeliveryOrderRepository, private val deliveryOrderRepository: DeliveryOrderRepository,
@@ -121,23 +120,23 @@ open class DeliveryOrderService(
private val doPickOrderLineRepository: DoPickOrderLineRepository, private val doPickOrderLineRepository: DoPickOrderLineRepository,
private val doPickOrderLineRecordRepository: DoPickOrderLineRecordRepository, private val doPickOrderLineRecordRepository: DoPickOrderLineRecordRepository,
private val itemsRepository: ItemsRepository, private val itemsRepository: ItemsRepository,
private val doFloorSupplierSettingsService: DoFloorSupplierSettingsService,
) { ) {
/** /**
* 樓層篩選:2F → P07/P06D(車線- 族);4F → P06B(P06B_ 族);全部 → 三者
* 車線-X 仍屬該 DO 的 supplier,故 P06B+車線-X 不會出現在 2F,P06D+車線-X 不會出現在 4F
* 樓層篩選:2F/4F/ALL 由 [DoFloorSupplierSettingsService] 讀 `settings`
* 車線-X 仍依 DO supplier 所屬樓層出現在對應 tab
*/ */
private fun allowedSupplierCodesForFloor(floor: String?): List<String> {
val f = floor?.trim()?.uppercase(Locale.ROOT).orEmpty()
if (f.isEmpty() || f == "ALL" || f == "All") {
return listOf("P06B", "P07", "P06D")
}
return when (f) {
"2F" -> listOf("P07", "P06D")
"4F" -> listOf("P06B")
else -> listOf("P06B", "P07", "P06D")
}
}
private fun allowedSupplierCodesForFloor(floor: String?): List<String> =
doFloorSupplierSettingsService.allowedSupplierCodesForFloor(floor)

private fun loadDoFloorSupplierLists(): Pair<List<String>, List<String>> =
doFloorSupplierSettingsService.loadDoFloorSupplierLists()


private fun preferredStoreFloorForSupplier(
supplierCode: String?,
suppliers2F: List<String>,
suppliers4F: List<String>,
): String = doFloorSupplierSettingsService.preferredStoreFloorForSupplier(supplierCode, suppliers2F, suppliers4F)
open fun searchDoLiteByPage( open fun searchDoLiteByPage(
code: String?, code: String?,
shopName: String?, shopName: String?,
@@ -147,7 +146,7 @@ open class DeliveryOrderService(
pageSize: Int?, pageSize: Int?,
truckLanceCode: String?, truckLanceCode: String?,
floor: String? = null, floor: String? = null,
isEtra: Boolean? = null,
isExtra: Boolean? = null,
): RecordsRes<DeliveryOrderInfoLiteDto> { ): RecordsRes<DeliveryOrderInfoLiteDto> {


val page = (pageNum ?: 1) - 1 val page = (pageNum ?: 1) - 1
@@ -169,7 +168,7 @@ open class DeliveryOrderService(
status = statusEnum, status = statusEnum,
etaStart = etaStart, etaStart = etaStart,
etaEnd = etaEnd, etaEnd = etaEnd,
isEtra = isEtra,
isExtra = isExtra,
allowedSupplierCodes = allowedForFloor, allowedSupplierCodes = allowedForFloor,
pageable = PageRequest.of(0, 100_000), pageable = PageRequest.of(0, 100_000),
) )
@@ -181,6 +180,7 @@ open class DeliveryOrderService(
.associateBy { it.id } .associateBy { it.id }


val preFilteredContent = allResult.content val preFilteredContent = allResult.content
val (floorSuppliers2F, floorSuppliers4F) = loadDoFloorSupplierLists()


// ✅ 优化3: 收集所有需要查询的 shopId 和日期组合(只处理预过滤后的记录) // ✅ 优化3: 收集所有需要查询的 shopId 和日期组合(只处理预过滤后的记录)
val shopIdAndDatePairs = preFilteredContent.mapNotNull { info -> val shopIdAndDatePairs = preFilteredContent.mapNotNull { info ->
@@ -191,11 +191,7 @@ open class DeliveryOrderService(
val targetDate = estimatedArrivalDate.toLocalDate() val targetDate = estimatedArrivalDate.toLocalDate()
val dayAbbr = getDayOfWeekAbbr(targetDate) val dayAbbr = getDayOfWeekAbbr(targetDate)
val supplierCode = deliveryOrder.supplier?.code val supplierCode = deliveryOrder.supplier?.code
val preferredFloor = when (supplierCode) {
"P06B" -> "4F"
"P07", "P06D" -> "2F"
else -> "2F" // 或者改成 null / 其他默认值,看你业务需要
}
val preferredFloor = preferredStoreFloorForSupplier(supplierCode, floorSuppliers2F, floorSuppliers4F)
Triple(shopId, preferredFloor, dayAbbr) Triple(shopId, preferredFloor, dayAbbr)
} else { } else {
null null
@@ -217,11 +213,7 @@ open class DeliveryOrderService(
val processedRecords = preFilteredContent.map { info -> val processedRecords = preFilteredContent.map { info ->
val deliveryOrder = deliveryOrdersMap[info.id] val deliveryOrder = deliveryOrdersMap[info.id]
val supplierCode = deliveryOrder?.supplier?.code val supplierCode = deliveryOrder?.supplier?.code
val preferredFloor = when (supplierCode) {
"P06B" -> "4F"
"P07", "P06D" -> "2F"
else -> "2F"
}
val preferredFloor = preferredStoreFloorForSupplier(supplierCode, floorSuppliers2F, floorSuppliers4F)
val shop = deliveryOrder?.shop val shop = deliveryOrder?.shop
val shopId = shop?.id val shopId = shop?.id
val estimatedArrivalDate = info.estimatedArrivalDate val estimatedArrivalDate = info.estimatedArrivalDate
@@ -248,7 +240,7 @@ open class DeliveryOrderService(
supplierName = info.supplierName, supplierName = info.supplierName,
shopAddress = info.shopAddress, shopAddress = info.shopAddress,
truckLanceCode = calculatedTruckLanceCode, truckLanceCode = calculatedTruckLanceCode,
isEtra = deliveryOrdersMap[info.id]?.isEtra ?: info.isEtra,
isExtra = deliveryOrdersMap[info.id]?.isExtra ?: info.isExtra,
) )
}.filter { dto -> }.filter { dto ->
val dtoTruckLanceCode = dto.truckLanceCode?.lowercase() ?: "" val dtoTruckLanceCode = dto.truckLanceCode?.lowercase() ?: ""
@@ -279,19 +271,16 @@ open class DeliveryOrderService(
status = statusEnum, status = statusEnum,
etaStart = etaStart, etaStart = etaStart,
etaEnd = etaEnd, etaEnd = etaEnd,
isEtra = isEtra,
isExtra = isExtra,
allowedSupplierCodes = allowedSupplierCodes, allowedSupplierCodes = allowedSupplierCodes,
pageable = PageRequest.of(page.coerceAtLeast(0), size), pageable = PageRequest.of(page.coerceAtLeast(0), size),
) )


val (floorSuppliers2F, floorSuppliers4F) = loadDoFloorSupplierLists()
val records = result.content.map { info -> val records = result.content.map { info ->
val deliveryOrder = deliveryOrderRepository.findByIdAndDeletedIsFalse(info.id) val deliveryOrder = deliveryOrderRepository.findByIdAndDeletedIsFalse(info.id)
val supplierCode = deliveryOrder?.supplier?.code val supplierCode = deliveryOrder?.supplier?.code
val preferredFloor = when (supplierCode) {
"P06B" -> "4F"
"P07", "P06D" -> "2F"
else -> "2F"
}
val preferredFloor = preferredStoreFloorForSupplier(supplierCode, floorSuppliers2F, floorSuppliers4F)
val shop = deliveryOrder?.shop val shop = deliveryOrder?.shop
val shopId = shop?.id val shopId = shop?.id
val estimatedArrivalDate = info.estimatedArrivalDate val estimatedArrivalDate = info.estimatedArrivalDate
@@ -315,7 +304,7 @@ open class DeliveryOrderService(
supplierName = info.supplierName, supplierName = info.supplierName,
shopAddress = info.shopAddress, shopAddress = info.shopAddress,
truckLanceCode = calculatedTruckLanceCode, truckLanceCode = calculatedTruckLanceCode,
isEtra = deliveryOrder?.isEtra ?: info.isEtra,
isExtra = deliveryOrder?.isExtra ?: info.isExtra,
) )
} }


@@ -338,7 +327,7 @@ open class DeliveryOrderService(
pageSize: Int?, pageSize: Int?,
truckLanceCode: String?, truckLanceCode: String?,
floor: String? = null, floor: String? = null,
isEtra: Boolean? = null,
isExtra: Boolean? = null,
): RecordsRes<DeliveryOrderInfoLiteDto> { ): RecordsRes<DeliveryOrderInfoLiteDto> {
val mode = TruckLaneSearchSpec.parse(truckLanceCode) val mode = TruckLaneSearchSpec.parse(truckLanceCode)
if (mode is TruckLaneSearchSpec.Mode.NoFilter) { if (mode is TruckLaneSearchSpec.Mode.NoFilter) {
@@ -351,7 +340,7 @@ open class DeliveryOrderService(
pageSize, pageSize,
null, null,
floor, floor,
isEtra,
isExtra,
) )
} }
val pageIdx = (pageNum ?: 1).coerceAtLeast(1) - 1 val pageIdx = (pageNum ?: 1).coerceAtLeast(1) - 1
@@ -367,7 +356,7 @@ open class DeliveryOrderService(
statusEnum = statusEnum, statusEnum = statusEnum,
etaStart = etaStart, etaStart = etaStart,
etaEnd = etaEnd, etaEnd = etaEnd,
isEtra = isEtra,
isExtra = isExtra,
allowedSupplierCodes = allowedSupplierCodesForFloor(floor), allowedSupplierCodes = allowedSupplierCodesForFloor(floor),
lanePredicate = lanePredicate, lanePredicate = lanePredicate,
) )
@@ -391,7 +380,7 @@ open class DeliveryOrderService(
pageNum: Int?, pageNum: Int?,
pageSize: Int?, pageSize: Int?,
floor: String? = null, floor: String? = null,
isEtra: Boolean? = null,
isExtra: Boolean? = null,
): RecordsRes<DeliveryOrderInfoLiteDto> { ): RecordsRes<DeliveryOrderInfoLiteDto> {
val page = (pageNum ?: 1) - 1 val page = (pageNum ?: 1) - 1
val size = pageSize ?: 10 val size = pageSize ?: 10
@@ -406,22 +395,19 @@ open class DeliveryOrderService(
status = statusEnum, status = statusEnum,
etaStart = etaStart, etaStart = etaStart,
etaEnd = etaEnd, etaEnd = etaEnd,
isEtra = isEtra,
isExtra = isExtra,
allowedSupplierCodes = allowedSupplierCodes, allowedSupplierCodes = allowedSupplierCodes,
pageable = PageRequest.of(0, 100_000), pageable = PageRequest.of(0, 100_000),
) )


val deliveryOrderIds = allResult.content.mapNotNull { it.id } val deliveryOrderIds = allResult.content.mapNotNull { it.id }
val deliveryOrdersMap = deliveryOrderRepository.findAllById(deliveryOrderIds).associateBy { it.id } val deliveryOrdersMap = deliveryOrderRepository.findAllById(deliveryOrderIds).associateBy { it.id }
val (floorSuppliers2F, floorSuppliers4F) = loadDoFloorSupplierLists()


val processedRecords = allResult.content.map { info -> val processedRecords = allResult.content.map { info ->
val deliveryOrder = deliveryOrdersMap[info.id] val deliveryOrder = deliveryOrdersMap[info.id]
val supplierCode = deliveryOrder?.supplier?.code val supplierCode = deliveryOrder?.supplier?.code
val preferredFloor = when (supplierCode) {
"P06B" -> "4F"
"P07", "P06D" -> "2F"
else -> "2F"
}
val preferredFloor = preferredStoreFloorForSupplier(supplierCode, floorSuppliers2F, floorSuppliers4F)
val shop = deliveryOrder?.shop val shop = deliveryOrder?.shop
val shopId = shop?.id val shopId = shop?.id
val infoEta = info.estimatedArrivalDate val infoEta = info.estimatedArrivalDate
@@ -445,7 +431,7 @@ open class DeliveryOrderService(
supplierName = info.supplierName, supplierName = info.supplierName,
shopAddress = info.shopAddress, shopAddress = info.shopAddress,
truckLanceCode = calculatedTruckLanceCode, truckLanceCode = calculatedTruckLanceCode,
isEtra = deliveryOrdersMap[info.id]?.isEtra ?: info.isEtra,
isExtra = deliveryOrdersMap[info.id]?.isExtra ?: info.isExtra,
) )
}.filter { dto -> TruckLaneSearchSpec.isUnassignedResolvedLane(dto.truckLanceCode) } }.filter { dto -> TruckLaneSearchSpec.isUnassignedResolvedLane(dto.truckLanceCode) }


@@ -487,7 +473,7 @@ open class DeliveryOrderService(
estimatedArrivalDate = deliveryOrder.estimatedArrivalDate, estimatedArrivalDate = deliveryOrder.estimatedArrivalDate,
completeDate = deliveryOrder.completeDate, completeDate = deliveryOrder.completeDate,
status = deliveryOrder.status?.value, status = deliveryOrder.status?.value,
isEtra = deliveryOrder.isEtra,
isExtra = deliveryOrder.isExtra,
deliveryOrderLines = deliveryOrder.deliveryOrderLines.map { line -> deliveryOrderLines = deliveryOrder.deliveryOrderLines.map { line ->
DoDetailLineResponse( DoDetailLineResponse(
id = line.id!!, id = line.id!!,
@@ -808,7 +794,7 @@ open class DeliveryOrderService(
this.handler = handler this.handler = handler
m18BeId = request.m18BeId m18BeId = request.m18BeId
this.deleted = request.deleted this.deleted = request.deleted
isEtra = request.isEtra ?: false
isExtra = request.isExtra ?: false
} }


val savedDeliveryOrder = deliveryOrderRepository.saveAndFlush(deliveryOrder).let { val savedDeliveryOrder = deliveryOrderRepository.saveAndFlush(deliveryOrder).let {
@@ -948,14 +934,10 @@ open class DeliveryOrderService(


println(" DEBUG: Target date: $targetDate, Date prefix: $datePrefix") println(" DEBUG: Target date: $targetDate, Date prefix: $datePrefix")
// 新逻辑:根据 supplier code 决定楼层
// 如果 supplier code 是 "P06B",使用 4F,否则使用 2F
// 新逻辑:根据 supplier code 决定楼层(清單來自 settings)
val supplierCode = deliveryOrder.supplier?.code val supplierCode = deliveryOrder.supplier?.code
val preferredFloor = when (supplierCode) {
"P06B" -> "4F"
"P07", "P06D" -> "2F"
else -> "2F" // 或者改成 null / 其他默认值,看你业务需要
}
val (floorSuppliers2F, floorSuppliers4F) = loadDoFloorSupplierLists()
val preferredFloor = preferredStoreFloorForSupplier(supplierCode, floorSuppliers2F, floorSuppliers4F)


println(" DEBUG: Supplier code: $supplierCode, Preferred floor: $preferredFloor") println(" DEBUG: Supplier code: $supplierCode, Preferred floor: $preferredFloor")


@@ -1839,15 +1821,11 @@ val inventoryLotLine = illId?.let { inventoryLotLineMap[it] }
} }
} }


// 新逻辑:根据 supplier code 决定楼层
// 如果 supplier code 是 "P06B",使用 4F,否则使用 2F
// 新逻辑:根据 supplier code 决定楼层(清單來自 settings)
val targetDate = deliveryOrder.estimatedArrivalDate?.toLocalDate() ?: LocalDate.now() val targetDate = deliveryOrder.estimatedArrivalDate?.toLocalDate() ?: LocalDate.now()
val supplierCode = deliveryOrder.supplier?.code val supplierCode = deliveryOrder.supplier?.code
val preferredFloor = when (supplierCode) {
"P06B" -> "4F"
"P07", "P06D" -> "2F"
else -> "2F" // 或者改成 null / 其他默认值,看你业务需要
}
val (floorSuppliers2F, floorSuppliers4F) = loadDoFloorSupplierLists()
val preferredFloor = preferredStoreFloorForSupplier(supplierCode, floorSuppliers2F, floorSuppliers4F)


println(" DEBUG: Floor calculation for DO ${deliveryOrder.id}") println(" DEBUG: Floor calculation for DO ${deliveryOrder.id}")
println(" - Supplier code: $supplierCode") println(" - Supplier code: $supplierCode")
@@ -1936,7 +1914,8 @@ val inventoryLotLine = illId?.let { inventoryLotLineMap[it] }
truckDepartureTime = effectiveTruck.departureTime, truckDepartureTime = effectiveTruck.departureTime,
truckLanceCode = effectiveTruck.truckLanceCode, truckLanceCode = effectiveTruck.truckLanceCode,
loadingSequence = effectiveTruck.loadingSequence, loadingSequence = effectiveTruck.loadingSequence,
usedDefaultTruck = usedDefaultTruck
usedDefaultTruck = usedDefaultTruck,
isExtra = deliveryOrder.isExtra ?: false,


) )
} }
@@ -2022,11 +2001,8 @@ val inventoryLotLine = illId?.let { inventoryLotLineMap[it] }
// Truck selection (reuse normal logic) // Truck selection (reuse normal logic)
val targetDate = deliveryOrder.estimatedArrivalDate?.toLocalDate() ?: LocalDate.now() val targetDate = deliveryOrder.estimatedArrivalDate?.toLocalDate() ?: LocalDate.now()
val supplierCode = deliveryOrder.supplier?.code val supplierCode = deliveryOrder.supplier?.code
val preferredFloor = when (supplierCode) {
"P06B" -> "4F"
"P07", "P06D" -> "2F"
else -> "2F"
}
val (floorSuppliers2F, floorSuppliers4F) = loadDoFloorSupplierLists()
val preferredFloor = preferredStoreFloorForSupplier(supplierCode, floorSuppliers2F, floorSuppliers4F)


val truck = deliveryOrder.shop?.id?.let { shopId -> val truck = deliveryOrder.shop?.id?.let { shopId ->
val trucks = truckRepository.findByShopIdAndDeletedFalse(shopId) val trucks = truckRepository.findByShopIdAndDeletedFalse(shopId)
@@ -2094,7 +2070,8 @@ val inventoryLotLine = illId?.let { inventoryLotLineMap[it] }
truckDepartureTime = effectiveTruck.departureTime, truckDepartureTime = effectiveTruck.departureTime,
truckLanceCode = effectiveTruck.truckLanceCode, truckLanceCode = effectiveTruck.truckLanceCode,
loadingSequence = effectiveTruck.loadingSequence, loadingSequence = effectiveTruck.loadingSequence,
usedDefaultTruck = usedDefaultTruck
usedDefaultTruck = usedDefaultTruck,
isExtra = deliveryOrder.isExtra ?: false,
) )
} }


@@ -2104,7 +2081,7 @@ val inventoryLotLine = illId?.let { inventoryLotLineMap[it] }
statusEnum: DeliveryOrderStatus?, statusEnum: DeliveryOrderStatus?,
etaStart: LocalDateTime?, etaStart: LocalDateTime?,
etaEnd: LocalDateTime?, etaEnd: LocalDateTime?,
isEtra: Boolean?,
isExtra: Boolean?,
allowedSupplierCodes: List<String>, allowedSupplierCodes: List<String>,
lanePredicate: (String?) -> Boolean, lanePredicate: (String?) -> Boolean,
): List<DeliveryOrderInfoLiteDto> { ): List<DeliveryOrderInfoLiteDto> {
@@ -2118,7 +2095,7 @@ val inventoryLotLine = illId?.let { inventoryLotLineMap[it] }
status = statusEnum, status = statusEnum,
etaStart = etaStart, etaStart = etaStart,
etaEnd = etaEnd, etaEnd = etaEnd,
isEtra = isEtra,
isExtra = isExtra,
allowedSupplierCodes = allowedSupplierCodes, allowedSupplierCodes = allowedSupplierCodes,
pageable = PageRequest.of(dbPage, 500), pageable = PageRequest.of(dbPage, 500),
) )
@@ -2140,6 +2117,7 @@ val inventoryLotLine = illId?.let { inventoryLotLineMap[it] }
val ids = rows.mapNotNull { it.id } val ids = rows.mapNotNull { it.id }
if (ids.isEmpty()) return emptyList() if (ids.isEmpty()) return emptyList()
val deliveryOrdersMap = deliveryOrderRepository.findAllById(ids).associateBy { it.id } val deliveryOrdersMap = deliveryOrderRepository.findAllById(ids).associateBy { it.id }
val (floorSuppliers2F, floorSuppliers4F) = loadDoFloorSupplierLists()


val shopIdAndDatePairs = rows.mapNotNull { info -> val shopIdAndDatePairs = rows.mapNotNull { info ->
val d = deliveryOrdersMap[info.id] val d = deliveryOrdersMap[info.id]
@@ -2149,11 +2127,7 @@ val inventoryLotLine = illId?.let { inventoryLotLineMap[it] }
val targetDate = eta.toLocalDate() val targetDate = eta.toLocalDate()
val dayAbbr = getDayOfWeekAbbr(targetDate) val dayAbbr = getDayOfWeekAbbr(targetDate)
val supplierCode = d.supplier?.code val supplierCode = d.supplier?.code
val preferredFloor = when (supplierCode) {
"P06B" -> "4F"
"P07", "P06D" -> "2F"
else -> "2F"
}
val preferredFloor = preferredStoreFloorForSupplier(supplierCode, floorSuppliers2F, floorSuppliers4F)
Triple(shopId, preferredFloor, dayAbbr) Triple(shopId, preferredFloor, dayAbbr)
} else { } else {
null null
@@ -2169,11 +2143,7 @@ val inventoryLotLine = illId?.let { inventoryLotLineMap[it] }
return rows.map { info -> return rows.map { info ->
val deliveryOrder = deliveryOrdersMap[info.id] val deliveryOrder = deliveryOrdersMap[info.id]
val supplierCode = deliveryOrder?.supplier?.code val supplierCode = deliveryOrder?.supplier?.code
val preferredFloor = when (supplierCode) {
"P06B" -> "4F"
"P07", "P06D" -> "2F"
else -> "2F"
}
val preferredFloor = preferredStoreFloorForSupplier(supplierCode, floorSuppliers2F, floorSuppliers4F)
val shopId = deliveryOrder?.shop?.id val shopId = deliveryOrder?.shop?.id
val infoEta = info.estimatedArrivalDate val infoEta = info.estimatedArrivalDate
val calculatedTruckLanceCode = val calculatedTruckLanceCode =
@@ -2194,14 +2164,14 @@ val inventoryLotLine = illId?.let { inventoryLotLineMap[it] }
supplierName = info.supplierName, supplierName = info.supplierName,
shopAddress = info.shopAddress, shopAddress = info.shopAddress,
truckLanceCode = calculatedTruckLanceCode, truckLanceCode = calculatedTruckLanceCode,
isEtra = deliveryOrder?.isEtra ?: info.isEtra,
isExtra = deliveryOrder?.isExtra ?: info.isExtra,
) )
} }
} }


/** /**
* 依店鋪 + 揀貨樓層解析當日應顯示之車線。 * 依店鋪 + 揀貨樓層解析當日應顯示之車線。
* - **2F**(P07/P06D):`TruckLanceCode` 多為 `車線-…` 且不含星期縮寫,不依 `LIKE '%Mon%'` 篩選;取該店該樓層未刪除車輛中出發時間最早者。
* - **2F**(P07/P06D/P06Y):`TruckLanceCode` 多為 `車線-…` 且不含星期縮寫,不依 `LIKE '%Mon%'` 篩選;取該店該樓層未刪除車輛中出發時間最早者。
* - **4F**(P06B):維持以星期縮寫篩選 [TruckRepository.findByShopIdAndStoreIdAndDayOfWeek];無命中時再退回同樓層最早出發。 * - **4F**(P06B):維持以星期縮寫篩選 [TruckRepository.findByShopIdAndStoreIdAndDayOfWeek];無命中時再退回同樓層最早出發。
*/ */
private fun resolveTruckForShopFloorAndDay( private fun resolveTruckForShopFloorAndDay(


+ 95
- 0
src/main/java/com/ffii/fpsms/modules/deliveryOrder/service/DoFloorSupplierSettingsService.kt Wyświetl plik

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

+ 7
- 20
src/main/java/com/ffii/fpsms/modules/deliveryOrder/service/DoReleaseCoordinatorService.kt Wyświetl plik

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


+ 11
- 0
src/main/java/com/ffii/fpsms/modules/deliveryOrder/service/DoWorkbenchDopoAssignmentService.kt Wyświetl plik

@@ -144,6 +144,9 @@ open class DoWorkbenchDopoAssignmentService(
sql.append(" AND dop.loadingSequence = :loadingSequence ") sql.append(" AND dop.loadingSequence = :loadingSequence ")
params["loadingSequence"] = request.loadingSequence params["loadingSequence"] = request.loadingSequence
} }
if (isisExtraReleaseType(request.releaseType)) {
sql.append(" AND LOWER(COALESCE(dop.releaseType, '')) = 'isExtra' ")
}
// Fetch a batch of candidates and try atomic-assign sequentially. // 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. // This avoids forcing the frontend to refresh when a single picked candidate is concurrently assigned.
val candidateLimit = 50 val candidateLimit = 50
@@ -247,6 +250,9 @@ open class DoWorkbenchDopoAssignmentService(
sql.append(" AND dop.loadingSequence = :loadingSequence ") sql.append(" AND dop.loadingSequence = :loadingSequence ")
params["loadingSequence"] = request.loadingSequence params["loadingSequence"] = request.loadingSequence
} }
if (isisExtraReleaseType(request.releaseType)) {
sql.append(" AND LOWER(COALESCE(dop.releaseType, '')) = 'isExtra' ")
}
val shouldOrderBySequenceV1 = actualStoreId == "2/F" && request.loadingSequence == null val shouldOrderBySequenceV1 = actualStoreId == "2/F" && request.loadingSequence == null
if (shouldOrderBySequenceV1) { if (shouldOrderBySequenceV1) {
sql.append(" ORDER BY dop.requiredDeliveryDate ASC, dop.truckDepartureTime ASC, dop.loadingSequence ASC, dop.id ASC LIMIT 1 ") sql.append(" ORDER BY dop.requiredDeliveryDate ASC, dop.truckDepartureTime ASC, dop.loadingSequence ASC, dop.id ASC LIMIT 1 ")
@@ -301,6 +307,11 @@ open class DoWorkbenchDopoAssignmentService(
} else null } else null
} }


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

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


+ 178
- 10
src/main/java/com/ffii/fpsms/modules/deliveryOrder/service/DoWorkbenchMainService.kt Wyświetl plik

@@ -1,3 +1,4 @@

package com.ffii.fpsms.modules.deliveryOrder.service package com.ffii.fpsms.modules.deliveryOrder.service


import com.ffii.core.support.JdbcDao import com.ffii.core.support.JdbcDao
@@ -54,6 +55,7 @@ import java.time.format.DateTimeFormatter
import com.ffii.fpsms.modules.deliveryOrder.web.models.StoreLaneSummary import com.ffii.fpsms.modules.deliveryOrder.web.models.StoreLaneSummary
import com.ffii.fpsms.modules.deliveryOrder.web.models.LaneRow import com.ffii.fpsms.modules.deliveryOrder.web.models.LaneRow
import com.ffii.fpsms.modules.deliveryOrder.web.models.LaneBtn import com.ffii.fpsms.modules.deliveryOrder.web.models.LaneBtn
import com.ffii.fpsms.modules.deliveryOrder.web.models.WorkbenchEtraShopLaneGroup
import com.ffii.fpsms.modules.deliveryOrder.web.models.ReleasedDoPickOrderListItem import com.ffii.fpsms.modules.deliveryOrder.web.models.ReleasedDoPickOrderListItem
import com.ffii.fpsms.modules.deliveryOrder.web.models.WorkbenchTicketReleaseTableResponse import com.ffii.fpsms.modules.deliveryOrder.web.models.WorkbenchTicketReleaseTableResponse
import com.ffii.fpsms.modules.user.service.UserService import com.ffii.fpsms.modules.user.service.UserService
@@ -670,6 +672,7 @@ return MessageResponse(
val releaseFilterClause = when (rt) { val releaseFilterClause = when (rt) {
"batch" -> " AND LOWER(COALESCE(dop.releaseType, '')) = 'batch' " "batch" -> " AND LOWER(COALESCE(dop.releaseType, '')) = 'batch' "
"single" -> " AND LOWER(COALESCE(dop.releaseType, '')) = 'single' " "single" -> " AND LOWER(COALESCE(dop.releaseType, '')) = 'single' "
"isExtra" -> " AND LOWER(COALESCE(dop.releaseType, '')) = 'isExtra' "
else -> "" else -> ""
} }
val sql = """ val sql = """
@@ -812,6 +815,7 @@ return MessageResponse(
unassigned = it.unassigned, unassigned = it.unassigned,
total = it.total, total = it.total,
handlerName = it.handlerName, handlerName = it.handlerName,
storeId = actualStoreId,
) )
} }
.sortedWith( .sortedWith(
@@ -853,24 +857,181 @@ return MessageResponse(
) )
} }


/**
* Workbench Etra view: all `delivery_order_pick_order` with `releaseType` = isExtra (case-insensitive),
* for one [requiredDeliveryDate], grouped by shop then by truck / time / loading sequence.
*/
open fun getWorkbenchEtraLaneSummary(requiredDate: LocalDate?): List<WorkbenchEtraShopLaneGroup> {
val targetDate = requiredDate ?: LocalDate.now()
val defaultTruck = truckRepository.findById(5577L).orElse(null)
val defaultTruckLaneCode = defaultTruck?.truckLanceCode ?: ""

val sql = """
SELECT
dop.shopCode AS shopCode,
dop.shopName AS shopName,
dop.storeId AS storeId,
dop.truckDepartureTime AS truckDepartureTime,
dop.truckLanceCode AS truckLanceCode,
dop.loadingSequence AS loadingSequence,
COUNT(DISTINCT dop.id) AS total_cnt,
SUM(CASE WHEN dop.handledBy IS NULL THEN 1 ELSE 0 END) AS unassigned_cnt,
GROUP_CONCAT(
DISTINCT NULLIF(TRIM(dop.handlerName), '')
ORDER BY dop.handlerName
SEPARATOR ', '
) AS handler_names
FROM fpsmsdb.delivery_order_pick_order dop
WHERE dop.deleted = 0
AND LOWER(COALESCE(dop.releaseType, '')) = 'isExtra'
AND dop.requiredDeliveryDate = :requiredDate
AND dop.ticketStatus IN ('pending', 'released')
AND EXISTS (
SELECT 1 FROM fpsmsdb.pick_order po
WHERE po.deliveryOrderPickOrderId = dop.id AND po.deleted = 0
)
GROUP BY dop.shopCode, dop.shopName, dop.storeId, dop.truckDepartureTime, dop.truckLanceCode, dop.loadingSequence
""".trimIndent()

val rawRows: List<Map<String, Any?>> = try {
jdbcDao.queryForList(sql, mapOf("requiredDate" to targetDate))
} catch (e: Exception) {
println("❌ getWorkbenchEtraLaneSummary: ${e.message}")
emptyList()
}

fun cellStr(row: Map<String, Any?>, name: String): String? {
val k = row.keys.find { it.equals(name, true) } ?: return null
return row[k]?.toString()?.trim()?.takeIf { it.isNotEmpty() }
}
fun cellNum(row: Map<String, Any?>, vararg names: String): Int {
for (n in names) {
val k = row.keys.find { it.equals(n, true) } ?: continue
(row[k] as? Number)?.toInt()?.let { return it }
}
return 0
}
fun cellNullableInt(row: Map<String, Any?>, vararg names: String): Int? {
for (n in names) {
val k = row.keys.find { it.equals(n, true) } ?: continue
val v = row[k] ?: continue
when (v) {
is Number -> return v.toInt()
is String -> v.trim().toIntOrNull()?.let { return it }
}
}
return null
}

data class EtraAgg(
val shopCode: String?,
val shopName: String?,
val storeId: String?,
val sortTime: LocalTime,
val lance: String,
val loadingSequence: Int?,
val unassigned: Int,
val total: Int,
val handlerName: String?,
)

val aggs = rawRows.mapNotNull { row ->
val lance = cellStr(row, "truckLanceCode") ?: return@mapNotNull null
if (lance == defaultTruckLaneCode) return@mapNotNull null
val storeIdCol = cellStr(row, "storeId")
val ttKey = row.keys.find { it.equals("truckDepartureTime", true) }
val ttVal = ttKey?.let { row[it] }
val sortTime = when (ttVal) {
null -> LocalTime.MIDNIGHT
is java.sql.Time -> ttVal.toLocalTime()
is LocalTime -> ttVal
is java.sql.Timestamp -> ttVal.toLocalDateTime().toLocalTime()
else -> runCatching { LocalTime.parse(ttVal.toString().take(8)) }.getOrNull()
?: runCatching { LocalTime.parse(ttVal.toString()) }.getOrNull()
?: LocalTime.MIDNIGHT
}
val loadingSeq = cellNullableInt(row, "loadingSequence")
val unassigned = cellNum(row, "unassigned_cnt", "unassignedCnt")
val total = cellNum(row, "total_cnt", "totalCnt")
if (total <= 0) return@mapNotNull null
EtraAgg(
shopCode = cellStr(row, "shopCode"),
shopName = cellStr(row, "shopName"),
storeId = storeIdCol,
sortTime = sortTime,
lance = lance,
loadingSequence = loadingSeq,
unassigned = unassigned,
total = total,
handlerName = cellStr(row, "handler_names"),
)
}

val byShop = aggs.groupBy { a ->
listOf(a.shopCode ?: "", a.shopName ?: "").joinToString("|")
}

return byShop.entries
.map { (key, group) ->
val head = group.first()
val lanes = group
.sortedWith(
compareBy<EtraAgg> { it.sortTime }
.thenBy { it.lance }
.thenBy { it.loadingSequence ?: 999 }
)
.map {
val is4F = it.storeId?.replace("/", "")?.trim()?.equals("4F", ignoreCase = true) == true
LaneBtn(
truckLanceCode = it.lance,
loadingSequence = if (is4F) it.loadingSequence else null,
unassigned = it.unassigned,
total = it.total,
handlerName = it.handlerName,
storeId = it.storeId,
truckDepartureTime = it.sortTime.toString(),
)
}
WorkbenchEtraShopLaneGroup(
shopCode = head.shopCode,
shopName = head.shopName,
lanes = lanes,
)
}
.sortedWith(
compareBy<WorkbenchEtraShopLaneGroup> { it.shopName ?: it.shopCode ?: "" }
.thenBy { it.shopCode ?: "" }
)
}

open fun findWorkbenchReleasedDeliveryOrderPickOrdersForSelection( open fun findWorkbenchReleasedDeliveryOrderPickOrdersForSelection(
shopName: String?, shopName: String?,
storeId: String?, storeId: String?,
truck: String?, truck: String?,
releaseTypeFilter: String? = null,
): List<ReleasedDoPickOrderListItem> = ): List<ReleasedDoPickOrderListItem> =
queryWorkbenchReleasedDopoList(shopName, storeId, truck, beforeToday = true)
queryWorkbenchReleasedDopoList(shopName, storeId, truck, beforeToday = true, releaseTypeFilter = releaseTypeFilter)


/** /**
* @param requiredDeliveryDate when null, uses [LocalDate.now] (calendar today). * @param requiredDeliveryDate when null, uses [LocalDate.now] (calendar today).
* When set, filters `dop.requiredDeliveryDate = :targetDate` (workbench date picker / select day). * When set, filters `dop.requiredDeliveryDate = :targetDate` (workbench date picker / select day).
* @param releaseTypeFilter when `isExtra` (case-insensitive), only `delivery_order_pick_order.releaseType = isExtra` rows.
*/ */
open fun findWorkbenchReleasedDeliveryOrderPickOrdersForSelectionToday( open fun findWorkbenchReleasedDeliveryOrderPickOrdersForSelectionToday(
shopName: String?, shopName: String?,
storeId: String?, storeId: String?,
truck: String?, truck: String?,
requiredDeliveryDate: LocalDate? = null, requiredDeliveryDate: LocalDate? = null,
releaseTypeFilter: String? = null,
): List<ReleasedDoPickOrderListItem> = ): List<ReleasedDoPickOrderListItem> =
queryWorkbenchReleasedDopoList(shopName, storeId, truck, beforeToday = false, equalsDeliveryDate = requiredDeliveryDate)
queryWorkbenchReleasedDopoList(
shopName,
storeId,
truck,
beforeToday = false,
equalsDeliveryDate = requiredDeliveryDate,
releaseTypeFilter = releaseTypeFilter,
)


/** /**
* Workbench completed tickets: query `delivery_order_pick_order` where `ticketStatus = completed`. * Workbench completed tickets: query `delivery_order_pick_order` where `ticketStatus = completed`.
@@ -1362,7 +1523,11 @@ return MessageResponse(
dop.deliveryNoteCode = CodeGenerator.generateNo(prefix = prefix, midfix = midfix, latestCode = latestCode) dop.deliveryNoteCode = CodeGenerator.generateNo(prefix = prefix, midfix = midfix, latestCode = latestCode)
} }
deliveryOrderPickOrderRepository.save(dop) deliveryOrderPickOrderRepository.save(dop)
<<<<<<< HEAD


=======
>>>>>>> e9f1f48edb57d3696af3ffb23bc40d9644c8c44f
} }
markDeliveryOrdersCompletedForDeliveryOrderPickOrder(deliveryOrderPickOrderId) markDeliveryOrdersCompletedForDeliveryOrderPickOrder(deliveryOrderPickOrderId)
return MessageResponse( return MessageResponse(
@@ -1469,6 +1634,7 @@ return MessageResponse(
truck: String?, truck: String?,
beforeToday: Boolean, beforeToday: Boolean,
equalsDeliveryDate: LocalDate? = null, equalsDeliveryDate: LocalDate? = null,
releaseTypeFilter: String? = null,
): List<ReleasedDoPickOrderListItem> { ): List<ReleasedDoPickOrderListItem> {
val today = LocalDate.now() val today = LocalDate.now()
val params = mutableMapOf<String, Any>() val params = mutableMapOf<String, Any>()
@@ -1519,6 +1685,10 @@ return MessageResponse(
sqlBuilder.append(" AND (dop.shopName LIKE :shopPat OR dop.shopCode LIKE :shopPat) ") sqlBuilder.append(" AND (dop.shopName LIKE :shopPat OR dop.shopCode LIKE :shopPat) ")
params["shopPat"] = "%${shopName.trim()}%" params["shopPat"] = "%${shopName.trim()}%"
} }
val rtNorm = releaseTypeFilter?.trim()?.lowercase().orEmpty()
if (rtNorm == "isExtra") {
sqlBuilder.append(" AND LOWER(COALESCE(dop.releaseType, '')) = 'isExtra' ")
}
sqlBuilder.append(" ORDER BY dop.requiredDeliveryDate, dop.truckDepartureTime, dop.truckLanceCode, dop.id ") sqlBuilder.append(" ORDER BY dop.requiredDeliveryDate, dop.truckDepartureTime, dop.truckLanceCode, dop.id ")
val rows: List<Map<String, Any?>> = try { val rows: List<Map<String, Any?>> = try {
jdbcDao.queryForList(sqlBuilder.toString(), params) jdbcDao.queryForList(sqlBuilder.toString(), params)
@@ -1913,6 +2083,7 @@ return MessageResponse(
tryCompleteDeliveryOrderPickOrderTicketCompleted(poId) tryCompleteDeliveryOrderPickOrderTicketCompleted(poId)
} }
} }

private fun registerAfterCommit(action: () -> Unit) { private fun registerAfterCommit(action: () -> Unit) {
if (!TransactionSynchronizationManager.isSynchronizationActive()) { if (!TransactionSynchronizationManager.isSynchronizationActive()) {
action() action()
@@ -2048,6 +2219,7 @@ return MessageResponse(
) )
} }
} }

private fun postWorkbenchPickSideEffects(savedStockOutLine: StockOutLine, deltaQty: BigDecimal, createLedger: Boolean = true) { private fun postWorkbenchPickSideEffects(savedStockOutLine: StockOutLine, deltaQty: BigDecimal, createLedger: Boolean = true) {
if (deltaQty <= BigDecimal.ZERO) return if (deltaQty <= BigDecimal.ZERO) return
val wall0 = System.nanoTime() val wall0 = System.nanoTime()
@@ -2230,9 +2402,10 @@ return MessageResponse(
throw last ?: RuntimeException("Failed to complete pick order after retries (poId=$poId)") throw last ?: RuntimeException("Failed to complete pick order after retries (poId=$poId)")
} }


/**
/**
* Workbench completion: if all pick_orders under the same delivery_order_pick_order are completed, * Workbench completion: if all pick_orders under the same delivery_order_pick_order are completed,
* update ONLY delivery_order_pick_order.ticketStatus (no do_pick_order/do_pick_order_line records).
* update delivery_order_pick_order.ticketStatus and related delivery_order.status → completed.
* Does not create do_pick_order / do_pick_order_line records.
*/ */
private fun tryCompleteDeliveryOrderPickOrderTicketCompleted(poId: Long) { private fun tryCompleteDeliveryOrderPickOrderTicketCompleted(poId: Long) {
val dopRow = jdbcDao.queryForMap( val dopRow = jdbcDao.queryForMap(
@@ -2307,7 +2480,6 @@ return MessageResponse(
deliveryOrderRepository.save(deliveryOrder) deliveryOrderRepository.save(deliveryOrder)
} }
} }

private fun checkWorkbenchPickOrderLineCompleted(pickOrderLineId: Long, allStockOutLines: List<StockOutLineInfo>) { private fun checkWorkbenchPickOrderLineCompleted(pickOrderLineId: Long, allStockOutLines: List<StockOutLineInfo>) {
val pol = pickOrderLineRepository.findById(pickOrderLineId).orElse(null) ?: return val pol = pickOrderLineRepository.findById(pickOrderLineId).orElse(null) ?: return
if (pol.status == PickOrderLineStatus.COMPLETED) return if (pol.status == PickOrderLineStatus.COMPLETED) return
@@ -2745,11 +2917,7 @@ return MessageResponse(
} }
} }


/**
* Carton label reprint for workbench: [request.doPickOrderId] is [delivery_order_pick_order.id],
* same as [getWorkbenchPrintContext]. Legacy [DeliveryOrderService.printDNLabelsReprint] expects
* [do_pick_order_record.recordId] and must not be used here.
*/

private fun exportDNLabelsReprintWorkbench(request: PrintDNLabelsReprintRequest): Map<String, Any> { private fun exportDNLabelsReprintWorkbench(request: PrintDNLabelsReprintRequest): Map<String, Any> {
validateWorkbenchCartonReprintRange( validateWorkbenchCartonReprintRange(
fromCarton = request.fromCarton, fromCarton = request.fromCarton,


+ 20
- 9
src/main/java/com/ffii/fpsms/modules/deliveryOrder/service/DoWorkbenchReleaseService.kt Wyświetl plik

@@ -359,14 +359,17 @@ open class DoWorkbenchReleaseService(
} }


/** /**
* `TI-B-yyyyMMdd-2F-001` (batch) or `TI-S-yyyyMMdd-2F-001` (single), same suffix rules as [DoReleaseCoordinatorService] / legacy `do_pick_order`.
* `TI-B-yyyyMMdd-2F-001` (batch), `TI-S-yyyyMMdd-2F-001` (single), or `TI-E-yyyyMMdd-2F-001` (Etra),
* same suffix rules as [DoReleaseCoordinatorService] / legacy `do_pick_order`.
*/ */
private fun nextDeliveryOrderPickOrderTicketNo( private fun nextDeliveryOrderPickOrderTicketNo(
requiredDate: LocalDate, requiredDate: LocalDate,
storeDisplay: String, storeDisplay: String,
ticketLetter: String, ticketLetter: String,
): String { ): String {
require(ticketLetter == "B" || ticketLetter == "S") { "ticketLetter must be B or S" }
require(ticketLetter == "B" || ticketLetter == "S" || ticketLetter == "E") {
"ticketLetter must be B, S or E"
}
val ymd = requiredDate.format(DateTimeFormatter.ofPattern("yyyyMMdd")) val ymd = requiredDate.format(DateTimeFormatter.ofPattern("yyyyMMdd"))
val floor = storeDisplay.replace("/", "").trim() val floor = storeDisplay.replace("/", "").trim()
val prefix = "TI-$ticketLetter-$ymd-$floor-" val prefix = "TI-$ticketLetter-$ymd-$floor-"
@@ -397,6 +400,9 @@ open class DoWorkbenchReleaseService(
private fun nextDeliveryOrderPickOrderSingleTicketNo(requiredDate: LocalDate, storeDisplay: String): String = private fun nextDeliveryOrderPickOrderSingleTicketNo(requiredDate: LocalDate, storeDisplay: String): String =
nextDeliveryOrderPickOrderTicketNo(requiredDate, storeDisplay, "S") nextDeliveryOrderPickOrderTicketNo(requiredDate, storeDisplay, "S")


private fun nextDeliveryOrderPickOrderEtraTicketNo(requiredDate: LocalDate, storeDisplay: String): String =
nextDeliveryOrderPickOrderTicketNo(requiredDate, storeDisplay, "E")

private fun asyncJobType(useV2: Boolean, dopReleaseType: String): String { private fun asyncJobType(useV2: Boolean, dopReleaseType: String): String {
val single = dopReleaseType.equals("single", ignoreCase = true) val single = dopReleaseType.equals("single", ignoreCase = true)
return when { return when {
@@ -440,11 +446,6 @@ open class DoWorkbenchReleaseService(
): Int { ): Int {
if (results.isEmpty()) return 0 if (results.isEmpty()) return 0


val releaseTypeCol = when (dopReleaseType.lowercase()) {
"single" -> "single"
else -> "batch"
}

val grouped = results.groupBy { val grouped = results.groupBy {
listOf( listOf(
it.shopId?.toString() ?: "", it.shopId?.toString() ?: "",
@@ -452,7 +453,8 @@ open class DoWorkbenchReleaseService(
it.preferredFloor, it.preferredFloor,
it.truckId?.toString() ?: "", it.truckId?.toString() ?: "",
it.truckDepartureTime?.toString() ?: "", it.truckDepartureTime?.toString() ?: "",
it.truckLanceCode ?: ""
it.truckLanceCode ?: "",
it.isExtra.toString(),
).joinToString("|") ).joinToString("|")
} }


@@ -477,7 +479,16 @@ open class DoWorkbenchReleaseService(
(storeId ?: "2/F").replace("/", "").trim() (storeId ?: "2/F").replace("/", "").trim()
} }
val requiredDate = first.estimatedArrivalDate ?: LocalDate.now() val requiredDate = first.estimatedArrivalDate ?: LocalDate.now()
val tempTicket = if (releaseTypeCol == "single") {
val releaseTypeCol = if (first.isExtra) {
"isExtra"
} else if (dopReleaseType.equals("single", ignoreCase = true)) {
"single"
} else {
"batch"
}
val tempTicket = if (first.isExtra) {
nextDeliveryOrderPickOrderEtraTicketNo(requiredDate, ticketFloorSegment)
} else if (releaseTypeCol == "single") {
nextDeliveryOrderPickOrderSingleTicketNo(requiredDate, ticketFloorSegment) nextDeliveryOrderPickOrderSingleTicketNo(requiredDate, ticketFloorSegment)
} else { } else {
nextDeliveryOrderPickOrderBatchTicketNo(requiredDate, ticketFloorSegment) nextDeliveryOrderPickOrderBatchTicketNo(requiredDate, ticketFloorSegment)


+ 3
- 3
src/main/java/com/ffii/fpsms/modules/deliveryOrder/web/DeliveryOrderController.kt Wyświetl plik

@@ -72,7 +72,7 @@ class DeliveryOrderController(
pageSize = request.pageSize, pageSize = request.pageSize,
truckLanceCode = request.truckLanceCode, truckLanceCode = request.truckLanceCode,
floor = request.floor, floor = request.floor,
isEtra = request.isEtra,
isExtra = request.isExtra,
) )
} }


@@ -89,7 +89,7 @@ class DeliveryOrderController(
pageNum = request.pageNum, pageNum = request.pageNum,
pageSize = request.pageSize, pageSize = request.pageSize,
floor = request.floor, floor = request.floor,
isEtra = request.isEtra,
isExtra = request.isExtra,
) )
} }


@@ -108,7 +108,7 @@ class DeliveryOrderController(
pageSize = request.pageSize, pageSize = request.pageSize,
truckLanceCode = request.truckLanceCode, truckLanceCode = request.truckLanceCode,
floor = request.floor, floor = request.floor,
isEtra = request.isEtra,
isExtra = request.isExtra,
) )
} }




+ 17
- 2
src/main/java/com/ffii/fpsms/modules/deliveryOrder/web/DoWorkbenchController.kt Wyświetl plik

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


@GetMapping("/released-today") @GetMapping("/released-today")
@@ -112,12 +125,14 @@ class DoWorkbenchController(
@RequestParam(required = false) storeId: 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) @DateTimeFormat(iso = DateTimeFormat.ISO.DATE) requiredDate: LocalDate?,
@RequestParam(required = false) releaseType: String?,
): List<ReleasedDoPickOrderListItem> { ): List<ReleasedDoPickOrderListItem> {
return doWorkbenchMainService.findWorkbenchReleasedDeliveryOrderPickOrdersForSelectionToday( return doWorkbenchMainService.findWorkbenchReleasedDeliveryOrderPickOrdersForSelectionToday(
shopName, shopName,
storeId, storeId,
truck, truck,
requiredDeliveryDate = requiredDate, requiredDeliveryDate = requiredDate,
releaseTypeFilter = releaseType,
) )
} }




+ 16
- 3
src/main/java/com/ffii/fpsms/modules/deliveryOrder/web/models/DoDetailResponse.kt Wyświetl plik

@@ -19,7 +19,7 @@ data class DoDetailResponse(
val completeDate: LocalDateTime?, val completeDate: LocalDateTime?,
val status: String?, val status: String?,
/** 加單 DO(M18 加單專用同步) */ /** 加單 DO(M18 加單專用同步) */
val isEtra: Boolean = false,
val isExtra: Boolean = false,
val deliveryOrderLines: List<DoDetailLineResponse> val deliveryOrderLines: List<DoDetailLineResponse>
) )


@@ -51,7 +51,18 @@ data class LaneBtn(
val unassigned: Int, val unassigned: Int,
val total: Int, val total: Int,
// 同一 truckLanceCode + loadingSequence 的 handler 去重后逗号拼接 // 同一 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( data class AssignByLaneRequest(
val userId: Long, val userId: Long,
@@ -59,7 +70,9 @@ data class AssignByLaneRequest(
val truckDepartureTime: String?, // 可选:限定出车时间 val truckDepartureTime: String?, // 可选:限定出车时间
val truckLanceCode: String , val truckLanceCode: String ,
val loadingSequence: Int? = null, 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( data class DoPickOrderSummaryItem(
val truckDepartureTime: java.time.LocalTime?, val truckDepartureTime: java.time.LocalTime?,


+ 5
- 4
src/main/java/com/ffii/fpsms/modules/deliveryOrder/web/models/ReleaseDoRequest.kt Wyświetl plik

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


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

+ 1
- 1
src/main/java/com/ffii/fpsms/modules/deliveryOrder/web/models/SaveDeliveryOrderRequest.kt Wyświetl plik

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


data class SaveDeliveryOrderStatusRequest( data class SaveDeliveryOrderStatusRequest(


+ 243
- 0
src/main/java/com/ffii/fpsms/modules/jobOrder/service/JobOrderPlanStartAutoService.kt Wyświetl plik

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

+ 6
- 3
src/main/java/com/ffii/fpsms/modules/jobOrder/service/PlasticBagPrinterService.kt Wyświetl plik

@@ -1367,15 +1367,18 @@ class PlasticBagPrinterService(
} }
val qrValue = zplEscape(qrPayload) 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 fontRegular = "E:STXihei.ttf"
val fontBold = "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 """ return """
^XA ^XA
^CI28 ^CI28
^PW700
^LL500
^PW$labelPw
^LL$labelLl
^PO N ^PO N
^FO10,20 ^FO10,20
^BQN,2,4^FDQA,$qrValue^FS ^BQN,2,4^FDQA,$qrValue^FS


+ 33
- 0
src/main/java/com/ffii/fpsms/modules/logistic/entity/Logistic.kt Wyświetl plik

@@ -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 Wyświetl plik

@@ -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 Wyświetl plik

@@ -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 Wyświetl plik

@@ -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 Wyświetl plik

@@ -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 Wyświetl plik

@@ -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 Wyświetl plik

@@ -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 Wyświetl plik

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

+ 10
- 0
src/main/java/com/ffii/fpsms/modules/master/entity/ShopRepository.kt Wyświetl plik

@@ -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.master.enums.ShopType
import com.ffii.fpsms.modules.pickOrder.entity.Truck import com.ffii.fpsms.modules.pickOrder.entity.Truck
import org.springframework.data.jpa.repository.Query import org.springframework.data.jpa.repository.Query
import org.springframework.data.repository.query.Param
import org.springframework.stereotype.Repository import org.springframework.stereotype.Repository


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


fun findByCode(code: String): Shop? fun findByCode(code: String): 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( @Query(
nativeQuery = true, nativeQuery = true,
value = """ value = """


+ 144
- 0
src/main/java/com/ffii/fpsms/modules/master/service/BomService.kt Wyświetl plik

@@ -34,6 +34,15 @@ import java.util.Comparator
import com.ffii.fpsms.modules.jobOrder.entity.JobOrderRepository import com.ffii.fpsms.modules.jobOrder.entity.JobOrderRepository
import com.ffii.fpsms.modules.productProcess.entity.ProductProcessRepository import com.ffii.fpsms.modules.productProcess.entity.ProductProcessRepository
import org.springframework.transaction.annotation.Transactional 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


@Service @Service
open class BomService( open class BomService(
@@ -52,6 +61,10 @@ open class BomService(
private val itemUomService: ItemUomService, private val itemUomService: ItemUomService,
private val jobOrderRepository: JobOrderRepository, private val jobOrderRepository: JobOrderRepository,
private val productProcessRepository: ProductProcessRepository, 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, @Value("\${bom.import.temp-dir:\${java.io.tmpdir}/fpsms-bom-import}") private val bomImportTempDir: String,
) { ) {
open fun uploadBomFiles(files: List<MultipartFile>): BomUploadResponse { open fun uploadBomFiles(files: List<MultipartFile>): BomUploadResponse {
@@ -119,6 +132,29 @@ open class BomService(
?: bomRepository.findAllByItemIdAndDeletedIsFalse(itemId).firstOrNull() ?: bomRepository.findAllByItemIdAndDeletedIsFalse(itemId).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 { open fun saveBom(request: SaveBomRequest): SaveBomResponse {


val item = request.code.let { itemsService.findByM18BomCode(it) } ?: request.itemId?.let { itemsService.findById(it) } val item = request.code.let { itemsService.findByM18BomCode(it) } ?: request.itemId?.let { itemsService.findById(it) }
@@ -371,6 +407,114 @@ open class BomService(
return getBomDetail(bom.id!!) 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 future UI) 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.
*/
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 requestJsonPayload = m18BomForShopService.toJson(req)
var resp: GoodsReceiptNoteResponse? = null
var callError: Throwable? = null
try {
resp = m18BomForShopService.saveBomForShop(req)
} catch (e: Exception) {
callError = e
}

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 = resp?.recordId ?: 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")
resp.status == true && resp.recordId > 0L -> {
bom.m18Id = resp.recordId
bomRepository.saveAndFlush(bom)
M18BomShopSyncTriggerResult(
bomId = bomId,
synced = true,
recordId = resp.recordId,
status = true,
messageSummary = 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 },
callError?.message,
result.skippedReason?.takeIf { !result.synced },
).joinToString("; ").take(4000)

m18BomShopSyncLogRepository.save(
M18BomShopSyncLog().apply {
this.bomId = bomId
finishedItemCode = req.udfbomforshop.values.firstOrNull()?.udfBomCode
m18HeaderCode = req.udfbomforshop.values.firstOrNull()?.code
requestFingerprint = m18BomForShopService.contentFingerprint(req)
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 { private fun resolveEquipmentForBomProcess(pReq: EditBomProcessRequest): Equipment {
val equipmentId = pReq.equipmentId val equipmentId = pReq.equipmentId
val equipmentCode = pReq.equipmentCode?.trim().orEmpty() val equipmentCode = pReq.equipmentCode?.trim().orEmpty()


+ 14
- 0
src/main/java/com/ffii/fpsms/modules/master/service/ItemUomService.kt Wyświetl plik

@@ -282,6 +282,20 @@ open class ItemUomService(
return finalizePreciseStockQty(stockUnit, stockQty) 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 // See if need to update the response
open fun saveItemUom(request: ItemUomRequest): ItemUom { open fun saveItemUom(request: ItemUomRequest): ItemUom {
val itemUom = request.m18Id?.let { itemUomRespository.findFirstByM18IdOrderByIdDesc(it) } val itemUom = request.m18Id?.let { itemUomRespository.findFirstByM18IdOrderByIdDesc(it) }


+ 12
- 0
src/main/java/com/ffii/fpsms/modules/master/web/BomController.kt Wyświetl plik

@@ -29,6 +29,8 @@ import com.ffii.fpsms.modules.master.web.models.ImportBomRequestPayload
import com.ffii.fpsms.modules.master.web.models.BomDetailResponse import com.ffii.fpsms.modules.master.web.models.BomDetailResponse
import com.ffii.fpsms.modules.master.web.models.EditBomRequest import com.ffii.fpsms.modules.master.web.models.EditBomRequest
import com.ffii.fpsms.modules.master.web.models.BomExcelCheckProgress import com.ffii.fpsms.modules.master.web.models.BomExcelCheckProgress
import com.ffii.fpsms.modules.master.web.models.BomIdByItemCodeResponse
import com.ffii.core.exception.BadRequestException
import java.util.logging.Logger import java.util.logging.Logger
import java.nio.file.Files import java.nio.file.Files
import org.springframework.core.io.FileSystemResource import org.springframework.core.io.FileSystemResource
@@ -120,6 +122,16 @@ fun downloadBomFormatIssueLog(
// fun exportProblematicBom() { // fun exportProblematicBom() {
// return bomService.importBOM() // return bomService.importBOM()
// } // }

/** Testing: FPSMS BOM id by finished-good item code (same item as BOM header). */
@GetMapping("/by-item-code")
fun getBomByItemCode(@RequestParam code: String): BomIdByItemCodeResponse {
if (code.isBlank()) {
throw BadRequestException("query parameter code is required")
}
return bomService.findBomSummaryByItemCode(code.trim())
}

@GetMapping("/{id}/detail") @GetMapping("/{id}/detail")
fun getBomDetail(@PathVariable id: Long): BomDetailResponse { fun getBomDetail(@PathVariable id: Long): BomDetailResponse {
return bomService.getBomDetail(id) return bomService.getBomDetail(id)


+ 14
- 0
src/main/java/com/ffii/fpsms/modules/master/web/models/BomIdByItemCodeResponse.kt Wyświetl plik

@@ -0,0 +1,14 @@
package com.ffii.fpsms.modules.master.web.models

/**
* Testing / lookup: resolve FPSMS BOM from finished-good [item] code (bom.item → [Items.code]).
*/
data class BomIdByItemCodeResponse(
val itemCode: String,
val itemId: Long? = null,
val bomId: Long? = null,
val bomCode: String? = null,
val bomM18Id: Long? = null,
/** e.g. item not found, or no BOM for item */
val message: String? = null,
)

+ 6
- 1
src/main/java/com/ffii/fpsms/modules/pickOrder/entity/Truck.kt Wyświetl plik

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


import com.ffii.core.entity.BaseEntity import com.ffii.core.entity.BaseEntity
import com.ffii.fpsms.modules.logistic.entity.Logistic
import com.ffii.fpsms.modules.master.entity.Shop import com.ffii.fpsms.modules.master.entity.Shop
import jakarta.persistence.* import jakarta.persistence.*
import jakarta.validation.constraints.NotNull import jakarta.validation.constraints.NotNull
@@ -42,4 +43,8 @@ open class Truck : BaseEntity<Long>() {
@Column(name = "remark") @Column(name = "remark")
open var remark: String? = null open var remark: String? = null


}
@ManyToOne(fetch = FetchType.LAZY)
@JoinColumn(name = "logisticId")
open var logistic: Logistic? = null

}

+ 19
- 0
src/main/java/com/ffii/fpsms/modules/pickOrder/entity/TruckLaneVersion.kt Wyświetl plik

@@ -0,0 +1,19 @@
package com.ffii.fpsms.modules.pickOrder.entity

import com.ffii.core.entity.BaseEntity
import jakarta.persistence.*
import jakarta.validation.constraints.Size

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

@field:Size(max = 100)
@Column(name = "truckLanceCode", nullable = true, length = 100)
open var truckLanceCode: String? = null

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


+ 55
- 0
src/main/java/com/ffii/fpsms/modules/pickOrder/entity/TruckLaneVersionLine.kt Wyświetl plik

@@ -0,0 +1,55 @@
package com.ffii.fpsms.modules.pickOrder.entity

import com.ffii.core.entity.BaseEntity
import jakarta.persistence.*
import jakarta.validation.constraints.NotNull
import jakarta.validation.constraints.Size

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

@ManyToOne(fetch = FetchType.LAZY)
@JoinColumn(name = "truckLaneVersionId", nullable = false)
open var truckLaneVersion: TruckLaneVersion? = null

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

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

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

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

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

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

@field:Size(max = 30)
@Column(name = "departureTime", length = 30)
open var departureTime: String? = null

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

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

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


+ 10
- 0
src/main/java/com/ffii/fpsms/modules/pickOrder/entity/TruckLaneVersionLineRepository.kt Wyświetl plik

@@ -0,0 +1,10 @@
package com.ffii.fpsms.modules.pickOrder.entity

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

@Repository
interface TruckLaneVersionLineRepository : AbstractRepository<TruckLaneVersionLine, Long> {
fun findAllByTruckLaneVersion_IdAndDeletedFalseOrderByIdAsc(truckLaneVersionId: Long): List<TruckLaneVersionLine>
}


+ 12
- 0
src/main/java/com/ffii/fpsms/modules/pickOrder/entity/TruckLaneVersionRepository.kt Wyświetl plik

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

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

@Repository
interface TruckLaneVersionRepository : AbstractRepository<TruckLaneVersion, Long> {
fun findAllByTruckLanceCodeAndDeletedFalseOrderByCreatedDesc(truckLanceCode: String): List<TruckLaneVersion>
fun findAllByDeletedFalseOrderByCreatedDesc(): List<TruckLaneVersion>
fun findByIdAndDeletedFalse(id: Long): TruckLaneVersion?
}


+ 81
- 4
src/main/java/com/ffii/fpsms/modules/pickOrder/entity/TruckRepository.kt Wyświetl plik

@@ -1,6 +1,8 @@
package com.ffii.fpsms.modules.pickOrder.entity package com.ffii.fpsms.modules.pickOrder.entity


import com.ffii.core.support.AbstractRepository import com.ffii.core.support.AbstractRepository
import com.ffii.fpsms.modules.logistic.entity.Logistic
import org.springframework.data.jpa.repository.Modifying
import org.springframework.data.jpa.repository.Query import org.springframework.data.jpa.repository.Query
import org.springframework.data.repository.query.Param import org.springframework.data.repository.query.Param
import org.springframework.stereotype.Repository import org.springframework.stereotype.Repository
@@ -32,8 +34,81 @@ interface TruckRepository : AbstractRepository<Truck, Long> {
fun findByTruckLanceCode(truckLanceCode: String): Truck? fun findByTruckLanceCode(truckLanceCode: String): Truck?
@Query("SELECT t FROM Truck t WHERE t.truckLanceCode = :truckLanceCode AND t.deleted = false") @Query("SELECT t FROM Truck t WHERE t.truckLanceCode = :truckLanceCode AND t.deleted = false")
fun findAllByTruckLanceCodeAndDeletedFalse(@Param("truckLanceCode") truckLanceCode: String): List<Truck> fun findAllByTruckLanceCodeAndDeletedFalse(@Param("truckLanceCode") truckLanceCode: String): List<Truck>

/**
* Same lane group as `findAllUniqueTruckLanceCodeAndRemarkCombinations`:
* remark NULL / blank belong to one bucket; non-blank matches exactly.
*/
@Query(
"""
SELECT DISTINCT t FROM Truck t
LEFT JOIN FETCH t.logistic
LEFT JOIN FETCH t.shop
WHERE t.truckLanceCode = :truckLanceCode
AND t.deleted = false
AND (
(:blankRemark = true AND (t.remark IS NULL OR trim(t.remark) = ''))
OR (:blankRemark = false AND trim(t.remark) = :exactRemark)
)
"""
)
fun findAllByTruckLanceCodeAndRemarkAndDeletedFalse(
@Param("truckLanceCode") truckLanceCode: String,
@Param("blankRemark") blankRemark: Boolean,
@Param("exactRemark") exactRemark: String?,
): List<Truck>

/**
* RouteBoard O(1) load: return all truck rows used by lanes, with logistic pre-fetched.
* Frontend groups by (truckLanceCode, normalizedRemark) where normalizedRemark is:
* - NULL / blank => ""
* - else TRIM(remark)
*/
@Query(
"""
SELECT t FROM Truck t
LEFT JOIN FETCH t.logistic
LEFT JOIN FETCH t.shop
WHERE t.deleted = false
AND t.truckLanceCode IS NOT NULL
AND trim(t.truckLanceCode) <> ''
ORDER BY t.truckLanceCode ASC,
CASE WHEN t.remark IS NULL OR trim(t.remark) = '' THEN '' ELSE trim(t.remark) END ASC,
t.loadingSequence ASC,
t.id ASC
"""
)
fun findAllForRouteBoard(): List<Truck>

/**
* 單一 UPDATE 寫入整條 lane 的 logistic,避免先 JOIN FETCH 載入再逐列 save(大車線會極慢)。
*/
@Modifying(clearAutomatically = true, flushAutomatically = true)
@Query(
"""
UPDATE Truck t SET t.logistic = :logistic
WHERE t.truckLanceCode = :truckLanceCode
AND t.deleted = false
AND (
(:blankRemark = true AND (t.remark IS NULL OR trim(t.remark) = ''))
OR (:blankRemark = false AND trim(t.remark) = :exactRemark)
)
""",
)
fun bulkUpdateLogisticForLaneGroup(
@Param("logistic") logistic: Logistic?,
@Param("truckLanceCode") truckLanceCode: String,
@Param("blankRemark") blankRemark: Boolean,
@Param("exactRemark") exactRemark: String?,
): Int

fun findAllByTruckLanceCodeAndStoreIdAndDeletedFalse(truckLanceCode: String, storeId: String): List<Truck>
fun findByShopNameAndStoreIdAndTruckLanceCode(shopName: String, storeId: String, truckLanceCode: String): Truck? fun findByShopNameAndStoreIdAndTruckLanceCode(shopName: String, storeId: String, truckLanceCode: String): Truck?
fun findByShopCodeAndStoreId(shopCode: String, storeId: String): Truck?
/** 同店同樓層重複列時取 id 最小一筆,避免 NonUniqueResultException */
fun findFirstByShopCodeAndStoreIdAndDeletedFalseOrderByIdAsc(
shopCode: String,
storeId: String,
): Truck?




fun findByShopIdAndStoreId(shopId: Long, storeId: String): Truck? fun findByShopIdAndStoreId(shopId: Long, storeId: String): Truck?
@@ -60,15 +135,17 @@ fun findByShopIdAndStoreIdAndDayOfWeek(
SELECT t.* SELECT t.*
FROM truck t FROM truck t
INNER JOIN ( INNER JOIN (
SELECT TruckLanceCode, remark, MIN(id) as min_id
SELECT TruckLanceCode,
COALESCE(NULLIF(TRIM(remark), ''), '') AS remark_norm,
MIN(id) AS min_id
FROM truck FROM truck
WHERE deleted = false WHERE deleted = false
AND TruckLanceCode IS NOT NULL AND TruckLanceCode IS NOT NULL
GROUP BY TruckLanceCode, remark
GROUP BY TruckLanceCode, COALESCE(NULLIF(TRIM(remark), ''), '')
) AS unique_combos ) AS unique_combos
ON t.id = unique_combos.min_id ON t.id = unique_combos.min_id
WHERE t.deleted = false WHERE t.deleted = false
ORDER BY t.TruckLanceCode, t.remark
ORDER BY t.TruckLanceCode, COALESCE(NULLIF(TRIM(t.remark), ''), '')
""" """
) )
fun findAllUniqueTruckLanceCodeAndRemarkCombinations(): List<Truck> fun findAllUniqueTruckLanceCodeAndRemarkCombinations(): List<Truck>


+ 3
- 0
src/main/java/com/ffii/fpsms/modules/pickOrder/service/HierarchicalFgPayloadAssembler.kt Wyświetl plik

@@ -240,6 +240,9 @@ ORDER BY
"id" to row["stockOutLineId"], "id" to row["stockOutLineId"],
"status" to row["stockOutLineStatus"], "status" to row["stockOutLineStatus"],
"qty" to row["stockOutLineQty"], "qty" to row["stockOutLineQty"],
"requiredQty" to row["requiredQty"],
"suggestedPickLotQty" to row["requiredQty"],
"suggestedPickLotId" to row["suggestedPickLotId"],
"lotId" to lotId, "lotId" to lotId,
"lotNo" to (row["lotNo"] ?: ""), "lotNo" to (row["lotNo"] ?: ""),
"location" to (row["location"] ?: ""), "location" to (row["location"] ?: ""),


+ 202
- 0
src/main/java/com/ffii/fpsms/modules/pickOrder/service/RouteLaneExcelSupport.kt Wyświetl plik

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

import org.apache.poi.ss.usermodel.BorderStyle
import org.apache.poi.ss.usermodel.FillPatternType
import org.apache.poi.ss.usermodel.HorizontalAlignment
import org.apache.poi.ss.usermodel.IndexedColors
import org.apache.poi.ss.usermodel.Sheet
import org.apache.poi.ss.usermodel.VerticalAlignment
import org.apache.poi.ss.usermodel.Workbook
import org.apache.poi.ss.util.CellRangeAddress
import org.apache.poi.ss.util.WorkbookUtil
import org.apache.poi.xssf.usermodel.XSSFCellStyle
import org.apache.poi.xssf.usermodel.XSSFFont
import org.apache.poi.xssf.usermodel.XSSFWorkbook
import java.net.URLDecoder
import java.nio.charset.StandardCharsets

/**
* MTMS 車線 Excel(PDF 圖1):每個車線一個 worksheet,格式版本 MTMS_ROUTE_V1。
* laneId 與前端 [encodeLaneId] 一致:`encodeURIComponent(code)|encodeURIComponent(remark)`。
*/
object RouteLaneExcelSupport {
const val FORMAT_MARKER = "MTMS_ROUTE_V1"
const val SEP = "|"

/** 0-based row indices */
const val ROW_MARKER = 0
const val ROW_STORE = 1
const val ROW_DEPARTURE_DEFAULT = 2
const val ROW_HEADER = 3
const val ROW_FIRST_DATA = 4

const val COL_META_A = 0
const val COL_META_B = 1
const val COL_META_C = 2

const val COL_AREA_PLATE = 0
const val COL_SHOP_NAME = 1
const val COL_BRAND = 2
const val COL_SHOP_CODE = 3
const val COL_SCHEDULE = 4
const val COL_DEPARTURE_ROW = 5

fun decodeLaneId(laneId: String): Pair<String, String?>? {
val i = laneId.indexOf(SEP)
if (i < 0) return null
return try {
val code = URLDecoder.decode(laneId.substring(0, i), StandardCharsets.UTF_8).trim()
val rem = URLDecoder.decode(laneId.substring(i + SEP.length), StandardCharsets.UTF_8).trim()
if (code.isEmpty()) return null
code to if (rem.isEmpty()) null else rem
} catch (_: Exception) {
null
}
}

fun plateLabel(groupIndexZeroBased: Int): String {
val n = groupIndexZeroBased + 1
val digits = arrayOf("一", "二", "三", "四", "五", "六", "七", "八", "九", "十")
val cn = when {
n in 1..10 -> digits[n - 1]
n in 11..19 -> "十" + digits[n - 11]
else -> "$n"
}
return "板$cn"
}

fun uniqueSheetName(workbook: Workbook, truckLanceCode: String, remark: String?): String {
val remarkPart = remark?.trim()?.takeIf { it.isNotEmpty() }?.let { "_${it.take(8)}" } ?: ""
val raw = (truckLanceCode.take(22) + remarkPart).take(31)
var base = WorkbookUtil.createSafeSheetName(raw).take(31)
if (base.isEmpty()) base = "Lane"
var name = base
var i = 0
while (workbook.getSheet(name) != null) {
val suffix = "_$i"
val truncated = base.take((31 - suffix.length).coerceAtLeast(1))
name = WorkbookUtil.createSafeSheetName(truncated + suffix).take(31)
i++
}
return name
}

private data class RouteLaneExportStyles(
val metaKey: XSSFCellStyle,
val metaValue: XSSFCellStyle,
val header: XSSFCellStyle,
val data: XSSFCellStyle,
val dataAlt: XSSFCellStyle,
)

private fun buildExportStyles(wb: XSSFWorkbook): RouteLaneExportStyles {
fun XSSFCellStyle.borders() {
borderTop = BorderStyle.THIN
borderBottom = BorderStyle.THIN
borderLeft = BorderStyle.THIN
borderRight = BorderStyle.THIN
}

val metaKey = (wb.createCellStyle() as XSSFCellStyle).apply {
alignment = HorizontalAlignment.LEFT
verticalAlignment = VerticalAlignment.CENTER
fillForegroundColor = IndexedColors.GREY_40_PERCENT.index
fillPattern = FillPatternType.SOLID_FOREGROUND
borders()
val f = wb.createFont() as XSSFFont
f.bold = true
f.fontHeightInPoints = 11
setFont(f)
}
val metaValue = (wb.createCellStyle() as XSSFCellStyle).apply {
alignment = HorizontalAlignment.LEFT
verticalAlignment = VerticalAlignment.CENTER
fillForegroundColor = IndexedColors.WHITE.index
fillPattern = FillPatternType.SOLID_FOREGROUND
borders()
val f = wb.createFont()
f.fontHeightInPoints = 11
setFont(f)
}
val headerFont = (wb.createFont() as XSSFFont).apply {
bold = true
fontHeightInPoints = 11
color = IndexedColors.WHITE.index
}
val header = (wb.createCellStyle() as XSSFCellStyle).apply {
alignment = HorizontalAlignment.CENTER
verticalAlignment = VerticalAlignment.CENTER
fillForegroundColor = IndexedColors.ROYAL_BLUE.index
fillPattern = FillPatternType.SOLID_FOREGROUND
borders()
setFont(headerFont)
}
val data = (wb.createCellStyle() as XSSFCellStyle).apply {
alignment = HorizontalAlignment.LEFT
verticalAlignment = VerticalAlignment.CENTER
wrapText = true
borders()
val f = wb.createFont()
f.fontHeightInPoints = 11
setFont(f)
}
val dataAlt = (wb.createCellStyle() as XSSFCellStyle).apply {
cloneStyleFrom(data)
fillPattern = FillPatternType.SOLID_FOREGROUND
fillForegroundColor = IndexedColors.GREY_25_PERCENT.index
}
return RouteLaneExportStyles(metaKey, metaValue, header, data, dataAlt)
}

/**
* 表頭/邊框/隔行底色/欄寬/凍結首列資料之上/AutoFilter。不改儲存格值(import 仍讀 raw)。
*/
fun applyRouteLaneExportFinishing(
sheet: Sheet,
wb: XSSFWorkbook,
firstDataRow: Int,
lastDataRow: Int,
) {
val st = buildExportStyles(wb)

for (r in intArrayOf(ROW_MARKER, ROW_STORE, ROW_DEPARTURE_DEFAULT)) {
val row = sheet.getRow(r) ?: continue
for (c in 0..COL_META_C) {
val cell = row.getCell(c) ?: continue
cell.cellStyle = if (c == COL_META_A) st.metaKey else st.metaValue
}
}

val headerRow = sheet.getRow(ROW_HEADER)
if (headerRow != null) {
for (c in COL_AREA_PLATE..COL_DEPARTURE_ROW) {
headerRow.getCell(c)?.cellStyle = st.header
}
}

if (lastDataRow >= firstDataRow) {
for (r in firstDataRow..lastDataRow) {
val alt = (r - firstDataRow) % 2 == 1
val style = if (alt) st.dataAlt else st.data
val row = sheet.getRow(r) ?: continue
for (c in COL_AREA_PLATE..COL_DEPARTURE_ROW) {
row.getCell(c)?.cellStyle = style
}
}
}

sheet.setColumnWidth(COL_AREA_PLATE, 14 * 256)
sheet.setColumnWidth(COL_SHOP_NAME, 28 * 256)
sheet.setColumnWidth(COL_BRAND, 14 * 256)
sheet.setColumnWidth(COL_SHOP_CODE, 12 * 256)
sheet.setColumnWidth(COL_SCHEDULE, 12 * 256)
sheet.setColumnWidth(COL_DEPARTURE_ROW, 12 * 256)

sheet.createFreezePane(0, ROW_FIRST_DATA)

val filterLast = if (lastDataRow >= firstDataRow) lastDataRow else ROW_HEADER
sheet.setAutoFilter(
CellRangeAddress(ROW_HEADER, filterLast, COL_AREA_PLATE, COL_DEPARTURE_ROW),
)
}
}

+ 359
- 0
src/main/java/com/ffii/fpsms/modules/pickOrder/service/RouteReportExcelSupport.kt Wyświetl plik

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

import org.apache.poi.ss.usermodel.BorderStyle
import org.apache.poi.ss.usermodel.FillPatternType
import org.apache.poi.ss.usermodel.HorizontalAlignment
import org.apache.poi.ss.usermodel.IndexedColors
import org.apache.poi.ss.usermodel.Sheet
import org.apache.poi.ss.usermodel.VerticalAlignment
import org.apache.poi.ss.util.CellRangeAddress
import org.apache.poi.ss.util.RegionUtil
import org.apache.poi.xssf.usermodel.XSSFCellStyle
import org.apache.poi.xssf.usermodel.XSSFFont
import org.apache.poi.xssf.usermodel.XSSFWorkbook

object RouteReportExcelSupport {
const val SHEET_NAME = "車線Report"
const val BLOCK_WIDTH = 2 // 每間物流公司一個 block:2 欄

data class Styles(
val title: XSSFCellStyle,
val titlePreparedBy: XSSFCellStyle,
val company: XSSFCellStyle,
val plate: XSSFCellStyle,
val timeHeader: XSSFCellStyle,
val laneLeft: XSSFCellStyle,
val laneFill: XSSFCellStyle,
val district: XSSFCellStyle,
val shopNo: XSSFCellStyle,
val shopText: XSSFCellStyle,
val total: XSSFCellStyle,
val driverLabel: XSSFCellStyle,
val driverValue: XSSFCellStyle,
)

private fun borders(st: XSSFCellStyle, border: BorderStyle = BorderStyle.THIN) {
st.borderTop = border
st.borderBottom = border
st.borderLeft = border
st.borderRight = border
}

fun buildStyles(wb: XSSFWorkbook): Styles {
fun font(
size: Short,
bold: Boolean = false,
color: Short? = null,
): XSSFFont {
val f = wb.createFont() as XSSFFont
f.fontHeightInPoints = size
f.bold = bold
if (color != null) f.color = color
return f
}

val title = (wb.createCellStyle() as XSSFCellStyle).apply {
alignment = HorizontalAlignment.CENTER
verticalAlignment = VerticalAlignment.CENTER
borders(this, BorderStyle.MEDIUM)
setFont(font(16, bold = true))
}

val titlePreparedBy = (wb.createCellStyle() as XSSFCellStyle).apply {
alignment = HorizontalAlignment.RIGHT
verticalAlignment = VerticalAlignment.CENTER
borders(this, BorderStyle.MEDIUM)
setFont(font(11, bold = true))
}

val company = (wb.createCellStyle() as XSSFCellStyle).apply {
alignment = HorizontalAlignment.CENTER
verticalAlignment = VerticalAlignment.CENTER
fillForegroundColor = IndexedColors.GREY_25_PERCENT.index
fillPattern = FillPatternType.SOLID_FOREGROUND
borders(this, BorderStyle.MEDIUM)
setFont(font(12, bold = true))
}

val plate = (wb.createCellStyle() as XSSFCellStyle).apply {
alignment = HorizontalAlignment.CENTER
verticalAlignment = VerticalAlignment.CENTER
borders(this, BorderStyle.THIN)
setFont(font(11, bold = true))
}

val timeHeader = (wb.createCellStyle() as XSSFCellStyle).apply {
alignment = HorizontalAlignment.CENTER
verticalAlignment = VerticalAlignment.CENTER
fillForegroundColor = IndexedColors.LIGHT_YELLOW.index
fillPattern = FillPatternType.SOLID_FOREGROUND
borders(this, BorderStyle.MEDIUM)
setFont(font(11, bold = true))
}

val laneLeft = (wb.createCellStyle() as XSSFCellStyle).apply {
alignment = HorizontalAlignment.CENTER
verticalAlignment = VerticalAlignment.CENTER
fillForegroundColor = IndexedColors.GREY_40_PERCENT.index
fillPattern = FillPatternType.SOLID_FOREGROUND
borders(this, BorderStyle.MEDIUM)
setFont(font(11, bold = true, color = IndexedColors.WHITE.index))
}

val laneFill = (wb.createCellStyle() as XSSFCellStyle).apply {
alignment = HorizontalAlignment.LEFT
verticalAlignment = VerticalAlignment.CENTER
fillForegroundColor = IndexedColors.GREY_25_PERCENT.index
fillPattern = FillPatternType.SOLID_FOREGROUND
borders(this, BorderStyle.MEDIUM)
setFont(font(11, bold = true))
}

val district = (wb.createCellStyle() as XSSFCellStyle).apply {
alignment = HorizontalAlignment.LEFT
verticalAlignment = VerticalAlignment.CENTER
fillForegroundColor = IndexedColors.LIGHT_CORNFLOWER_BLUE.index
fillPattern = FillPatternType.SOLID_FOREGROUND
borders(this, BorderStyle.THIN)
setFont(font(11, bold = true))
}

val shopNo = (wb.createCellStyle() as XSSFCellStyle).apply {
alignment = HorizontalAlignment.RIGHT
verticalAlignment = VerticalAlignment.TOP
borders(this, BorderStyle.THIN)
setFont(font(11, bold = true))
}

val shopText = (wb.createCellStyle() as XSSFCellStyle).apply {
alignment = HorizontalAlignment.LEFT
verticalAlignment = VerticalAlignment.TOP
wrapText = true
borders(this, BorderStyle.THIN)
setFont(font(11))
}

val total = (wb.createCellStyle() as XSSFCellStyle).apply {
alignment = HorizontalAlignment.LEFT
verticalAlignment = VerticalAlignment.CENTER
fillForegroundColor = IndexedColors.GREY_25_PERCENT.index
fillPattern = FillPatternType.SOLID_FOREGROUND
borders(this, BorderStyle.MEDIUM)
setFont(font(11, bold = true))
}

val driverLabel = (wb.createCellStyle() as XSSFCellStyle).apply {
alignment = HorizontalAlignment.CENTER
verticalAlignment = VerticalAlignment.CENTER
fillForegroundColor = IndexedColors.GREY_40_PERCENT.index
fillPattern = FillPatternType.SOLID_FOREGROUND
borders(this, BorderStyle.MEDIUM)
setFont(font(11, bold = true, color = IndexedColors.WHITE.index))
}

val driverValue = (wb.createCellStyle() as XSSFCellStyle).apply {
alignment = HorizontalAlignment.LEFT
verticalAlignment = VerticalAlignment.CENTER
borders(this, BorderStyle.MEDIUM)
setFont(font(11, bold = true))
}

return Styles(
title = title,
titlePreparedBy = titlePreparedBy,
company = company,
plate = plate,
timeHeader = timeHeader,
laneLeft = laneLeft,
laneFill = laneFill,
district = district,
shopNo = shopNo,
shopText = shopText,
total = total,
driverLabel = driverLabel,
driverValue = driverValue,
)
}

private fun ensureRow(sheet: Sheet, r: Int) = sheet.getRow(r) ?: sheet.createRow(r)
private fun ensureCell(sheet: Sheet, r: Int, c: Int) =
ensureRow(sheet, r).let { row -> row.getCell(c) ?: row.createCell(c) }

fun styleRange(
sheet: Sheet,
row: Int,
firstCol: Int,
lastCol: Int,
style: XSSFCellStyle,
) {
for (c in firstCol..lastCol) {
ensureCell(sheet, row, c).cellStyle = style
}
}

fun mergeAndStyle(
sheet: Sheet,
row: Int,
firstCol: Int,
lastCol: Int,
style: XSSFCellStyle,
border: BorderStyle = BorderStyle.MEDIUM,
) {
for (c in firstCol..lastCol) {
ensureCell(sheet, row, c).cellStyle = style
}
// POI 不允許 merge 單一 cell(需 2+ cells)。此時只套 style + cell border 即可。
if (firstCol == lastCol) return
val region = CellRangeAddress(row, row, firstCol, lastCol)
sheet.addMergedRegion(region)
RegionUtil.setBorderTop(border, region, sheet)
RegionUtil.setBorderBottom(border, region, sheet)
RegionUtil.setBorderLeft(border, region, sheet)
RegionUtil.setBorderRight(border, region, sheet)
}

fun applyColumnWidths(sheet: Sheet, blockIndex: Int) {
val base = blockIndex * BLOCK_WIDTH
sheet.setColumnWidth(base + 0, 10 * 256)
sheet.setColumnWidth(base + 1, 26 * 256)
}

fun writeTitle(
sheet: Sheet,
st: Styles,
titleText: String,
preparedByText: String,
totalBlocks: Int,
) {
val lastCol = (totalBlocks * BLOCK_WIDTH - 1).coerceAtLeast(0)
val r = 0
// 預留右邊 2 欄顯示「製表: xxx」
val preparedCols = 1.coerceAtMost(lastCol + 1)
val preparedFirstCol = (lastCol - preparedCols + 1).coerceAtLeast(0)
val titleLastCol = (preparedFirstCol - 1).coerceAtLeast(0)

if (preparedFirstCol == 0) {
// 欄位不足:整行仍以 title style 輸出(避免 merge 範圍倒轉)
mergeAndStyle(sheet, r, 0, lastCol, st.title, BorderStyle.MEDIUM)
ensureCell(sheet, r, 0).setCellValue("$titleText $preparedByText")
} else {
mergeAndStyle(sheet, r, 0, titleLastCol, st.title, BorderStyle.MEDIUM)
ensureCell(sheet, r, 0).setCellValue(titleText)

mergeAndStyle(sheet, r, preparedFirstCol, lastCol, st.titlePreparedBy, BorderStyle.MEDIUM)
ensureCell(sheet, r, preparedFirstCol).setCellValue(preparedByText)
}
sheet.getRow(r)?.heightInPoints = 26f
}

data class BlockMeta(
val companyName: String,
val plate: String,
val driverName: String,
val driverNumber: String,
)

/**
* @return 最後寫到的 row index(含)
*/
fun writeCompanyBlock(
sheet: Sheet,
st: Styles,
blockIndex: Int,
startRow: Int,
meta: BlockMeta,
groups: List<TimeGroup>,
totalShopCount: Int,
): Int {
val baseCol = blockIndex * BLOCK_WIDTH
applyColumnWidths(sheet, blockIndex)

var r = startRow

// 公司名
mergeAndStyle(sheet, r, baseCol, baseCol + 1, st.company, BorderStyle.MEDIUM)
ensureCell(sheet, r, baseCol).setCellValue(meta.companyName)
sheet.getRow(r)?.heightInPoints = 18f
r++

// 車牌
mergeAndStyle(sheet, r, baseCol, baseCol + 1, st.plate, BorderStyle.THIN)
ensureCell(sheet, r, baseCol).setCellValue(meta.plate)
r++

for (tg in groups) {
mergeAndStyle(sheet, r, baseCol, baseCol + 1, st.timeHeader, BorderStyle.MEDIUM)
ensureCell(sheet, r, baseCol).setCellValue(tg.timeLabel)
r++

for (lg in tg.lanes) {
// 車線標題:左一格強調,右三格補底
ensureCell(sheet, r, baseCol).apply {
cellStyle = st.laneLeft
setCellValue(lg.laneCode)
}
// 2 欄版:右側只剩 1 格(不 merge)
ensureCell(sheet, r, baseCol + 1).cellStyle = st.laneFill
r++

for (dg in lg.districts) {
mergeAndStyle(sheet, r, baseCol, baseCol + 1, st.district, BorderStyle.THIN)
ensureCell(sheet, r, baseCol).setCellValue(dg.districtLabel)
r++

var idx = 1
for (s in dg.shops) {
ensureCell(sheet, r, baseCol).apply {
cellStyle = st.shopNo
setCellValue("$idx.")
}
// shop row 不做 merge:避免 merged regions 爆量導致寫檔/開檔變慢
styleRange(sheet, r, baseCol + 1, baseCol + 1, st.shopText)
ensureCell(sheet, r, baseCol + 1).setCellValue(s)
val lines = (s.count { it == '\n' } + 1).coerceAtLeast(1)
val h = (16f * lines).coerceIn(18f, 72f)
sheet.getRow(r)?.heightInPoints = h
r++
idx++
}
}
}
}

// 分店數目
mergeAndStyle(sheet, r, baseCol, baseCol + 1, st.total, BorderStyle.MEDIUM)
ensureCell(sheet, r, baseCol).setCellValue("分店數目:$totalShopCount")
r++

// 車長 / driver
ensureCell(sheet, r, baseCol).cellStyle = st.driverLabel
ensureCell(sheet, r, baseCol).setCellValue("車長")
ensureCell(sheet, r, baseCol + 1).cellStyle = st.driverValue
ensureCell(sheet, r, baseCol + 1).setCellValue(meta.driverName)
r++

// driver number
// 2 欄版:電話/司機號碼跨兩欄合併成一格(像截圖的大白格)
mergeAndStyle(sheet, r, baseCol, baseCol + 1, st.driverValue, BorderStyle.MEDIUM)
ensureCell(sheet, r, baseCol).setCellValue(meta.driverNumber)
r++

return r - 1
}

data class TimeGroup(
val timeLabel: String,
val lanes: List<LaneGroup>,
)

data class LaneGroup(
val laneCode: String,
val districts: List<DistrictGroup>,
)

data class DistrictGroup(
val districtLabel: String,
val shops: List<String>,
)
}


+ 194
- 0
src/main/java/com/ffii/fpsms/modules/pickOrder/service/TruckLaneVersionReportExcelSupport.kt Wyświetl plik

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

import org.apache.poi.ss.usermodel.BorderStyle
import org.apache.poi.ss.usermodel.FillPatternType
import org.apache.poi.ss.usermodel.HorizontalAlignment
import org.apache.poi.ss.usermodel.IndexedColors
import org.apache.poi.ss.usermodel.Sheet
import org.apache.poi.ss.usermodel.VerticalAlignment
import org.apache.poi.ss.util.CellRangeAddress
import org.apache.poi.ss.util.RegionUtil
import org.apache.poi.xssf.usermodel.XSSFCellStyle
import org.apache.poi.xssf.usermodel.XSSFFont
import org.apache.poi.xssf.usermodel.XSSFWorkbook

object TruckLaneVersionReportExcelSupport {
const val SUMMARY_SHEET = "版本異動報告"

private data class Styles(
val title: XSSFCellStyle,
val metaKey: XSSFCellStyle,
val metaVal: XSSFCellStyle,
val header: XSSFCellStyle,
val normal: XSSFCellStyle,
val added: XSSFCellStyle,
val deleted: XSSFCellStyle,
val moved: XSSFCellStyle,
val edited: XSSFCellStyle,
val highlight: XSSFCellStyle,
)

private fun buildStyles(wb: XSSFWorkbook): Styles {
fun font(size: Short, bold: Boolean = false, color: Short? = null): XSSFFont {
val f = wb.createFont() as XSSFFont
f.fontHeightInPoints = size
f.bold = bold
if (color != null) f.color = color
return f
}

fun style(
align: HorizontalAlignment,
vAlign: VerticalAlignment = VerticalAlignment.CENTER,
bg: Short? = null,
bold: Boolean = false,
size: Short = 11,
border: BorderStyle = BorderStyle.THIN,
): XSSFCellStyle {
return (wb.createCellStyle() as XSSFCellStyle).apply {
alignment = align
verticalAlignment = vAlign
borderTop = border
borderBottom = border
borderLeft = border
borderRight = border
if (bg != null) {
fillForegroundColor = bg
fillPattern = FillPatternType.SOLID_FOREGROUND
}
setFont(font(size, bold = bold))
wrapText = true
}
}

val title = style(HorizontalAlignment.CENTER, bg = IndexedColors.WHITE.index, bold = true, size = 16, border = BorderStyle.MEDIUM)
val metaKey = style(HorizontalAlignment.LEFT, bg = IndexedColors.GREY_25_PERCENT.index, bold = true, border = BorderStyle.THIN)
val metaVal = style(HorizontalAlignment.LEFT, bg = IndexedColors.WHITE.index, bold = false, border = BorderStyle.THIN)
val header = (wb.createCellStyle() as XSSFCellStyle).apply {
alignment = HorizontalAlignment.CENTER
verticalAlignment = VerticalAlignment.CENTER
fillForegroundColor = IndexedColors.ROYAL_BLUE.index
fillPattern = FillPatternType.SOLID_FOREGROUND
borderTop = BorderStyle.MEDIUM
borderBottom = BorderStyle.MEDIUM
borderLeft = BorderStyle.MEDIUM
borderRight = BorderStyle.MEDIUM
setFont(font(11, bold = true, color = IndexedColors.WHITE.index))
}
val normal = style(HorizontalAlignment.LEFT)
val added = style(HorizontalAlignment.LEFT, bg = IndexedColors.LIGHT_GREEN.index, border = BorderStyle.THIN)
val deleted = style(HorizontalAlignment.LEFT, bg = IndexedColors.ROSE.index, border = BorderStyle.THIN)
val moved = style(HorizontalAlignment.LEFT, bg = IndexedColors.LIGHT_YELLOW.index, border = BorderStyle.THIN)
val edited = style(HorizontalAlignment.LEFT, bg = IndexedColors.GREY_25_PERCENT.index, border = BorderStyle.THIN)
val highlight = style(HorizontalAlignment.LEFT, bg = IndexedColors.LIGHT_ORANGE.index, border = BorderStyle.THIN, bold = true)
return Styles(title, metaKey, metaVal, header, normal, added, deleted, moved, edited, highlight)
}

private fun ensureRow(sheet: Sheet, r: Int) = sheet.getRow(r) ?: sheet.createRow(r)
private fun cell(sheet: Sheet, r: Int, c: Int) = ensureRow(sheet, r).getCell(c) ?: ensureRow(sheet, r).createCell(c)

private fun mergeRow(sheet: Sheet, r: Int, c0: Int, c1: Int, style: XSSFCellStyle) {
for (c in c0..c1) cell(sheet, r, c).cellStyle = style
if (c0 == c1) return
val region = CellRangeAddress(r, r, c0, c1)
sheet.addMergedRegion(region)
RegionUtil.setBorderTop(BorderStyle.MEDIUM, region, sheet)
RegionUtil.setBorderBottom(BorderStyle.MEDIUM, region, sheet)
RegionUtil.setBorderLeft(BorderStyle.MEDIUM, region, sheet)
RegionUtil.setBorderRight(BorderStyle.MEDIUM, region, sheet)
}

data class SummaryMeta(
val title: String,
val editor: String,
val created: String,
val fromVersionId: Long,
val toVersionId: Long,
val note: String?,
val statsText: String,
)

enum class RowType { ADDED, DELETED, MOVED, EDITED }

data class SummaryRow(
val type: RowType,
val shopName: String,
val shopCode: String,
val fromLane: String,
val toLane: String,
val changeText: String,
/** 欄位名集合,用於高亮「變更資訊」cell */
val changedFields: Set<String> = emptySet(),
)

fun writeSummarySheet(wb: XSSFWorkbook, meta: SummaryMeta, rows: List<SummaryRow>) {
val st = buildStyles(wb)
val sheet = wb.createSheet(SUMMARY_SHEET)

// column widths
sheet.setColumnWidth(0, 10 * 256) // type
sheet.setColumnWidth(1, 22 * 256) // shop
sheet.setColumnWidth(2, 12 * 256) // code
sheet.setColumnWidth(3, 18 * 256) // from
sheet.setColumnWidth(4, 18 * 256) // to
sheet.setColumnWidth(5, 60 * 256) // text

var r = 0
mergeRow(sheet, r, 0, 5, st.title)
cell(sheet, r, 0).setCellValue(meta.title)
sheet.getRow(r)?.heightInPoints = 26f
r++

fun metaRow(k: String, v: String) {
cell(sheet, r, 0).apply { cellStyle = st.metaKey; setCellValue(k) }
mergeRow(sheet, r, 1, 5, st.metaVal)
cell(sheet, r, 1).setCellValue(v)
r++
}

metaRow("編輯者", meta.editor)
metaRow("建立時間", meta.created)
metaRow("版本", "from #${meta.fromVersionId} → to #${meta.toVersionId}")
metaRow("摘要", meta.statsText)
if (!meta.note.isNullOrBlank()) metaRow("備註", meta.note.trim())

r++

// header
val headerRowIndex = r
val headers = listOf("類型", "分店", "代碼", "From 車線", "To 車線", "變更資訊")
for (c in headers.indices) {
cell(sheet, r, c).apply { cellStyle = st.header; setCellValue(headers[c]) }
}
sheet.getRow(r)?.heightInPoints = 18f
r++

for (row in rows) {
val baseStyle =
when (row.type) {
RowType.ADDED -> st.added
RowType.DELETED -> st.deleted
RowType.MOVED -> st.moved
RowType.EDITED -> st.edited
}

fun set(c: Int, v: String, highlight: Boolean = false) {
cell(sheet, r, c).apply {
cellStyle = if (highlight) st.highlight else baseStyle
setCellValue(v)
}
}

set(0, row.type.name)
set(1, row.shopName)
set(2, row.shopCode)
set(3, row.fromLane, highlight = row.type == RowType.MOVED || row.type == RowType.ADDED || row.type == RowType.DELETED)
set(4, row.toLane, highlight = row.type == RowType.MOVED || row.type == RowType.ADDED || row.type == RowType.DELETED)
set(5, row.changeText, highlight = row.changedFields.isNotEmpty())
r++
}

sheet.createFreezePane(0, headerRowIndex + 1)
}
}


+ 300
- 0
src/main/java/com/ffii/fpsms/modules/pickOrder/service/TruckLaneVersionRouteReportExcelSupport.kt Wyświetl plik

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

import org.apache.poi.ss.usermodel.BorderStyle
import org.apache.poi.ss.usermodel.FillPatternType
import org.apache.poi.ss.usermodel.HorizontalAlignment
import org.apache.poi.ss.usermodel.IndexedColors
import org.apache.poi.ss.usermodel.Sheet
import org.apache.poi.ss.usermodel.VerticalAlignment
import org.apache.poi.ss.util.CellRangeAddress
import org.apache.poi.ss.util.RegionUtil
import org.apache.poi.xssf.usermodel.XSSFCellStyle
import org.apache.poi.xssf.usermodel.XSSFFont
import org.apache.poi.xssf.usermodel.XSSFWorkbook

/**
* 版本 Log 用:輸出「車線報告」版面(同正常 RouteReport),但把異動的 shop row 高亮。
*
* 2 欄 block:左序號 / label、右內容。
*/
object TruckLaneVersionRouteReportExcelSupport {
const val SHEET_NAME = "車線報告(版本)"
const val BLOCK_WIDTH = 2

data class Styles(
val title: XSSFCellStyle,
val titlePreparedBy: XSSFCellStyle,
val company: XSSFCellStyle,
val plate: XSSFCellStyle,
val timeHeader: XSSFCellStyle,
val laneLeft: XSSFCellStyle,
val laneFill: XSSFCellStyle,
val district: XSSFCellStyle,
val shopNo: XSSFCellStyle,
val shopText: XSSFCellStyle,
val shopNoChanged: XSSFCellStyle,
val shopTextChanged: XSSFCellStyle,
val total: XSSFCellStyle,
val driverLabel: XSSFCellStyle,
val driverValue: XSSFCellStyle,
)

fun buildStyles(wb: XSSFWorkbook): Styles {
fun font(size: Short, bold: Boolean = false, color: Short? = null): XSSFFont {
val f = wb.createFont() as XSSFFont
f.fontHeightInPoints = size
f.bold = bold
if (color != null) f.color = color
return f
}

fun borders(st: XSSFCellStyle, border: BorderStyle) {
st.borderTop = border
st.borderBottom = border
st.borderLeft = border
st.borderRight = border
}

fun baseCell(
align: HorizontalAlignment,
bg: Short? = null,
bold: Boolean = false,
size: Short = 11,
border: BorderStyle = BorderStyle.THIN,
wrap: Boolean = false,
): XSSFCellStyle {
return (wb.createCellStyle() as XSSFCellStyle).apply {
alignment = align
verticalAlignment = VerticalAlignment.CENTER
borders(this, border)
if (bg != null) {
fillForegroundColor = bg
fillPattern = FillPatternType.SOLID_FOREGROUND
}
setFont(font(size, bold = bold))
wrapText = wrap
}
}

val title = baseCell(HorizontalAlignment.CENTER, bold = true, size = 16, border = BorderStyle.MEDIUM)
val titlePreparedBy = baseCell(HorizontalAlignment.RIGHT, bold = true, size = 11, border = BorderStyle.MEDIUM)
val company = baseCell(
HorizontalAlignment.CENTER,
bg = IndexedColors.GREY_25_PERCENT.index,
bold = true,
size = 12,
border = BorderStyle.MEDIUM,
)
val plate = baseCell(HorizontalAlignment.CENTER, bold = true, border = BorderStyle.THIN)
val timeHeader = baseCell(
HorizontalAlignment.CENTER,
bg = IndexedColors.LIGHT_YELLOW.index,
bold = true,
border = BorderStyle.MEDIUM,
)
val laneLeft = baseCell(
HorizontalAlignment.CENTER,
bg = IndexedColors.GREY_40_PERCENT.index,
bold = true,
border = BorderStyle.MEDIUM,
).apply { setFont(font(11, bold = true, color = IndexedColors.WHITE.index)) }

val laneFill = baseCell(
HorizontalAlignment.LEFT,
bg = IndexedColors.GREY_25_PERCENT.index,
bold = true,
border = BorderStyle.MEDIUM,
)

val district = baseCell(
HorizontalAlignment.LEFT,
bg = IndexedColors.LIGHT_CORNFLOWER_BLUE.index,
bold = true,
border = BorderStyle.THIN,
)

val shopNo = baseCell(HorizontalAlignment.RIGHT, border = BorderStyle.THIN).apply {
verticalAlignment = VerticalAlignment.TOP
setFont(font(11, bold = true))
}
val shopText = baseCell(HorizontalAlignment.LEFT, border = BorderStyle.THIN, wrap = true).apply {
verticalAlignment = VerticalAlignment.TOP
}

val shopNoChanged = (wb.createCellStyle() as XSSFCellStyle).apply {
cloneStyleFrom(shopNo)
fillForegroundColor = IndexedColors.LIGHT_ORANGE.index
fillPattern = FillPatternType.SOLID_FOREGROUND
}
val shopTextChanged = (wb.createCellStyle() as XSSFCellStyle).apply {
cloneStyleFrom(shopText)
fillForegroundColor = IndexedColors.LIGHT_ORANGE.index
fillPattern = FillPatternType.SOLID_FOREGROUND
val f = wb.createFont()
f.fontHeightInPoints = 11
f.bold = true
setFont(f)
}

val total = baseCell(HorizontalAlignment.LEFT, bg = IndexedColors.GREY_25_PERCENT.index, bold = true, border = BorderStyle.MEDIUM)
val driverLabel = baseCell(
HorizontalAlignment.CENTER,
bg = IndexedColors.GREY_40_PERCENT.index,
bold = true,
border = BorderStyle.MEDIUM,
).apply { setFont(font(11, bold = true, color = IndexedColors.WHITE.index)) }
val driverValue = baseCell(HorizontalAlignment.LEFT, bold = true, border = BorderStyle.MEDIUM)

return Styles(
title = title,
titlePreparedBy = titlePreparedBy,
company = company,
plate = plate,
timeHeader = timeHeader,
laneLeft = laneLeft,
laneFill = laneFill,
district = district,
shopNo = shopNo,
shopText = shopText,
shopNoChanged = shopNoChanged,
shopTextChanged = shopTextChanged,
total = total,
driverLabel = driverLabel,
driverValue = driverValue,
)
}

private fun ensureRow(sheet: Sheet, r: Int) = sheet.getRow(r) ?: sheet.createRow(r)
private fun cell(sheet: Sheet, r: Int, c: Int) = ensureRow(sheet, r).getCell(c) ?: ensureRow(sheet, r).createCell(c)

private fun mergeAndStyle(sheet: Sheet, r: Int, c0: Int, c1: Int, style: XSSFCellStyle, border: BorderStyle) {
for (c in c0..c1) cell(sheet, r, c).cellStyle = style
if (c0 == c1) return
val region = CellRangeAddress(r, r, c0, c1)
sheet.addMergedRegion(region)
RegionUtil.setBorderTop(border, region, sheet)
RegionUtil.setBorderBottom(border, region, sheet)
RegionUtil.setBorderLeft(border, region, sheet)
RegionUtil.setBorderRight(border, region, sheet)
}

fun applyColumnWidths(sheet: Sheet, blockIndex: Int) {
val base = blockIndex * BLOCK_WIDTH
sheet.setColumnWidth(base + 0, 10 * 256)
sheet.setColumnWidth(base + 1, 30 * 256)
}

data class BlockMeta(
val companyName: String,
val plate: String,
val driverName: String,
val driverNumber: String,
)

data class ShopRow(
val truckRowId: Long,
val text: String,
val changed: Boolean,
)

data class DistrictGroup(
val district: String,
val shops: List<ShopRow>,
)

data class LaneGroup(
val laneLabel: String,
val districts: List<DistrictGroup>,
)

data class TimeGroup(
val timeLabel: String,
val lanes: List<LaneGroup>,
)

fun writeTitle(sheet: Sheet, st: Styles, titleText: String, preparedByText: String, totalBlocks: Int) {
val lastCol = (totalBlocks * BLOCK_WIDTH - 1).coerceAtLeast(0)
val r = 0
val preparedCols = 1.coerceAtMost(lastCol + 1)
val preparedFirstCol = (lastCol - preparedCols + 1).coerceAtLeast(0)
val titleLastCol = (preparedFirstCol - 1).coerceAtLeast(0)

if (preparedFirstCol == 0) {
mergeAndStyle(sheet, r, 0, lastCol, st.title, BorderStyle.MEDIUM)
cell(sheet, r, 0).setCellValue("$titleText $preparedByText")
} else {
mergeAndStyle(sheet, r, 0, titleLastCol, st.title, BorderStyle.MEDIUM)
cell(sheet, r, 0).setCellValue(titleText)
mergeAndStyle(sheet, r, preparedFirstCol, lastCol, st.titlePreparedBy, BorderStyle.MEDIUM)
cell(sheet, r, preparedFirstCol).setCellValue(preparedByText)
}
sheet.getRow(r)?.heightInPoints = 26f
}

fun writeCompanyBlock(
sheet: Sheet,
st: Styles,
blockIndex: Int,
startRow: Int,
meta: BlockMeta,
groups: List<TimeGroup>,
totalShopCount: Int,
): Int {
val baseCol = blockIndex * BLOCK_WIDTH
applyColumnWidths(sheet, blockIndex)
var r = startRow

mergeAndStyle(sheet, r, baseCol, baseCol + 1, st.company, BorderStyle.MEDIUM)
cell(sheet, r, baseCol).setCellValue(meta.companyName)
r++

mergeAndStyle(sheet, r, baseCol, baseCol + 1, st.plate, BorderStyle.THIN)
cell(sheet, r, baseCol).setCellValue(meta.plate)
r++

for (tg in groups) {
mergeAndStyle(sheet, r, baseCol, baseCol + 1, st.timeHeader, BorderStyle.MEDIUM)
cell(sheet, r, baseCol).setCellValue(tg.timeLabel)
r++

for (lg in tg.lanes) {
cell(sheet, r, baseCol).apply { cellStyle = st.laneLeft; setCellValue(lg.laneLabel) }
cell(sheet, r, baseCol + 1).cellStyle = st.laneFill
r++

for (dg in lg.districts) {
mergeAndStyle(sheet, r, baseCol, baseCol + 1, st.district, BorderStyle.THIN)
cell(sheet, r, baseCol).setCellValue(dg.district)
r++

var idx = 1
for (s in dg.shops) {
val noStyle = if (s.changed) st.shopNoChanged else st.shopNo
val txtStyle = if (s.changed) st.shopTextChanged else st.shopText
cell(sheet, r, baseCol).apply { cellStyle = noStyle; setCellValue("$idx.") }
cell(sheet, r, baseCol + 1).apply { cellStyle = txtStyle; setCellValue(s.text) }
val lines = (s.text.count { it == '\n' } + 1).coerceAtLeast(1)
sheet.getRow(r)?.heightInPoints = (16f * lines).coerceIn(18f, 90f)
r++
idx++
}
}
}
}

mergeAndStyle(sheet, r, baseCol, baseCol + 1, st.total, BorderStyle.MEDIUM)
cell(sheet, r, baseCol).setCellValue("分店數目:$totalShopCount")
r++

cell(sheet, r, baseCol).apply { cellStyle = st.driverLabel; setCellValue("車長") }
cell(sheet, r, baseCol + 1).apply { cellStyle = st.driverValue; setCellValue(meta.driverName) }
r++

mergeAndStyle(sheet, r, baseCol, baseCol + 1, st.driverValue, BorderStyle.MEDIUM)
cell(sheet, r, baseCol).setCellValue(meta.driverNumber)
r++

return r - 1
}
}


+ 308
- 0
src/main/java/com/ffii/fpsms/modules/pickOrder/service/TruckLaneVersionService.kt Wyświetl plik

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

import com.ffii.fpsms.modules.logistic.entity.LogisticRepository
import com.ffii.fpsms.modules.pickOrder.entity.*
import com.ffii.fpsms.modules.pickOrder.web.models.LogisticMasterDiffLine
import com.ffii.fpsms.modules.pickOrder.web.models.*
import jakarta.transaction.Transactional
import org.springframework.http.HttpStatus
import org.springframework.stereotype.Service
import org.springframework.web.server.ResponseStatusException
import java.time.LocalTime

@Service
open class TruckLaneVersionService(
private val truckRepository: TruckRepository,
private val truckLaneVersionRepository: TruckLaneVersionRepository,
private val truckLaneVersionLineRepository: TruckLaneVersionLineRepository,
private val logisticRepository: LogisticRepository,
) {
private fun toResponse(v: TruckLaneVersion): TruckLaneVersionResponse =
TruckLaneVersionResponse(
id = v.id ?: 0,
truckLanceCode = v.truckLanceCode ?: "",
note = v.note,
created = v.created?.toString(),
modifiedBy = v.modifiedBy,
)

/**
* 全看板 snapshot:`TruckLaneVersion.truckLanceCode` 為空(建立 snapshot 時未指定單線)。
* 另:若 line 上出現多種 `truckLanceCode`,視為全看板誤標成單線的舊資料,仍應對「整個 findAllForRouteBoard」做 extras 軟刪。
*/
private fun isFullBoardSnapshot(
version: TruckLaneVersion,
lines: List<TruckLaneVersionLine>,
): Boolean {
if (version.truckLanceCode.isNullOrBlank()) return true
val distinctLaneCodes =
lines.mapNotNull { it.truckLanceCode?.trim()?.takeIf { c -> c.isNotEmpty() } }.distinct()
return distinctLaneCodes.size > 1
}

@Transactional
open fun createSnapshot(request: CreateTruckLaneSnapshotRequest): TruckLaneVersionResponse {
val lane = request.truckLanceCode?.trim()?.takeIf { it.isNotEmpty() }

val version = TruckLaneVersion().apply {
this.truckLanceCode = lane
this.note = request.note?.trim()
}
val savedVersion = truckLaneVersionRepository.save(version)

val rows =
if (lane != null) {
truckRepository.findAllByTruckLanceCodeAndDeletedFalse(lane)
} else {
truckRepository.findAllForRouteBoard()
}
val lines = rows.map { t ->
TruckLaneVersionLine().apply {
this.truckLaneVersion = savedVersion
this.truckRowId = t.id
this.truckLanceCode = t.truckLanceCode
this.shopCode = t.shopCode
this.branchName = t.shopName
this.districtReference = t.districtReference
this.loadingSequence = t.loadingSequence
this.departureTime = t.departureTime?.toString()
this.storeId = t.storeId?.trim()?.takeIf { it.isNotEmpty() } ?: "-"
this.remark = t.remark
this.logisticId = t.logistic?.id
}
}
if (lines.isNotEmpty()) {
truckLaneVersionLineRepository.saveAll(lines)
}

return toResponse(savedVersion)
}

open fun listVersionsByLane(truckLanceCode: String): List<TruckLaneVersionResponse> {
val lane = truckLanceCode.trim()
return truckLaneVersionRepository
.findAllByTruckLanceCodeAndDeletedFalseOrderByCreatedDesc(lane)
.map(::toResponse)
}

open fun listAllVersions(): List<TruckLaneVersionResponse> {
return truckLaneVersionRepository
.findAllByDeletedFalseOrderByCreatedDesc()
.map(::toResponse)
}

open fun getVersionLines(versionId: Long): List<TruckLaneVersionLineResponse> {
return truckLaneVersionLineRepository
.findAllByTruckLaneVersion_IdAndDeletedFalseOrderByIdAsc(versionId)
.map {
TruckLaneVersionLineResponse(
truckRowId = it.truckRowId ?: 0,
truckLanceCode = it.truckLanceCode,
shopCode = it.shopCode,
branchName = it.branchName,
districtReference = it.districtReference,
loadingSequence = it.loadingSequence,
departureTime = it.departureTime,
storeId = it.storeId ?: "",
remark = it.remark,
logisticId = it.logisticId,
)
}
}

open fun diff(fromVersionId: Long, toVersionId: Long): TruckLaneVersionDiffResponse {
val fromLines = truckLaneVersionLineRepository
.findAllByTruckLaneVersion_IdAndDeletedFalseOrderByIdAsc(fromVersionId)
val toLines = truckLaneVersionLineRepository
.findAllByTruckLaneVersion_IdAndDeletedFalseOrderByIdAsc(toVersionId)

val fromByRow = fromLines.associateBy { it.truckRowId ?: -1 }
val toByRow = toLines.associateBy { it.truckRowId ?: -1 }

val allKeys = (fromByRow.keys + toByRow.keys).filter { it > 0 }.sorted()
val changed = mutableListOf<TruckLaneVersionDiffLine>()

fun s(v: Any?): String? = v?.toString()

allKeys.forEach { key ->
val a = fromByRow[key]
val b = toByRow[key]
val changes = mutableListOf<DiffFieldChange>()

if (s(a?.truckLanceCode) != s(b?.truckLanceCode)) changes.add(DiffFieldChange("truckLanceCode", s(a?.truckLanceCode), s(b?.truckLanceCode)))
if (s(a?.shopCode) != s(b?.shopCode)) changes.add(DiffFieldChange("shopCode", s(a?.shopCode), s(b?.shopCode)))
if (s(a?.branchName) != s(b?.branchName)) changes.add(DiffFieldChange("branchName", s(a?.branchName), s(b?.branchName)))
if (s(a?.districtReference) != s(b?.districtReference)) changes.add(DiffFieldChange("districtReference", s(a?.districtReference), s(b?.districtReference)))
if (s(a?.loadingSequence) != s(b?.loadingSequence)) changes.add(DiffFieldChange("loadingSequence", s(a?.loadingSequence), s(b?.loadingSequence)))
if (s(a?.departureTime) != s(b?.departureTime)) changes.add(DiffFieldChange("departureTime", s(a?.departureTime), s(b?.departureTime)))
if (s(a?.storeId) != s(b?.storeId)) changes.add(DiffFieldChange("storeId", s(a?.storeId), s(b?.storeId)))
if (s(a?.remark) != s(b?.remark)) changes.add(DiffFieldChange("remark", s(a?.remark), s(b?.remark)))
if (s(a?.logisticId) != s(b?.logisticId)) changes.add(DiffFieldChange("logisticId", s(a?.logisticId), s(b?.logisticId)))

if (changes.isNotEmpty()) {
changed.add(
TruckLaneVersionDiffLine(
truckRowId = key,
shopCode = b?.shopCode ?: a?.shopCode,
changes = changes,
truckLanceCode = b?.truckLanceCode ?: a?.truckLanceCode,
remark = b?.remark ?: a?.remark,
)
)
}
}

val fromV = truckLaneVersionRepository.findByIdAndDeletedFalse(fromVersionId)
val toV = truckLaneVersionRepository.findByIdAndDeletedFalse(toVersionId)
val logisticMasterChanges =
if (fromV != null && toV != null) {
diffLogisticMastersBetweenVersions(fromV, toV)
} else {
emptyList()
}

return TruckLaneVersionDiffResponse(
fromVersionId = fromVersionId,
toVersionId = toVersionId,
changed = changed,
logisticMasterChanges = logisticMasterChanges,
)
}

/**
* 物流主檔在兩個版本快照時間之間的新增/修改(含尚未指派到任何 truck 列者)。
*/
private fun diffLogisticMastersBetweenVersions(
fromVersion: TruckLaneVersion,
toVersion: TruckLaneVersion,
): List<LogisticMasterDiffLine> {
val fromAt = fromVersion.created ?: return emptyList()
val toAt = toVersion.created ?: return emptyList()
if (!toAt.isAfter(fromAt)) return emptyList()

fun inOpenInterval(ts: java.time.LocalDateTime?): Boolean {
if (ts == null) return false
return ts.isAfter(fromAt) && !ts.isAfter(toAt)
}

val out = ArrayList<LogisticMasterDiffLine>()
for (l in logisticRepository.findAllByDeletedFalseOrderByIdAsc()) {
val id = l.id ?: continue
val name = l.logisticName?.trim().orEmpty().ifEmpty { "—" }
val plate = l.carPlate?.trim().orEmpty().ifEmpty { "—" }
val created = l.created
val modified = l.modified

if (inOpenInterval(created)) {
out.add(
LogisticMasterDiffLine(
logisticId = id,
type = "ADDED",
logisticName = name,
carPlate = plate,
changeText = "新增物流公司:$name($plate)",
),
)
continue
}

if (created != null && !created.isAfter(fromAt) && inOpenInterval(modified)) {
out.add(
LogisticMasterDiffLine(
logisticId = id,
type = "EDITED",
logisticName = name,
carPlate = plate,
changeText = "修改物流公司:$name($plate)",
),
)
}
}
return out
}

@Transactional
open fun updateNote(versionId: Long, note: String?): TruckLaneVersionResponse {
val v = truckLaneVersionRepository.findByIdAndDeletedFalse(versionId)
?: throw ResponseStatusException(HttpStatus.NOT_FOUND, "Version not found: $versionId")
val trimmed = note?.trim()?.takeIf { it.isNotEmpty() }
v.note = trimmed
return toResponse(truckLaneVersionRepository.save(v))
}

@Transactional
open fun restore(versionId: Long): String {
val version = truckLaneVersionRepository.findByIdAndDeletedFalse(versionId)
?: throw ResponseStatusException(HttpStatus.NOT_FOUND, "Version not found: $versionId")

val lines = truckLaneVersionLineRepository.findAllByTruckLaneVersion_IdAndDeletedFalseOrderByIdAsc(versionId)
if (lines.isEmpty()) return "No lines to restore for versionId=$versionId"

val snapshottedIds = lines.mapNotNull { it.truckRowId }.filter { it > 0 }.toSet()
if (snapshottedIds.isEmpty()) {
return "No valid truckRowIds in snapshot for versionId=$versionId"
}

val fullBoard = isFullBoardSnapshot(version, lines)
if (fullBoard) {
val currentAll = truckRepository.findAllForRouteBoard()
val extras = currentAll.filter { t -> t.id != null && t.id !in snapshottedIds }
extras.forEach { it.deleted = true }
if (extras.isNotEmpty()) {
truckRepository.saveAll(extras)
}
} else {
val lane = version.truckLanceCode!!.trim()
if (lane.isNotEmpty()) {
val currentLane = truckRepository.findAllByTruckLanceCodeAndDeletedFalse(lane)
val extras = currentLane.filter { t -> t.id != null && t.id !in snapshottedIds }
extras.forEach { it.deleted = true }
if (extras.isNotEmpty()) {
truckRepository.saveAll(extras)
}
}
}

val trucksById = truckRepository.findAllById(snapshottedIds.toList()).associateBy { it.id }

val updated = lines.mapNotNull { line ->
val truckId = line.truckRowId ?: return@mapNotNull null
if (truckId <= 0) return@mapNotNull null
val truck = trucksById[truckId] ?: return@mapNotNull null

truck.deleted = false
truck.apply {
// Restore only the fields we snapshot.
this.truckLanceCode = line.truckLanceCode ?: version.truckLanceCode
this.loadingSequence = line.loadingSequence
this.districtReference = line.districtReference
val sid = line.storeId?.trim()?.takeUnless { it.isEmpty() || it == "-" }
if (sid != null) this.storeId = sid
this.shopCode = line.shopCode
this.shopName = line.branchName
this.remark = line.remark
this.departureTime =
line.departureTime?.trim()?.takeIf { it.isNotEmpty() }?.let { LocalTime.parse(it) }
val lid = line.logisticId
this.logistic =
if (lid != null && lid > 0) {
logisticRepository.findByIdAndDeletedFalse(lid)
} else {
null
}
}
}
if (updated.isNotEmpty()) {
truckRepository.saveAll(updated)
}

createSnapshot(
CreateTruckLaneSnapshotRequest(
truckLanceCode = null,
note = "restore from versionId=$versionId",
)
)

return "Restored versionId=$versionId"
}
}

+ 1311
- 31
src/main/java/com/ffii/fpsms/modules/pickOrder/service/TruckService.kt
Plik diff jest za duży
Wyświetl plik


+ 218
- 15
src/main/java/com/ffii/fpsms/modules/pickOrder/web/TruckController.kt Wyświetl plik

@@ -7,7 +7,11 @@ import org.springframework.web.bind.ServletRequestBindingException
import jakarta.servlet.http.HttpServletRequest import jakarta.servlet.http.HttpServletRequest
import org.apache.poi.ss.usermodel.Workbook import org.apache.poi.ss.usermodel.Workbook
import org.apache.poi.xssf.usermodel.XSSFWorkbook import org.apache.poi.xssf.usermodel.XSSFWorkbook
import org.springframework.http.ContentDisposition
import org.springframework.http.HttpHeaders
import org.springframework.http.MediaType
import org.springframework.http.ResponseEntity import org.springframework.http.ResponseEntity
import java.nio.charset.StandardCharsets
import org.springframework.web.bind.annotation.* import org.springframework.web.bind.annotation.*
import org.springframework.web.multipart.MultipartHttpServletRequest import org.springframework.web.multipart.MultipartHttpServletRequest


@@ -17,12 +21,19 @@ import com.ffii.fpsms.modules.pickOrder.web.models.UpdateTruckShopDetailsRequest
import com.ffii.fpsms.modules.pickOrder.service.TruckService import com.ffii.fpsms.modules.pickOrder.service.TruckService
import com.ffii.fpsms.modules.pickOrder.entity.TruckRepository import com.ffii.fpsms.modules.pickOrder.entity.TruckRepository
import com.ffii.fpsms.modules.pickOrder.web.models.SaveTruckLane import com.ffii.fpsms.modules.pickOrder.web.models.SaveTruckLane
import com.ffii.fpsms.modules.pickOrder.web.models.TruckLaneCombinationResponse
import com.ffii.fpsms.modules.pickOrder.web.models.ExportRouteLanesRequest
import com.ffii.fpsms.modules.pickOrder.web.models.ExportRouteReportRequest
import com.ffii.fpsms.modules.pickOrder.web.models.ExportTruckLaneVersionReportExcelRequest
import com.ffii.fpsms.modules.pickOrder.web.models.UpdateLaneLogisticRequest
import com.ffii.fpsms.modules.pickOrder.web.models.ParseRouteLanesExcelResponse
import com.ffii.fpsms.modules.pickOrder.web.models.deleteTruckLane import com.ffii.fpsms.modules.pickOrder.web.models.deleteTruckLane
import com.ffii.fpsms.modules.pickOrder.web.models.toLaneCombinationResponse
import jakarta.validation.Valid import jakarta.validation.Valid


@RestController @RestController
@RequestMapping("/truck") @RequestMapping("/truck")
class TruckController(
open class TruckController(
private val truckService: TruckService, private val truckService: TruckService,
private val truckRepository: TruckRepository, private val truckRepository: TruckRepository,
) { ) {
@@ -80,6 +91,142 @@ class TruckController(
} }
} }


/**
* PDF 圖1:多車線匯出;每個 laneId(encodeLaneId)一個 worksheet,格式 MTMS_ROUTE_V1。
*/
@PostMapping(
"/exportRouteLanesExcel",
consumes = [MediaType.APPLICATION_JSON_VALUE],
produces = ["application/vnd.openxmlformats-officedocument.spreadsheetml.sheet"],
)
fun exportRouteLanesExcel(@RequestBody req: ExportRouteLanesRequest): ResponseEntity<ByteArray> {
val bytes = truckService.exportRouteLanesExcelBytes(req.laneIds)
val filename = "MTMS_車線_${System.currentTimeMillis()}.xlsx"
val disposition = ContentDisposition.attachment()
.filename(filename, StandardCharsets.UTF_8)
.build()
return ResponseEntity.ok()
.header(HttpHeaders.CONTENT_DISPOSITION, disposition.toString())
.contentType(MediaType.parseMediaType("application/vnd.openxmlformats-officedocument.spreadsheetml.sheet"))
.body(bytes)
}

/**
* 圖2:車線 Report(單一 sheet;每間物流公司一個水平區塊)
*/
@PostMapping(
"/exportRouteReportExcel",
consumes = [MediaType.APPLICATION_JSON_VALUE],
produces = ["application/vnd.openxmlformats-officedocument.spreadsheetml.sheet"],
)
fun exportRouteReportExcel(
request: HttpServletRequest,
@RequestBody req: ExportRouteReportRequest,
): ResponseEntity<ByteArray> {
val preparedBy = request.userPrincipal?.name?.trim().takeUnless { it.isNullOrBlank() } ?: "current user"
val bytes = truckService.exportRouteReportExcelBytes(req.laneIds, preparedBy)
val filename = truckService.buildRouteReportFilename()
val disposition = ContentDisposition.attachment()
.filename(filename, StandardCharsets.UTF_8)
.build()
return ResponseEntity.ok()
.header(HttpHeaders.CONTENT_DISPOSITION, disposition.toString())
.contentType(MediaType.parseMediaType("application/vnd.openxmlformats-officedocument.spreadsheetml.sheet"))
.body(bytes)
}

@PostMapping(
"/exportTruckLaneVersionReportExcel",
consumes = [MediaType.APPLICATION_JSON_VALUE],
produces = ["application/vnd.openxmlformats-officedocument.spreadsheetml.sheet"],
)
open fun exportTruckLaneVersionReportExcel(
request: HttpServletRequest,
@RequestBody req: ExportTruckLaneVersionReportExcelRequest,
): ResponseEntity<ByteArray> {
val preparedBy = request.userPrincipal?.name?.trim().takeUnless { it.isNullOrBlank() } ?: "current user"
val bytes = truckService.exportTruckLaneVersionReportExcelBytes(
TruckService.ExportTruckLaneVersionReportInput(
fromVersionId = req.fromVersionId,
toVersionId = req.toVersionId,
preparedBy = preparedBy,
),
)
val filename = "車線版本報告_${System.currentTimeMillis()}.xlsx"
val disposition = ContentDisposition.attachment()
.filename(filename, StandardCharsets.UTF_8)
.build()
return ResponseEntity.ok()
.header(HttpHeaders.CONTENT_DISPOSITION, disposition.toString())
.contentType(MediaType.parseMediaType("application/vnd.openxmlformats-officedocument.spreadsheetml.sheet"))
.body(bytes)
}

/** 與 [importRouteLanesExcel] 同一格式;僅解析、不寫入 DB(看板 staged import 預覽)。 */
@PostMapping("/parseRouteLanesExcel")
@Throws(ServletRequestBindingException::class)
fun parseRouteLanesExcel(request: HttpServletRequest): ResponseEntity<ParseRouteLanesExcelResponse> {
var workbook: Workbook? = null
try {
val multipartFile = (request as MultipartHttpServletRequest).getFile("multipartFileList")
workbook = XSSFWorkbook(multipartFile?.inputStream)
return ResponseEntity.ok(truckService.parseRouteLanesExcel(workbook))
} catch (e: Exception) {
println("Error reading Excel file: ${e.message}")
return ResponseEntity.badRequest().body(
ParseRouteLanesExcelResponse(0, 0, emptyList()),
)
} finally {
try {
workbook?.close()
} catch (_: Exception) {
}
}
}

/** 與 [exportRouteLanesExcel] 同一格式;一個檔案內多 sheet,每 sheet 一條車線。 */
@PostMapping("/importRouteLanesExcel")
@Throws(ServletRequestBindingException::class)
fun importRouteLanesExcel(request: HttpServletRequest): ResponseEntity<*> {
var workbook: Workbook? = null
try {
val multipartFile = (request as MultipartHttpServletRequest).getFile("multipartFileList")
workbook = XSSFWorkbook(multipartFile?.inputStream)
} catch (e: Exception) {
println("Error reading Excel file: ${e.message}")
return ResponseEntity.badRequest().body(
MessageResponse(
id = null,
name = null,
code = null,
type = "truck",
message = "Error reading Excel file: ${e.message}",
errorPosition = null,
entity = null,
),
)
}
try {
val result = truckService.importRouteLanesExcel(workbook)
return ResponseEntity.ok(
MessageResponse(
id = null,
name = null,
code = null,
type = "truck",
message = result,
errorPosition = null,
entity = null,
),
)
} finally {
try {
workbook?.close()
} catch (_: Exception) {
}
}
}

@PostMapping("/importExcel") @PostMapping("/importExcel")
@Throws(ServletRequestBindingException::class) @Throws(ServletRequestBindingException::class)
fun importExcel(request: HttpServletRequest): ResponseEntity<*> { fun importExcel(request: HttpServletRequest): ResponseEntity<*> {
@@ -103,18 +250,25 @@ class TruckController(
) )
} }


val result = truckService.importExcel(workbook)
return ResponseEntity.ok(
MessageResponse(
id = null,
name = null,
code = null,
type = "truck",
message = result,
errorPosition = null,
entity = null
try {
val result = truckService.importExcel(workbook)
return ResponseEntity.ok(
MessageResponse(
id = null,
name = null,
code = null,
type = "truck",
message = result,
errorPosition = null,
entity = null
)
) )
)
} finally {
try {
workbook?.close()
} catch (_: Exception) {
}
}
} }


@GetMapping("/findTruckLane/{shopId}") @GetMapping("/findTruckLane/{shopId}")
@@ -136,7 +290,7 @@ class TruckController(
type = "truck", type = "truck",
message = if (truck != null) "Truck lane updated successfully" else "Truck lane not found", message = if (truck != null) "Truck lane updated successfully" else "Truck lane not found",
errorPosition = null, errorPosition = null,
entity = truck
entity = null
) )
} catch (e: Exception) { } catch (e: Exception) {
return MessageResponse( return MessageResponse(
@@ -151,6 +305,32 @@ class TruckController(
} }
} }


@PostMapping("/updateLaneLogistic")
fun updateLaneLogistic(@Valid @RequestBody request: UpdateLaneLogisticRequest): MessageResponse {
try {
val n = truckService.updateLogisticForEntireLane(request)
return MessageResponse(
id = null,
name = null,
code = request.truckLanceCode,
type = "truck",
message = "Updated logistic for $n truck row(s)",
errorPosition = null,
entity = null,
)
} catch (e: Exception) {
return MessageResponse(
id = null,
name = null,
code = null,
type = "truck",
message = "Error: ${e.message}",
errorPosition = null,
entity = null,
)
}
}

@PostMapping("/deleteTruckLane") @PostMapping("/deleteTruckLane")
fun deleteTruckLane(@Valid @RequestBody request: deleteTruckLane): MessageResponse { fun deleteTruckLane(@Valid @RequestBody request: deleteTruckLane): MessageResponse {
try { try {
@@ -178,8 +358,10 @@ class TruckController(
} }


@GetMapping("/findAllUniqueTruckLanceCodeAndRemarkCombinations") @GetMapping("/findAllUniqueTruckLanceCodeAndRemarkCombinations")
fun findAllUniqueTruckLanceCodeAndRemarkCombinations(): List<Truck> {
return truckService.findAllUniqueTruckLanceCodeAndRemarkCombinations()
fun findAllUniqueTruckLanceCodeAndRemarkCombinations(): List<TruckLaneCombinationResponse> {
return truckService
.findAllUniqueTruckLanceCodeAndRemarkCombinations()
.map { it.toLaneCombinationResponse() }
} }




@@ -193,6 +375,27 @@ class TruckController(
return truckService.findAllByTruckLanceCodeAndDeletedFalse(truckLanceCode) return truckService.findAllByTruckLanceCodeAndDeletedFalse(truckLanceCode)
} }


/**
* Filter trucks by the same (truckLanceCode, remark) group as the unique-combinations query.
* Omit `remark` or pass empty for rows with NULL/empty remark.
*/
@GetMapping("/findAllByTruckLanceCodeAndRemarkAndDeletedFalse")
fun findAllByTruckLanceCodeAndRemarkAndDeletedFalse(
@RequestParam truckLanceCode: String,
@RequestParam(required = false) remark: String?,
): List<Truck> {
return truckService.findAllByTruckLanceCodeAndRemarkAndDeletedFalse(truckLanceCode, remark)
}

/**
* RouteBoard O(1) load: return all truck rows (deleted=false) once.
* Frontend groups by (truckLanceCode, normalizedRemark).
*/
@GetMapping("/findAllForRouteBoard")
fun findAllForRouteBoard(): List<Truck> {
return truckService.findAllForRouteBoard()
}

@GetMapping("/findAllUniqueShopNamesAndCodesFromTrucks") @GetMapping("/findAllUniqueShopNamesAndCodesFromTrucks")
fun findAllUniqueShopNamesAndCodesFromTrucks(): List<Map<String, String>> { fun findAllUniqueShopNamesAndCodesFromTrucks(): List<Map<String, String>> {
return truckService.findAllUniqueShopNamesAndCodesFromTrucks() return truckService.findAllUniqueShopNamesAndCodesFromTrucks()


+ 73
- 0
src/main/java/com/ffii/fpsms/modules/pickOrder/web/TruckLaneVersionController.kt Wyświetl plik

@@ -0,0 +1,73 @@
package com.ffii.fpsms.modules.pickOrder.web

import com.ffii.fpsms.modules.master.web.models.MessageResponse
import com.ffii.fpsms.modules.pickOrder.service.TruckLaneVersionService
import com.ffii.fpsms.modules.pickOrder.web.models.CreateTruckLaneSnapshotRequest
import com.ffii.fpsms.modules.pickOrder.web.models.TruckLaneVersionDiffResponse
import com.ffii.fpsms.modules.pickOrder.web.models.TruckLaneVersionLineResponse
import com.ffii.fpsms.modules.pickOrder.web.models.TruckLaneVersionResponse
import com.ffii.fpsms.modules.pickOrder.web.models.UpdateTruckLaneVersionNoteRequest
import jakarta.validation.Valid
import org.springframework.http.ResponseEntity
import org.springframework.web.bind.annotation.*

@RestController
@RequestMapping("/truckLaneVersion")
class TruckLaneVersionController(
private val truckLaneVersionService: TruckLaneVersionService,
) {
@PostMapping("/snapshot")
fun createSnapshot(@Valid @RequestBody request: CreateTruckLaneSnapshotRequest): TruckLaneVersionResponse {
return truckLaneVersionService.createSnapshot(request)
}

@GetMapping
fun listVersions(
@RequestParam(required = false) truckLanceCode: String?,
): List<TruckLaneVersionResponse> {
val lane = truckLanceCode?.trim()?.takeIf { it.isNotEmpty() }
return if (lane != null) {
truckLaneVersionService.listVersionsByLane(lane)
} else {
truckLaneVersionService.listAllVersions()
}
}

@GetMapping("/{versionId}/lines")
fun getLines(@PathVariable versionId: Long): List<TruckLaneVersionLineResponse> {
return truckLaneVersionService.getVersionLines(versionId)
}

@PatchMapping("/{versionId}/note")
fun updateNote(
@PathVariable versionId: Long,
@Valid @RequestBody request: UpdateTruckLaneVersionNoteRequest,
): TruckLaneVersionResponse {
return truckLaneVersionService.updateNote(versionId, request.note)
}

@GetMapping("/diff")
fun diff(
@RequestParam fromVersionId: Long,
@RequestParam toVersionId: Long,
): TruckLaneVersionDiffResponse {
return truckLaneVersionService.diff(fromVersionId, toVersionId)
}

@PostMapping("/{versionId}/restore")
fun restore(@PathVariable versionId: Long): ResponseEntity<MessageResponse> {
val msg = truckLaneVersionService.restore(versionId)
return ResponseEntity.ok(
MessageResponse(
id = null,
name = null,
code = null,
type = "OK",
message = msg,
errorPosition = null,
entity = null,
)
)
}
}


+ 5
- 0
src/main/java/com/ffii/fpsms/modules/pickOrder/web/models/ExportRouteLanesRequest.kt Wyświetl plik

@@ -0,0 +1,5 @@
package com.ffii.fpsms.modules.pickOrder.web.models

data class ExportRouteLanesRequest(
val laneIds: List<String>,
)

+ 11
- 0
src/main/java/com/ffii/fpsms/modules/pickOrder/web/models/ExportRouteReportRequest.kt Wyświetl plik

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

/**
* 匯出「車線 Report」(圖2):單一 workbook/單 sheet。
* laneIds 與前端 encodeLaneId 一致:encodeURIComponent(code)|encodeURIComponent(remark)。
* 若 laneIds 為空,視為匯出 RouteBoard 全部車線。
*/
data class ExportRouteReportRequest(
val laneIds: List<String> = emptyList(),
)


+ 7
- 0
src/main/java/com/ffii/fpsms/modules/pickOrder/web/models/ExportTruckLaneVersionReportExcelRequest.kt Wyświetl plik

@@ -0,0 +1,7 @@
package com.ffii.fpsms.modules.pickOrder.web.models

data class ExportTruckLaneVersionReportExcelRequest(
val fromVersionId: Long,
val toVersionId: Long,
)


+ 22
- 0
src/main/java/com/ffii/fpsms/modules/pickOrder/web/models/ParseRouteLanesExcelModels.kt Wyświetl plik

@@ -0,0 +1,22 @@
package com.ffii.fpsms.modules.pickOrder.web.models

/** Preview row for staged route Excel import (no DB write). */
data class RouteLaneImportPreviewRow(
val truckRowId: Long?,
val truckLanceCode: String,
val remark: String?,
val storeId: String,
val departureTime: String,
val shopId: Long,
val shopName: String,
val shopCode: String,
val loadingSequence: Int,
val districtReference: String?,
val logisticId: Long?,
)

data class ParseRouteLanesExcelResponse(
val sheetCount: Int,
val rowCount: Int,
val rows: List<RouteLaneImportPreviewRow>,
)

+ 15
- 1
src/main/java/com/ffii/fpsms/modules/pickOrder/web/models/SaveTruckRequest.kt Wyświetl plik

@@ -1,4 +1,5 @@
package com.ffii.fpsms.modules.pickOrder.web.models package com.ffii.fpsms.modules.pickOrder.web.models
import jakarta.validation.constraints.NotBlank
import java.time.LocalTime import java.time.LocalTime
data class SaveTruckRequest( data class SaveTruckRequest(
val id: Long? = null, val id: Long? = null,
@@ -11,6 +12,7 @@ data class SaveTruckRequest(
val loadingSequence: Int, val loadingSequence: Int,
val remark: String? = null, val remark: String? = null,
val districtReference: String? = null, val districtReference: String? = null,
val logisticId: Long? = null,
) )
data class SaveTruckLane( data class SaveTruckLane(
val id: Long, val id: Long,
@@ -19,7 +21,10 @@ data class SaveTruckLane(
val loadingSequence: Long, val loadingSequence: Long,
val districtReference: String?, val districtReference: String?,
val storeId: String, val storeId: String,
val remark: String? = null
val remark: String? = null,
val logisticId: Long? = null,
/** When true, apply [logisticId] (including null to clear); when false, leave truck.logistic unchanged. */
val updateLogistic: Boolean = false,
) )
data class deleteTruckLane( data class deleteTruckLane(
val id: Long val id: Long
@@ -39,4 +44,13 @@ data class CreateTruckWithoutShopRequest(
val loadingSequence: Int = 0, val loadingSequence: Int = 0,
val districtReference: String? = null, val districtReference: String? = null,
val remark: String? = null, val remark: String? = null,
val logisticId: Long? = null,
)

/** 單一 transaction 更新同 (truckLanceCode, remark) 桶內所有 truck 的 logistic。 */
data class UpdateLaneLogisticRequest(
@field:NotBlank
val truckLanceCode: String,
val remark: String? = null,
val logisticId: Long? = null,
) )

+ 34
- 0
src/main/java/com/ffii/fpsms/modules/pickOrder/web/models/TruckLaneCombinationResponse.kt Wyświetl plik

@@ -0,0 +1,34 @@
package com.ffii.fpsms.modules.pickOrder.web.models

import com.ffii.fpsms.modules.pickOrder.entity.Truck
import java.time.LocalTime

/**
* 僅供 `findAllUniqueTruckLanceCodeAndRemarkCombinations` 回傳,避免序列化 JPA
* 關聯(shop / logistic)產生超大或非法 JSON。
*/
data class TruckLaneCombinationResponse(
val id: Long,
val truckLanceCode: String?,
val departureTime: LocalTime?,
val loadingSequence: Int?,
val districtReference: String?,
val storeId: String?,
val remark: String?,
val shopName: String?,
val shopCode: String?,
)

fun Truck.toLaneCombinationResponse(): TruckLaneCombinationResponse {
return TruckLaneCombinationResponse(
id = this.id ?: 0L,
truckLanceCode = this.truckLanceCode,
departureTime = this.departureTime,
loadingSequence = this.loadingSequence,
districtReference = this.districtReference,
storeId = this.storeId,
remark = this.remark,
shopName = this.shopName,
shopCode = this.shopCode,
)
}

+ 68
- 0
src/main/java/com/ffii/fpsms/modules/pickOrder/web/models/TruckLaneVersionModels.kt Wyświetl plik

@@ -0,0 +1,68 @@
package com.ffii.fpsms.modules.pickOrder.web.models

import jakarta.validation.constraints.Size

data class CreateTruckLaneSnapshotRequest(
@field:Size(max = 100)
val truckLanceCode: String? = null,
@field:Size(max = 500)
val note: String? = null,
)

data class RestoreTruckLaneSnapshotRequest(
val versionId: Long,
)

data class TruckLaneVersionResponse(
val id: Long,
val truckLanceCode: String,
val note: String?,
val created: String?,
/** BaseEntity `modifiedBy`:最後寫入此快照的使用者(通常為 JWT name / staffNo) */
val modifiedBy: String?,
)

data class TruckLaneVersionLineResponse(
val truckRowId: Long,
val truckLanceCode: String?,
val shopCode: String?,
val branchName: String?,
val districtReference: String?,
val loadingSequence: Int?,
val departureTime: String?,
val storeId: String,
val remark: String?,
val logisticId: Long?,
)

data class DiffFieldChange(
val field: String,
val from: String?,
val to: String?,
)

data class TruckLaneVersionDiffLine(
val truckRowId: Long,
val shopCode: String?,
val changes: List<DiffFieldChange>,
/** 快照中的車線代碼(優先 to 版,刪除列時 fallback from)— 供僅欄位異動時顯示車線 */
val truckLanceCode: String? = null,
val remark: String? = null,
)

/** 物流主檔異動(版本區間內新增/修改;不依賴 truck 列是否已指派) */
data class LogisticMasterDiffLine(
val logisticId: Long,
val type: String,
val logisticName: String,
val carPlate: String,
val changeText: String,
)

data class TruckLaneVersionDiffResponse(
val fromVersionId: Long,
val toVersionId: Long,
val changed: List<TruckLaneVersionDiffLine>,
val logisticMasterChanges: List<LogisticMasterDiffLine> = emptyList(),
)


+ 8
- 0
src/main/java/com/ffii/fpsms/modules/pickOrder/web/models/UpdateTruckLaneVersionNoteRequest.kt Wyświetl plik

@@ -0,0 +1,8 @@
package com.ffii.fpsms.modules.pickOrder.web.models

import jakarta.validation.constraints.Size

data class UpdateTruckLaneVersionNoteRequest(
@field:Size(max = 500)
val note: String? = null,
)

+ 12
- 12
src/main/java/com/ffii/fpsms/modules/productProcess/service/ProductProcessService.kt Wyświetl plik

@@ -2385,11 +2385,11 @@ open class ProductProcessService(
val allLines = productProcessLineRepository.findByProductProcess_Id(productProcessId) val allLines = productProcessLineRepository.findByProductProcess_Id(productProcessId)
.sortedBy { it.seqNo ?: 0L } .sortedBy { it.seqNo ?: 0L }


println("=== findNewCreatedLineIds DEBUG START ===")
println("BOM bomProcessMap: $bomProcessMap")
println("All lines (sorted by seqNo):")
//println("=== findNewCreatedLineIds DEBUG START ===")
//println("BOM bomProcessMap: $bomProcessMap")
//println("All lines (sorted by seqNo):")
allLines.forEach { line -> allLines.forEach { line ->
println(" id=${line.id}, seqNo=${line.seqNo}, bomProcessId=${line.bomProcess?.id}")
//println(" id=${line.id}, seqNo=${line.seqNo}, bomProcessId=${line.bomProcess?.id}")
} }


// 创建一个集合来跟踪哪些 line 是新创建的 // 创建一个集合来跟踪哪些 line 是新创建的
@@ -2402,18 +2402,18 @@ open class ProductProcessService(
iteration++ iteration++
hasChanges = false hasChanges = false


println("\n--- Iteration $iteration ---")
//println("\n--- Iteration $iteration ---")


// 获取剩余的 line(排除已标记为新创建的),按 seqNo 排序 // 获取剩余的 line(排除已标记为新创建的),按 seqNo 排序
val remainingLines = allLines.filter { it.id !in newCreatedLineIds } val remainingLines = allLines.filter { it.id !in newCreatedLineIds }
.sortedBy { it.seqNo ?: 0L } .sortedBy { it.seqNo ?: 0L }


println("Remaining lines (excluding new created):")
//println("Remaining lines (excluding new created):")
remainingLines.forEach { line -> remainingLines.forEach { line ->
println(" id=${line.id}, seqNo=${line.seqNo}, bomProcessId=${line.bomProcess?.id}")
//println(" id=${line.id}, seqNo=${line.seqNo}, bomProcessId=${line.bomProcess?.id}")
} }


println("New created line IDs so far: $newCreatedLineIds")
//println("New created line IDs so far: $newCreatedLineIds")


// 计算每个剩余 line 的期望 seqNo(应该是连续的 1, 2, 3...) // 计算每个剩余 line 的期望 seqNo(应该是连续的 1, 2, 3...)
val expectedSeqNoMap = remainingLines.mapIndexed { index, line -> val expectedSeqNoMap = remainingLines.mapIndexed { index, line ->
@@ -2430,7 +2430,7 @@ open class ProductProcessService(
val bomProcessId = line.bomProcess?.id val bomProcessId = line.bomProcess?.id
val expectedSeqNo = expectedSeqNoMap[line.id] ?: continue val expectedSeqNo = expectedSeqNoMap[line.id] ?: continue


println("\nChecking line id=${line.id}, seqNo=${line.seqNo}, bomProcessId=$bomProcessId, expectedSeqNo=$expectedSeqNo")
//println("\nChecking line id=${line.id}, seqNo=${line.seqNo}, bomProcessId=$bomProcessId, expectedSeqNo=$expectedSeqNo")


if (bomProcessId == null) { if (bomProcessId == null) {
println(" -> No bomProcessId, marking as new created") println(" -> No bomProcessId, marking as new created")
@@ -2442,7 +2442,7 @@ open class ProductProcessService(
// 查找这个 bomProcessId 在 BOM 中的实际 seqNo // 查找这个 bomProcessId 在 BOM 中的实际 seqNo
val bomProcessSeqNo = bomProcessMap[bomProcessId] val bomProcessSeqNo = bomProcessMap[bomProcessId]


println(" -> BOM bomProcessId=$bomProcessId has seqNo=$bomProcessSeqNo in BOM")
//println(" -> BOM bomProcessId=$bomProcessId has seqNo=$bomProcessSeqNo in BOM")


if (bomProcessSeqNo == null) { if (bomProcessSeqNo == null) {
println(" -> bomProcessId not found in BOM, marking as new created") println(" -> bomProcessId not found in BOM, marking as new created")
@@ -2461,8 +2461,8 @@ open class ProductProcessService(
} }
} }
} }
println("\n=== Final Result ===")
println("New created line IDs: $newCreatedLineIds")
//println("\n=== Final Result ===")
//println("New created line IDs: $newCreatedLineIds")
println("=== findNewCreatedLineIds DEBUG END ===\n") println("=== findNewCreatedLineIds DEBUG END ===\n")


return newCreatedLineIds return newCreatedLineIds


+ 32
- 0
src/main/java/com/ffii/fpsms/modules/purchaseOrder/entity/PurchaseOrderLineRepository.kt Wyświetl plik

@@ -3,6 +3,7 @@ package com.ffii.fpsms.modules.purchaseOrder.entity
import com.ffii.core.support.AbstractRepository import com.ffii.core.support.AbstractRepository
import com.ffii.fpsms.modules.purchaseOrder.entity.projections.PurchaseOrderLineInfo import com.ffii.fpsms.modules.purchaseOrder.entity.projections.PurchaseOrderLineInfo
import com.ffii.fpsms.modules.purchaseOrder.enums.PurchaseOrderLineStatus import com.ffii.fpsms.modules.purchaseOrder.enums.PurchaseOrderLineStatus
import org.springframework.data.domain.Pageable
import org.springframework.data.jpa.repository.Query import org.springframework.data.jpa.repository.Query
import org.springframework.data.repository.query.Param import org.springframework.data.repository.query.Param
import org.springframework.stereotype.Repository import org.springframework.stereotype.Repository
@@ -10,6 +11,37 @@ import java.io.Serializable


@Repository @Repository
interface PurchaseOrderLineRepository : AbstractRepository<PurchaseOrderLine, Long> { interface PurchaseOrderLineRepository : AbstractRepository<PurchaseOrderLine, Long> {
@Query(
"SELECT pol FROM PurchaseOrderLine pol " +
"LEFT JOIN FETCH pol.purchaseOrder po " +
"LEFT JOIN FETCH po.supplier " +
"JOIN FETCH pol.uom " +
"LEFT JOIN FETCH pol.uomM18 " +
"WHERE pol.deleted = false AND pol.item.id = :itemId AND pol.m18DataLog IS NOT NULL " +
"ORDER BY pol.created DESC",
)
fun findLatestLinesForBomM18ByItemId(
@Param("itemId") itemId: Long,
pageable: Pageable,
): List<PurchaseOrderLine>

/**
* Latest PO (by header `purchase_order.created`) for a material item code: supplier `shop.m18Id` from `purchase_order.supplierId`.
* Mirrors manual SQL: pol → items (code), po, shop on supplier, uom_conversion; order by po.created desc limit 1.
*/
@Query(
value =
"SELECT sh.m18Id FROM purchase_order_line pol " +
"LEFT JOIN items it ON pol.itemId = it.id " +
"LEFT JOIN purchase_order po ON pol.purchaseOrderId = po.id " +
"LEFT JOIN shop sh ON po.supplierId = sh.id " +
"LEFT JOIN uom_conversion um ON pol.uomIdM18 = um.id " +
"WHERE pol.deleted = false AND it.deleted = false AND it.code = :itemCode " +
"ORDER BY po.created DESC LIMIT 1",
nativeQuery = true,
)
fun findLatestPoSupplierM18IdByItemCodeNative(@Param("itemCode") itemCode: String): List<Long>

fun findByM18DataLogIdAndDeletedIsFalse(m18datalogId: Serializable): PurchaseOrderLine? fun findByM18DataLogIdAndDeletedIsFalse(m18datalogId: Serializable): PurchaseOrderLine?
fun findAllPurchaseOrderLineInfoByPurchaseOrderIdAndDeletedIsFalse(purchaseOrderId: Long): List<PurchaseOrderLineInfo> fun findAllPurchaseOrderLineInfoByPurchaseOrderIdAndDeletedIsFalse(purchaseOrderId: Long): List<PurchaseOrderLineInfo>
fun findAllByPurchaseOrderIdAndDeletedIsFalse(purchaseOrderId: Long): List<PurchaseOrderLine> fun findAllByPurchaseOrderIdAndDeletedIsFalse(purchaseOrderId: Long): List<PurchaseOrderLine>


+ 40
- 30
src/main/java/com/ffii/fpsms/modules/report/service/FGStockOutTraceabilityReportService.kt Wyświetl plik

@@ -9,13 +9,23 @@ class FGStockOutTraceabilityReportService(
) { ) {
fun getDistinctHandlersForFGStockOutTraceability(): List<String> { fun getDistinctHandlersForFGStockOutTraceability(): List<String> {
val sql = """ val sql = """
SELECT DISTINCT COALESCE(picker_user.name, modified_user.name, '') AS handler
FROM stock_out_line sol
INNER JOIN stock_out so ON sol.stockOutId = so.id AND so.deleted = 0 AND so.type = 'do'
LEFT JOIN user picker_user ON sol.handled_by = picker_user.id AND picker_user.deleted = 0
LEFT JOIN user modified_user ON sol.modifiedBy = modified_user.staffNo AND modified_user.deleted = 0 AND sol.handled_by IS NULL
WHERE sol.deleted = 0
ORDER BY handler
SELECT DISTINCT h.handler
FROM (
SELECT TRIM(COALESCE(picker_user.name, modified_user.name, '')) AS handler
FROM stock_out_line sol
INNER JOIN stock_out so ON sol.stockOutId = so.id AND so.deleted = 0 AND so.type = 'do'
LEFT JOIN user picker_user ON sol.handled_by = picker_user.id AND picker_user.deleted = 0
LEFT JOIN user modified_user ON sol.modifiedBy = modified_user.staffNo AND modified_user.deleted = 0 AND sol.handled_by IS NULL
WHERE sol.deleted = 0
UNION
SELECT TRIM(IFNULL(handlerName, '')) AS handler
FROM delivery_order_pick_order
WHERE deleted = 0
AND ticketStatus = 'completed'
AND IFNULL(handlerName, '') <> ''
) h
WHERE TRIM(IFNULL(h.handler, '')) <> ''
ORDER BY h.handler
""".trimIndent() """.trimIndent()


return jdbcDao return jdbcDao
@@ -54,7 +64,7 @@ class FGStockOutTraceabilityReportService(


val yearSql = if (!year.isNullOrBlank()) { val yearSql = if (!year.isNullOrBlank()) {
args["year"] = year args["year"] = year
"AND YEAR(IFNULL(dpor.RequiredDeliveryDate, do.estimatedArrivalDate)) = :year"
"AND YEAR(IFNULL(dopo.requiredDeliveryDate, do.estimatedArrivalDate)) = :year"
} else { } else {
"" ""
} }
@@ -62,7 +72,7 @@ class FGStockOutTraceabilityReportService(
val lastOutDateStartSql = if (!lastOutDateStart.isNullOrBlank()) { val lastOutDateStartSql = if (!lastOutDateStart.isNullOrBlank()) {
val formattedDate = lastOutDateStart.replace("/", "-") val formattedDate = lastOutDateStart.replace("/", "-")
args["lastOutDateStart"] = formattedDate args["lastOutDateStart"] = formattedDate
"AND DATE(IFNULL(dpor.RequiredDeliveryDate, do.estimatedArrivalDate)) >= DATE(:lastOutDateStart)"
"AND DATE(IFNULL(dopo.requiredDeliveryDate, do.estimatedArrivalDate)) >= DATE(:lastOutDateStart)"
} else { } else {
"" ""
} }
@@ -70,14 +80,14 @@ class FGStockOutTraceabilityReportService(
val lastOutDateEndSql = if (!lastOutDateEnd.isNullOrBlank()) { val lastOutDateEndSql = if (!lastOutDateEnd.isNullOrBlank()) {
val formattedDate = lastOutDateEnd.replace("/", "-") val formattedDate = lastOutDateEnd.replace("/", "-")
args["lastOutDateEnd"] = formattedDate args["lastOutDateEnd"] = formattedDate
"AND DATE(IFNULL(dpor.RequiredDeliveryDate, do.estimatedArrivalDate)) <= DATE(:lastOutDateEnd)"
"AND DATE(IFNULL(dopo.requiredDeliveryDate, do.estimatedArrivalDate)) <= DATE(:lastOutDateEnd)"
} else { } else {
"" ""
} }


val handlerSql = buildMultiValueExactClause( val handlerSql = buildMultiValueExactClause(
handler, handler,
"COALESCE(picker_user.name, modified_user.name, '')",
"COALESCE(picker_user.name, modified_user.name, IFNULL(dopo.handlerName, ''))",
"handler", "handler",
args, args,
) )
@@ -85,13 +95,13 @@ class FGStockOutTraceabilityReportService(
val sql = """ val sql = """
SELECT SELECT
IFNULL(DATE_FORMAT( IFNULL(DATE_FORMAT(
IFNULL(dpor.RequiredDeliveryDate, do.estimatedArrivalDate),
IFNULL(dopo.requiredDeliveryDate, do.estimatedArrivalDate),
'%Y-%m-%d' '%Y-%m-%d'
), '') AS deliveryDate, ), '') AS deliveryDate,
IFNULL(it.code, '') AS itemNo, IFNULL(it.code, '') AS itemNo,
IFNULL(it.name, '') AS itemName, IFNULL(it.name, '') AS itemName,
IFNULL(uc.udfudesc, '') AS unitOfMeasure, IFNULL(uc.udfudesc, '') AS unitOfMeasure,
IFNULL(dpor.deliveryNoteCode, '') AS dnNo,
IFNULL(dopo.deliveryNoteCode, '') AS dnNo,
CAST(IFNULL(sp.id, 0) AS CHAR) AS customerId, CAST(IFNULL(sp.id, 0) AS CHAR) AS customerId,
IFNULL(sp.name, '') AS customerName, IFNULL(sp.name, '') AS customerName,
FORMAT( FORMAT(
@@ -109,11 +119,13 @@ class FGStockOutTraceabilityReportService(
COALESCE( COALESCE(
picker_user.name, picker_user.name,
modified_user.name, modified_user.name,
dopo.handlerName,
'' ''
) AS handler, ) AS handler,
COALESCE( COALESCE(
picker_user.name, picker_user.name,
modified_user.name, modified_user.name,
dopo.handlerName,
'' ''
) AS pickedBy, ) AS pickedBy,
GROUP_CONCAT(DISTINCT wh.code ORDER BY wh.code SEPARATOR ', ') AS storeLocation, GROUP_CONCAT(DISTINCT wh.code ORDER BY wh.code SEPARATOR ', ') AS storeLocation,
@@ -122,19 +134,22 @@ class FGStockOutTraceabilityReportService(
ROUND(SUM(IFNULL(sol.qty, 0)) OVER (PARTITION BY it.code), 0), 0 ROUND(SUM(IFNULL(sol.qty, 0)) OVER (PARTITION BY it.code), 0), 0
) AS totalStockOutQty, ) AS totalStockOutQty,
0 AS stockSubCategory 0 AS stockSubCategory
FROM do_pick_order_line_record dpolr
LEFT JOIN do_pick_order_record dpor
ON dpolr.record_id = dpor.id
AND dpor.deleted = 0
AND dpor.ticket_status = 'completed'
FROM delivery_order_pick_order dopo
INNER JOIN pick_order po
ON po.deliveryOrderPickOrderId = dopo.id
AND po.deleted = 0
INNER JOIN delivery_order do INNER JOIN delivery_order do
ON dpolr.do_order_id = do.id
ON po.doId = do.id
AND do.deleted = 0 AND do.deleted = 0
LEFT JOIN shop sp LEFT JOIN shop sp
ON do.shopId = sp.id ON do.shopId = sp.id
AND sp.deleted = 0 AND sp.deleted = 0
LEFT JOIN pick_order_line pol
ON pol.poId = po.id
AND pol.deleted = 0
LEFT JOIN delivery_order_line dol LEFT JOIN delivery_order_line dol
ON do.id = dol.deliveryOrderId
ON dol.deliveryOrderId = do.id
AND dol.itemId = pol.itemId
AND dol.deleted = 0 AND dol.deleted = 0
LEFT JOIN items it LEFT JOIN items it
ON dol.itemId = it.id ON dol.itemId = it.id
@@ -144,13 +159,6 @@ class FGStockOutTraceabilityReportService(
AND iu.stockUnit = 1 AND iu.stockUnit = 1
LEFT JOIN uom_conversion uc LEFT JOIN uom_conversion uc
ON iu.uomId = uc.id ON iu.uomId = uc.id
LEFT JOIN pick_order_line pol
ON dpolr.pick_order_id = pol.poId
AND pol.itemId = it.id
AND pol.deleted = 0
LEFT JOIN pick_order po
ON pol.poId = po.id
AND po.deleted = 0
LEFT JOIN stock_out_line sol LEFT JOIN stock_out_line sol
ON pol.id = sol.pickOrderLineId ON pol.id = sol.pickOrderLineId
AND sol.itemId = it.id AND sol.itemId = it.id
@@ -176,7 +184,8 @@ class FGStockOutTraceabilityReportService(
AND modified_user.deleted = 0 AND modified_user.deleted = 0
AND sol.handled_by IS NULL AND sol.handled_by IS NULL
WHERE WHERE
dpolr.deleted = 0
dopo.deleted = 0
AND dopo.ticketStatus = 'completed'
$stockCategorySql $stockCategorySql
$stockSubCategorySql $stockSubCategorySql
$itemCodeSql $itemCodeSql
@@ -186,12 +195,13 @@ class FGStockOutTraceabilityReportService(
$handlerSql $handlerSql
GROUP BY GROUP BY
sol.id, sol.id,
dpor.RequiredDeliveryDate,
dopo.requiredDeliveryDate,
dopo.handlerName,
do.estimatedArrivalDate, do.estimatedArrivalDate,
it.code, it.code,
it.name, it.name,
uc.udfudesc, uc.udfudesc,
dpor.deliveryNoteCode,
dopo.deliveryNoteCode,
sp.id, sp.id,
sp.name, sp.name,
sol.qty, sol.qty,


+ 44
- 43
src/main/java/com/ffii/fpsms/modules/report/service/ReportService.kt Wyświetl plik

@@ -10,6 +10,7 @@ import com.ffii.fpsms.m18.M18GrnRules
import com.ffii.fpsms.modules.master.entity.ShopRepository import com.ffii.fpsms.modules.master.entity.ShopRepository
import com.ffii.fpsms.modules.master.enums.ShopType import com.ffii.fpsms.modules.master.enums.ShopType
import com.ffii.fpsms.modules.master.service.ItemUomService import com.ffii.fpsms.modules.master.service.ItemUomService
import com.ffii.fpsms.modules.deliveryOrder.service.DoFloorSupplierSettingsService
import java.math.BigDecimal import java.math.BigDecimal
import net.sf.jasperreports.engine.export.ooxml.JRXlsxExporter import net.sf.jasperreports.engine.export.ooxml.JRXlsxExporter
import net.sf.jasperreports.export.SimpleExporterInput import net.sf.jasperreports.export.SimpleExporterInput
@@ -20,6 +21,7 @@ open class ReportService(
private val jdbcDao: JdbcDao, private val jdbcDao: JdbcDao,
private val itemUomService: ItemUomService, private val itemUomService: ItemUomService,
private val shopRepository: ShopRepository, private val shopRepository: ShopRepository,
private val doFloorSupplierSettingsService: DoFloorSupplierSettingsService,
) { ) {
/** /**
* Queries the database for inventory data based on dates and optional item type. * Queries the database for inventory data based on dates and optional item type.
@@ -101,7 +103,7 @@ open class ReportService(
val yearSql = if (!year.isNullOrBlank()) { val yearSql = if (!year.isNullOrBlank()) {
args["year"] = year args["year"] = year
"AND YEAR(IFNULL(dpor.RequiredDeliveryDate, do.estimatedArrivalDate)) = :year"
"AND YEAR(IFNULL(dopo.requiredDeliveryDate, do.estimatedArrivalDate)) = :year"
} else { } else {
"" ""
} }
@@ -109,25 +111,27 @@ open class ReportService(
val lastOutDateStartSql = if (!lastOutDateStart.isNullOrBlank()) { val lastOutDateStartSql = if (!lastOutDateStart.isNullOrBlank()) {
val formattedDate = lastOutDateStart.replace("/", "-") val formattedDate = lastOutDateStart.replace("/", "-")
args["lastOutDateStart"] = formattedDate args["lastOutDateStart"] = formattedDate
"AND DATE(IFNULL(dpor.RequiredDeliveryDate, do.estimatedArrivalDate)) >= DATE(:lastOutDateStart)"
"AND DATE(IFNULL(dopo.requiredDeliveryDate, do.estimatedArrivalDate)) >= DATE(:lastOutDateStart)"
} else "" } else ""
val lastOutDateEndSql = if (!lastOutDateEnd.isNullOrBlank()) { val lastOutDateEndSql = if (!lastOutDateEnd.isNullOrBlank()) {
val formattedDate = lastOutDateEnd.replace("/", "-") val formattedDate = lastOutDateEnd.replace("/", "-")
args["lastOutDateEnd"] = formattedDate args["lastOutDateEnd"] = formattedDate
"AND DATE(IFNULL(dpor.RequiredDeliveryDate, do.estimatedArrivalDate)) <= DATE(:lastOutDateEnd)"
"AND DATE(IFNULL(dopo.requiredDeliveryDate, do.estimatedArrivalDate)) <= DATE(:lastOutDateEnd)"
} else "" } else ""
val supplierFloorSqlCases = doFloorSupplierSettingsService.sqlPreferredFloorCases("supplier.code")

val sql = """ val sql = """
SELECT SELECT
IFNULL(DATE_FORMAT( IFNULL(DATE_FORMAT(
IFNULL(dpor.RequiredDeliveryDate, do.estimatedArrivalDate),
IFNULL(dopo.requiredDeliveryDate, do.estimatedArrivalDate),
'%Y-%m-%d' '%Y-%m-%d'
), '') AS deliveryDate, ), '') AS deliveryDate,
IFNULL(it.code, '') AS itemNo, IFNULL(it.code, '') AS itemNo,
IFNULL(it.name, '') AS itemName, IFNULL(it.name, '') AS itemName,
IFNULL(uc.udfudesc, '') AS unitOfMeasure, IFNULL(uc.udfudesc, '') AS unitOfMeasure,
IFNULL(dpor.deliveryNoteCode, '') AS dnNo,
IFNULL(dopo.deliveryNoteCode, '') AS dnNo,
CAST(IFNULL(sp.id, 0) AS CHAR) AS customerId, CAST(IFNULL(sp.id, 0) AS CHAR) AS customerId,
IFNULL(sp.name, '') AS customerName, IFNULL(sp.name, '') AS customerName,
CAST( CAST(
@@ -138,28 +142,20 @@ open class ReportService(


FORMAT(ROUND(IFNULL(IFNULL(sol.qty, dol.qty), 0), 0), 0) AS qty, FORMAT(ROUND(IFNULL(IFNULL(sol.qty, dol.qty), 0), 0), 0) AS qty,
COALESCE( COALESCE(
dpor.TruckLanceCode,
dopo.truckLanceCode,
(SELECT t2.TruckLanceCode (SELECT t2.TruckLanceCode
FROM truck t2 FROM truck t2
WHERE t2.shopId = do.shopId WHERE t2.shopId = do.shopId
AND t2.deleted = 0 AND t2.deleted = 0
AND t2.Store_id = CASE
WHEN supplier.code = 'P06B' THEN '4F'
WHEN supplier.code IN ('P07', 'P06D') THEN '2F'
ELSE NULL
END
AND t2.Store_id = ${supplierFloorSqlCases.floorStringCase}
AND ( AND (
(CASE
WHEN supplier.code = 'P06B' THEN '4F'
WHEN supplier.code IN ('P07', 'P06D') THEN '2F'
ELSE NULL
END
(${supplierFloorSqlCases.floorStringCase}
AND (SELECT COUNT(*) FROM truck t3 AND (SELECT COUNT(*) FROM truck t3
WHERE t3.shopId = do.shopId AND t3.deleted = 0 WHERE t3.shopId = do.shopId AND t3.deleted = 0
AND t3.Store_id = '4F') > 1 AND t3.Store_id = '4F') > 1
AND IFNULL(dpor.RequiredDeliveryDate, do.estimatedArrivalDate) IS NOT NULL
AND IFNULL(dopo.requiredDeliveryDate, do.estimatedArrivalDate) IS NOT NULL
AND t2.TruckLanceCode LIKE CONCAT('%', AND t2.TruckLanceCode LIKE CONCAT('%',
CASE DAYNAME(IFNULL(dpor.RequiredDeliveryDate, do.estimatedArrivalDate))
CASE DAYNAME(IFNULL(dopo.requiredDeliveryDate, do.estimatedArrivalDate))
WHEN 'Monday' THEN 'Mon' WHEN 'Monday' THEN 'Mon'
WHEN 'Tuesday' THEN 'Tue' WHEN 'Tuesday' THEN 'Tue'
WHEN 'Wednesday' THEN 'Wed' WHEN 'Wednesday' THEN 'Wed'
@@ -170,11 +166,7 @@ FORMAT(ROUND(IFNULL(IFNULL(sol.qty, dol.qty), 0), 0), 0) AS qty,
ELSE '' ELSE ''
END, '%')) END, '%'))
OR OR
t2.Store_id = CASE
WHEN supplier.code = 'P06B' THEN '4F'
WHEN supplier.code IN ('P07', 'P06D') THEN '2F'
ELSE NULL
END
t2.Store_id = ${supplierFloorSqlCases.floorStringCase}
) )
ORDER BY t2.DepartureTime ASC ORDER BY t2.DepartureTime ASC
LIMIT 1), LIMIT 1),
@@ -183,13 +175,12 @@ FORMAT(ROUND(IFNULL(IFNULL(sol.qty, dol.qty), 0), 0), 0) AS qty,
'' AS driver, '' AS driver,
IFNULL(do.code, '') AS deliveryOrderNo, IFNULL(do.code, '') AS deliveryOrderNo,
IFNULL(qc.name, '') AS stockSubCategory IFNULL(qc.name, '') AS stockSubCategory
FROM do_pick_order_line_record dpolr
LEFT JOIN do_pick_order_record dpor
ON dpolr.do_pick_order_id = dpor.record_id
AND dpor.deleted = 0
AND dpor.ticket_status = 'completed'
FROM delivery_order_pick_order dopo
INNER JOIN pick_order po
ON po.deliveryOrderPickOrderId = dopo.id
AND po.deleted = 0
INNER JOIN delivery_order do INNER JOIN delivery_order do
ON dpolr.do_order_id = do.id
ON po.doId = do.id
AND do.deleted = 0 AND do.deleted = 0
LEFT JOIN shop supplier LEFT JOIN shop supplier
ON do.supplierId = supplier.id ON do.supplierId = supplier.id
@@ -197,8 +188,12 @@ FORMAT(ROUND(IFNULL(IFNULL(sol.qty, dol.qty), 0), 0), 0) AS qty,
LEFT JOIN shop sp LEFT JOIN shop sp
ON do.shopId = sp.id ON do.shopId = sp.id
AND sp.deleted = 0 AND sp.deleted = 0
LEFT JOIN pick_order_line pol
ON pol.poId = po.id
AND pol.deleted = 0
LEFT JOIN delivery_order_line dol LEFT JOIN delivery_order_line dol
ON do.id = dol.deliveryOrderId
ON dol.deliveryOrderId = do.id
AND dol.itemId = pol.itemId
AND dol.deleted = 0 AND dol.deleted = 0
LEFT JOIN items it LEFT JOIN items it
ON dol.itemId = it.id ON dol.itemId = it.id
@@ -215,10 +210,6 @@ FORMAT(ROUND(IFNULL(IFNULL(sol.qty, dol.qty), 0), 0), 0) AS qty,
AND iu.stockUnit = 1 AND iu.stockUnit = 1
LEFT JOIN uom_conversion uc LEFT JOIN uom_conversion uc
ON iu.uomId = uc.id ON iu.uomId = uc.id
LEFT JOIN pick_order_line pol
ON dpolr.pick_order_id = pol.poId
AND pol.itemId = it.id
AND pol.deleted = 0
LEFT JOIN stock_out_line sol LEFT JOIN stock_out_line sol
ON pol.id = sol.pickOrderLineId ON pol.id = sol.pickOrderLineId
AND sol.itemId = it.id AND sol.itemId = it.id
@@ -234,8 +225,8 @@ FORMAT(ROUND(IFNULL(IFNULL(sol.qty, dol.qty), 0), 0), 0) AS qty,
ON il.stockInLineId = sil.id ON il.stockInLineId = sil.id
AND sil.deleted = 0 AND sil.deleted = 0
WHERE WHERE
dpolr.deleted = 0
AND (dpor.id IS NULL OR dpor.ticket_status = 'completed')
dopo.deleted = 0
AND dopo.ticketStatus = 'completed'
AND COALESCE(sol.qty, dol.qty, 0) <> 0 AND COALESCE(sol.qty, dol.qty, 0) <> 0
$stockCategorySql $stockCategorySql
$stockSubCategorySql $stockSubCategorySql
@@ -258,13 +249,23 @@ return result


fun getDistinctHandlersForFGStockOutTraceability(): List<String> { fun getDistinctHandlersForFGStockOutTraceability(): List<String> {
val sql = """ val sql = """
SELECT DISTINCT COALESCE(picker_user.name, modified_user.name, '') AS handler
FROM stock_out_line sol
INNER JOIN stock_out so ON sol.stockOutId = so.id AND so.deleted = 0 AND so.type = 'do'
LEFT JOIN user picker_user ON sol.handled_by = picker_user.id AND picker_user.deleted = 0
LEFT JOIN user modified_user ON sol.modifiedBy = modified_user.staffNo AND modified_user.deleted = 0 AND sol.handled_by IS NULL
WHERE sol.deleted = 0
ORDER BY handler
SELECT DISTINCT h.handler
FROM (
SELECT TRIM(COALESCE(picker_user.name, modified_user.name, '')) AS handler
FROM stock_out_line sol
INNER JOIN stock_out so ON sol.stockOutId = so.id AND so.deleted = 0 AND so.type = 'do'
LEFT JOIN user picker_user ON sol.handled_by = picker_user.id AND picker_user.deleted = 0
LEFT JOIN user modified_user ON sol.modifiedBy = modified_user.staffNo AND modified_user.deleted = 0 AND sol.handled_by IS NULL
WHERE sol.deleted = 0
UNION
SELECT TRIM(IFNULL(handlerName, '')) AS handler
FROM delivery_order_pick_order
WHERE deleted = 0
AND ticketStatus = 'completed'
AND IFNULL(handlerName, '') <> ''
) h
WHERE TRIM(IFNULL(h.handler, '')) <> ''
ORDER BY h.handler
""".trimIndent() """.trimIndent()
return jdbcDao.queryForList(sql, emptyMap<String, Any>()).map { row -> (row["handler"]?.toString() ?: "").trim() }.filter { it.isNotBlank() } return jdbcDao.queryForList(sql, emptyMap<String, Any>()).map { row -> (row["handler"]?.toString() ?: "").trim() }.filter { it.isNotBlank() }
} }


+ 14
- 2
src/main/java/com/ffii/fpsms/modules/settings/web/SettingsController.java Wyświetl plik

@@ -5,6 +5,7 @@ import java.util.List;
import org.springframework.http.HttpStatus; import org.springframework.http.HttpStatus;
import org.springframework.web.bind.annotation.GetMapping; import org.springframework.web.bind.annotation.GetMapping;
import org.springframework.web.bind.annotation.PatchMapping; import org.springframework.web.bind.annotation.PatchMapping;
import org.springframework.web.bind.annotation.PostMapping;
import org.springframework.web.bind.annotation.PathVariable; import org.springframework.web.bind.annotation.PathVariable;
import org.springframework.web.bind.annotation.RequestBody; import org.springframework.web.bind.annotation.RequestBody;
import org.springframework.web.bind.annotation.RequestMapping; import org.springframework.web.bind.annotation.RequestMapping;
@@ -41,13 +42,24 @@ public class SettingsController{
// @PreAuthorize("hasAuthority('ADMIN')") // @PreAuthorize("hasAuthority('ADMIN')")
@ResponseStatus(HttpStatus.NO_CONTENT) @ResponseStatus(HttpStatus.NO_CONTENT)
public void update(@PathVariable String name, @RequestBody @Valid UpdateReq body) { public void update(@PathVariable String name, @RequestBody @Valid UpdateReq body) {
applyUpdate(name, body);
}

/** Same as PATCH; use from browsers where CORS preflight for PATCH is blocked. */
@PostMapping("/{name}")
@ResponseStatus(HttpStatus.NO_CONTENT)
public void updatePost(@PathVariable String name, @RequestBody @Valid UpdateReq body) {
applyUpdate(name, body);
}

private void applyUpdate(String name, UpdateReq body) {
Settings entity = this.settingsService.findByName(name) Settings entity = this.settingsService.findByName(name)
.orElseThrow(NotFoundException::new); .orElseThrow(NotFoundException::new);
if (!this.settingsService.validateType(entity.getType(), body.value)) {
if (!this.settingsService.validateType(entity.getType(), body.getValue())) {
throw new BadRequestException(); throw new BadRequestException();
} }


entity.setValue(body.value);
entity.setValue(body.getValue());
this.settingsService.save(entity); this.settingsService.save(entity);
} }




+ 16
- 5
src/main/java/com/ffii/fpsms/modules/stock/entity/InventoryRepository.kt Wyświetl plik

@@ -14,11 +14,22 @@ import java.util.Optional
interface InventoryRepository: AbstractRepository<Inventory, Long> { interface InventoryRepository: AbstractRepository<Inventory, Long> {
fun findInventoryInfoByDeletedIsFalse(): List<InventoryInfo> fun findInventoryInfoByDeletedIsFalse(): List<InventoryInfo>


@Query("SELECT i FROM Inventory i " +
"WHERE (:code IS NULL OR i.item.code LIKE CONCAT('%', :code, '%')) " +
"AND (:name IS NULL OR i.item.name LIKE CONCAT('%', :name, '%')) " +
"AND (:type IS NULL OR :type = '' OR i.item.type = :type) " +
"AND i.deleted = false")
@Query(
"""
SELECT i FROM Inventory i
WHERE (:code IS NULL OR i.item.code LIKE CONCAT('%', :code, '%'))
AND (:name IS NULL OR i.item.name LIKE CONCAT('%', :name, '%'))
AND (:type IS NULL OR :type = '' OR i.item.type = :type)
AND i.deleted = false
AND EXISTS (
SELECT 1 FROM ItemUom iu
WHERE iu.item.id = i.item.id
AND iu.deleted = false
AND iu.baseUnit = true
AND iu.uom.id = i.uom.id
)
"""
)
fun findInventoryInfoByItemCodeContainsAndItemNameContainsAndItemTypeAndDeletedIsFalse(code: String, name: String, type: String, pageable: Pageable): Page<InventoryInfo> fun findInventoryInfoByItemCodeContainsAndItemNameContainsAndItemTypeAndDeletedIsFalse(code: String, name: String, type: String, pageable: Pageable): Page<InventoryInfo>


@Query("SELECT i FROM Inventory i " + @Query("SELECT i FROM Inventory i " +


+ 13
- 12
src/main/java/com/ffii/fpsms/modules/stock/entity/StockLedgerRepository.kt Wyświetl plik

@@ -1,11 +1,12 @@
package com.ffii.fpsms.modules.stock.entity package com.ffii.fpsms.modules.stock.entity


import com.ffii.core.support.AbstractRepository import com.ffii.core.support.AbstractRepository
import org.springframework.data.domain.Page
import org.springframework.data.domain.Pageable
import org.springframework.data.jpa.repository.Query import org.springframework.data.jpa.repository.Query
import org.springframework.data.repository.query.Param import org.springframework.data.repository.query.Param
import org.springframework.stereotype.Repository import org.springframework.stereotype.Repository
import java.time.LocalDate
import java.util.Optional
import java.time.LocalDateTime
@Repository @Repository
interface StockLedgerRepository: AbstractRepository<StockLedger, Long> { interface StockLedgerRepository: AbstractRepository<StockLedger, Long> {
@@ -19,17 +20,17 @@ interface StockLedgerRepository: AbstractRepository<StockLedger, Long> {
AND (:itemCode IS NULL OR sl.itemCode LIKE CONCAT('%', :itemCode, '%')) AND (:itemCode IS NULL OR sl.itemCode LIKE CONCAT('%', :itemCode, '%'))
AND (:itemName IS NULL OR i.name LIKE CONCAT('%', :itemName, '%')) AND (:itemName IS NULL OR i.name LIKE CONCAT('%', :itemName, '%'))
AND (:type IS NULL OR sl.type = :type) AND (:type IS NULL OR sl.type = :type)
AND (:startDate IS NULL OR DATE(sl.created) >= :startDate)
AND (:endDate IS NULL OR DATE(sl.created) <= :endDate)
ORDER BY sl.created ASC, sl.itemId
AND (:startDateTime IS NULL OR sl.created >= :startDateTime)
AND (:endDateExclusive IS NULL OR sl.created < :endDateExclusive)
""") """)
fun findStockTransactions( fun findStockTransactions(
@Param("itemCode") itemCode: String?, @Param("itemCode") itemCode: String?,
@Param("itemName") itemName: String?, @Param("itemName") itemName: String?,
@Param("type") type: String?, @Param("type") type: String?,
@Param("startDate") startDate: LocalDate?,
@Param("endDate") endDate: LocalDate?
): List<StockLedger>
@Param("startDateTime") startDateTime: LocalDateTime?,
@Param("endDateExclusive") endDateExclusive: LocalDateTime?,
pageable: Pageable
): Page<StockLedger>
@Query(""" @Query("""
SELECT COUNT(sl) FROM StockLedger sl SELECT COUNT(sl) FROM StockLedger sl
@@ -39,15 +40,15 @@ interface StockLedgerRepository: AbstractRepository<StockLedger, Long> {
AND (:itemCode IS NULL OR sl.itemCode LIKE CONCAT('%', :itemCode, '%')) AND (:itemCode IS NULL OR sl.itemCode LIKE CONCAT('%', :itemCode, '%'))
AND (:itemName IS NULL OR i.name LIKE CONCAT('%', :itemName, '%')) AND (:itemName IS NULL OR i.name LIKE CONCAT('%', :itemName, '%'))
AND (:type IS NULL OR sl.type = :type) AND (:type IS NULL OR sl.type = :type)
AND (:startDate IS NULL OR DATE(sl.created) >= :startDate)
AND (:endDate IS NULL OR DATE(sl.created) <= :endDate)
AND (:startDateTime IS NULL OR sl.created >= :startDateTime)
AND (:endDateExclusive IS NULL OR sl.created < :endDateExclusive)
""") """)
fun countStockTransactions( fun countStockTransactions(
@Param("itemCode") itemCode: String?, @Param("itemCode") itemCode: String?,
@Param("itemName") itemName: String?, @Param("itemName") itemName: String?,
@Param("type") type: String?, @Param("type") type: String?,
@Param("startDate") startDate: LocalDate?,
@Param("endDate") endDate: LocalDate?
@Param("startDateTime") startDateTime: LocalDateTime?,
@Param("endDateExclusive") endDateExclusive: LocalDateTime?
): Long ): Long






+ 19
- 34
src/main/java/com/ffii/fpsms/modules/stock/service/StockTakeRecordService.kt Wyświetl plik

@@ -16,6 +16,7 @@ import java.time.LocalDateTime
import java.math.BigDecimal import java.math.BigDecimal
import com.ffii.fpsms.modules.user.entity.UserRepository import com.ffii.fpsms.modules.user.entity.UserRepository
import org.springframework.data.domain.PageRequest import org.springframework.data.domain.PageRequest
import org.springframework.data.domain.Sort
import com.ffii.core.response.RecordsRes import com.ffii.core.response.RecordsRes
import com.ffii.fpsms.modules.stock.service.InventoryLotLineService import com.ffii.fpsms.modules.stock.service.InventoryLotLineService
import com.ffii.fpsms.modules.stock.entity.StockTakeLine import com.ffii.fpsms.modules.stock.entity.StockTakeLine
@@ -2741,40 +2742,32 @@ open fun searchStockTransactions(request: SearchStockTransactionRequest): Record
return RecordsRes(emptyList(), 0) return RecordsRes(emptyList(), 0)
} }


val startDate = request.startDate
val endDate = request.endDate
val startDateTime = request.startDate?.atStartOfDay()
val endDateExclusive = request.endDate?.plusDays(1)?.atStartOfDay()


println("Processed params: itemCode=$itemCode, itemName=$itemName, startDate=$startDate, endDate=$endDate")

val total = stockLedgerRepository.countStockTransactions(
itemCode = itemCode,
itemName = itemName,
type = request.type,
startDate = startDate,
endDate = endDate
println(
"Processed params: itemCode=$itemCode, itemName=$itemName, " +
"startDateTime=$startDateTime, endDateExclusive=$endDateExclusive"
) )


println("Total count: $total")

val actualPageSize = if (request.pageSize == 100) {
total.toInt().coerceAtLeast(1)
} else {
request.pageSize
}

val offset = request.pageNum * actualPageSize
val pageable = PageRequest.of(
request.pageNum.coerceAtLeast(0),
request.pageSize.coerceAtLeast(1),
Sort.by(Sort.Order.asc("created"), Sort.Order.asc("itemId"))
)


val ledgers = stockLedgerRepository.findStockTransactions(
val ledgerPage = stockLedgerRepository.findStockTransactions(
itemCode = itemCode, itemCode = itemCode,
itemName = itemName, itemName = itemName,
type = request.type, type = request.type,
startDate = startDate,
endDate = endDate
startDateTime = startDateTime,
endDateExclusive = endDateExclusive,
pageable = pageable
) )


println("Found ${ledgers.size} ledgers")
println("Found ${ledgerPage.numberOfElements} ledgers in current page, total=${ledgerPage.totalElements}")


val transactions = ledgers.map { ledger ->
val transactions = ledgerPage.content.map { ledger ->
val stockInLine = ledger.stockInLine val stockInLine = ledger.stockInLine
val stockOutLine = ledger.stockOutLine val stockOutLine = ledger.stockOutLine


@@ -2805,17 +2798,9 @@ open fun searchStockTransactions(request: SearchStockTransactionRequest): Record
) )
} }


val sortedTransactions = transactions.sortedWith(
compareBy<StockTransactionResponse>(
{ it.date ?: it.transactionDate?.toLocalDate() },
{ it.transactionDate }
)
)

val paginatedTransactions = sortedTransactions.drop(offset).take(actualPageSize)
val totalTime = System.currentTimeMillis() - startTime val totalTime = System.currentTimeMillis() - startTime
println("Total time (Repository query): ${totalTime}ms, count: ${paginatedTransactions.size}, total: $total")
println("Total time (Repository query): ${totalTime}ms, count: ${transactions.size}, total: ${ledgerPage.totalElements}")


return RecordsRes(paginatedTransactions, total.toInt())
return RecordsRes(transactions, ledgerPage.totalElements.toInt())
} }
} }

+ 10
- 6
src/main/java/com/ffii/fpsms/modules/stock/service/SuggestedPickLotService.kt Wyświetl plik

@@ -42,6 +42,7 @@ import com.ffii.fpsms.modules.stock.entity.projection.StockOutLineInfo
import com.ffii.fpsms.modules.stock.entity.StockOutLIneRepository import com.ffii.fpsms.modules.stock.entity.StockOutLIneRepository
import com.ffii.fpsms.modules.stock.web.model.StockOutStatus import com.ffii.fpsms.modules.stock.web.model.StockOutStatus
import com.ffii.fpsms.modules.common.SecurityUtils import com.ffii.fpsms.modules.common.SecurityUtils
import com.ffii.fpsms.modules.deliveryOrder.service.DoFloorSupplierSettingsService
@Service @Service
open class SuggestedPickLotService( open class SuggestedPickLotService(
val suggestedPickLotRepository: SuggestPickLotRepository, val suggestedPickLotRepository: SuggestPickLotRepository,
@@ -57,7 +58,8 @@ open class SuggestedPickLotService(
val failInventoryLotLineRepository: FailInventoryLotLineRepository, val failInventoryLotLineRepository: FailInventoryLotLineRepository,
val stockOutRepository: StockOutRepository, val stockOutRepository: StockOutRepository,
val itemRepository: ItemsRepository, val itemRepository: ItemsRepository,
val stockOutLineRepository: StockOutLIneRepository
val stockOutLineRepository: StockOutLIneRepository,
private val doFloorSupplierSettingsService: DoFloorSupplierSettingsService,
) { ) {


// Calculation Available Qty / Remaining Qty // Calculation Available Qty / Remaining Qty
@@ -114,6 +116,8 @@ open class SuggestedPickLotService(
.filter { it.expiryDate.isAfter(today) || it.expiryDate.isEqual(today)} .filter { it.expiryDate.isAfter(today) || it.expiryDate.isEqual(today)}
.sortedBy { it.expiryDate } .sortedBy { it.expiryDate }
.groupBy { it.item?.id } .groupBy { it.item?.id }

val (floorSuppliers2F, floorSuppliers4F) = doFloorSupplierSettingsService.loadDoFloorSupplierLists()
// loop for suggest pick lot line // loop for suggest pick lot line
pols.forEach { line -> pols.forEach { line ->
@@ -126,11 +130,11 @@ open class SuggestedPickLotService(
val doPreferredFloor: String? = if (isDoPickOrder) { val doPreferredFloor: String? = if (isDoPickOrder) {
val supplierCode = pickOrder?.deliveryOrder?.supplier?.code val supplierCode = pickOrder?.deliveryOrder?.supplier?.code
when (supplierCode) {
"P06B" -> "4F"
"P07", "P06D" -> "2F"
else -> null // 其他供应商不限定 2F/4F
}
doFloorSupplierSettingsService.preferredFloorForPickLotOrNull(
supplierCode,
floorSuppliers2F,
floorSuppliers4F,
)
} else { } else {
null null
} }


+ 14
- 1
src/main/java/com/ffii/fpsms/modules/stock/web/StockTakeRecordController.kt Wyświetl plik

@@ -32,7 +32,8 @@ class StockTakeRecordController(
@RequestParam(required = false) stockTakeSections: String?, @RequestParam(required = false) stockTakeSections: String?,
@RequestParam(required = false) status: String?, @RequestParam(required = false) status: String?,
@RequestParam(required = false) area: String?, @RequestParam(required = false) area: String?,
@RequestParam(required = false) storeId: String?
@RequestParam(required = false) storeId: String?,
@RequestParam(required = false, defaultValue = "false") onlyLatestRound: Boolean
): RecordsRes<AllPickedStockTakeListReponse> { ): RecordsRes<AllPickedStockTakeListReponse> {
var all = stockOutRecordService.AllPickedStockTakeList() var all = stockOutRecordService.AllPickedStockTakeList()
if (sectionDescription != null && sectionDescription != "All") { if (sectionDescription != null && sectionDescription != "All") {
@@ -71,6 +72,18 @@ class StockTakeRecordController(
it.storeId?.contains(storeIdKeyword, ignoreCase = true) == true it.storeId?.contains(storeIdKeyword, ignoreCase = true) == true
} }
} }
if (onlyLatestRound) {
val latestRoundKey = all
.mapNotNull { item ->
item.stockTakeRoundId ?: item.stockTakeId.takeIf { it > 0 }
}
.maxOrNull()
all = if (latestRoundKey == null) {
emptyList()
} else {
all.filter { (it.stockTakeRoundId ?: it.stockTakeId) == latestRoundKey }
}
}
val total = all.size val total = all.size
val fromIndex = pageNum * pageSize val fromIndex = pageNum * pageSize
val toIndex = minOf(fromIndex + pageSize, total) val toIndex = minOf(fromIndex + pageSize, total)


+ 8
- 0
src/main/java/com/ffii/fpsms/modules/stock/web/model/CreateStockTakeForSectionsRequest.kt Wyświetl plik

@@ -0,0 +1,8 @@
package com.ffii.fpsms.modules.stock.web.model

import com.fasterxml.jackson.annotation.JsonIgnoreProperties

@JsonIgnoreProperties(ignoreUnknown = true)
data class CreateStockTakeForSectionsRequest(
val sections: List<String>? = null,
)

+ 10
- 0
src/main/java/com/ffii/fpsms/modules/user/service/GroupService.java Wyświetl plik

@@ -1,6 +1,7 @@
package com.ffii.fpsms.modules.user.service; package com.ffii.fpsms.modules.user.service;


import java.util.Date; import java.util.Date;
import java.util.LinkedHashMap;
import java.util.List; import java.util.List;
import java.util.Map; import java.util.Map;
import java.util.stream.Collectors; import java.util.stream.Collectors;
@@ -209,4 +210,13 @@ public class GroupService extends AbstractBaseEntityService<Group, Long, GroupRe
return jdbcDao.queryForList(sql.toString(), args); return jdbcDao.queryForList(sql.toString(), args);
} }


@Transactional(rollbackFor = Exception.class)
public Map<Integer, List<Map<String, Object>>> listAuthForUsers(List<Integer> userIds) {
Map<Integer, List<Map<String, Object>>> result = new LinkedHashMap<>();
for (Integer userId : userIds) {
result.put(userId, listAuth(Map.of("userId", userId)));
}
return result;
}

} }

+ 4
- 4
src/main/java/com/ffii/fpsms/modules/user/service/UserService.java Wyświetl plik

@@ -185,8 +185,8 @@ public class UserService extends AbstractBaseEntityService<User, Long, UserRepos
if (!authBatchDeleteValues.isEmpty()) { if (!authBatchDeleteValues.isEmpty()) {
jdbcDao.batchUpdate( jdbcDao.batchUpdate(
"DELETE FROM user_authority" "DELETE FROM user_authority"
+ " WHERE userId = :userId ",
// + "AND authId = :authId",
+ " WHERE userId = :userId "
+ " AND authId = :authId",
authBatchDeleteValues); authBatchDeleteValues);
} }
if (!authBatchInsertValues.isEmpty()) { if (!authBatchInsertValues.isEmpty()) {
@@ -228,8 +228,8 @@ public class UserService extends AbstractBaseEntityService<User, Long, UserRepos
if (!authBatchDeleteValues.isEmpty()) { if (!authBatchDeleteValues.isEmpty()) {
jdbcDao.batchUpdate( jdbcDao.batchUpdate(
"DELETE FROM user_authority" "DELETE FROM user_authority"
+ " WHERE userId = :userId ",
// + "AND authId = :authId",
+ " WHERE userId = :userId "
+ " AND authId = :authId",
authBatchDeleteValues); authBatchDeleteValues);
} }
if (!authBatchInsertValues.isEmpty()) { if (!authBatchInsertValues.isEmpty()) {


+ 9
- 1
src/main/java/com/ffii/fpsms/modules/user/web/GroupController.java Wyświetl plik

@@ -1,6 +1,7 @@
package com.ffii.fpsms.modules.user.web; package com.ffii.fpsms.modules.user.web;


import java.util.HashMap; import java.util.HashMap;
import java.util.List;
import java.util.Map; import java.util.Map;


import org.apache.commons.logging.Log; import org.apache.commons.logging.Log;
@@ -11,6 +12,7 @@ import org.springframework.web.bind.annotation.DeleteMapping;
import org.springframework.web.bind.annotation.GetMapping; import org.springframework.web.bind.annotation.GetMapping;
import org.springframework.web.bind.annotation.PathVariable; import org.springframework.web.bind.annotation.PathVariable;
import org.springframework.web.bind.annotation.PostMapping; import org.springframework.web.bind.annotation.PostMapping;
import org.springframework.web.bind.annotation.RequestParam;
import org.springframework.web.bind.annotation.RequestBody; import org.springframework.web.bind.annotation.RequestBody;
import org.springframework.web.bind.annotation.RequestMapping; import org.springframework.web.bind.annotation.RequestMapping;
import org.springframework.web.bind.annotation.ResponseStatus; import org.springframework.web.bind.annotation.ResponseStatus;
@@ -80,7 +82,6 @@ public class GroupController{


@GetMapping("/auth/{target}/{id}") @GetMapping("/auth/{target}/{id}")
public RecordsRes<Map<String, Object>> authComboJson(HttpServletRequest request, @PathVariable("id") int id, @PathVariable("target") String target) throws ServletRequestBindingException { public RecordsRes<Map<String, Object>> authComboJson(HttpServletRequest request, @PathVariable("id") int id, @PathVariable("target") String target) throws ServletRequestBindingException {
System.out.println(request);
Map<String, Object> args = new HashMap<>(); Map<String, Object> args = new HashMap<>();
if (id != 0){ if (id != 0){
if (target.equals("group")){ if (target.equals("group")){
@@ -94,4 +95,11 @@ public class GroupController{
return new RecordsRes<>(groupService.listAuth(args)); return new RecordsRes<>(groupService.listAuth(args));
} }


@GetMapping("/auth/user-batch")
public Map<Integer, List<Map<String, Object>>> authBatchByUserIds(
@RequestParam("userIds") List<Integer> userIds
) {
return groupService.listAuthForUsers(userIds);
}

} }

+ 1
- 6
src/main/java/com/ffii/fpsms/modules/user/web/UserController.java Wyświetl plik

@@ -78,7 +78,6 @@ public class UserController{
@GetMapping @GetMapping
// @PreAuthorize("hasAuthority('VIEW_USER')") // @PreAuthorize("hasAuthority('VIEW_USER')")
public ResponseEntity<List<UserRecord>> list(@ModelAttribute @Valid SearchUserReq req) { public ResponseEntity<List<UserRecord>> list(@ModelAttribute @Valid SearchUserReq req) {
logger.info("Test List user");
return ResponseEntity.ok(userService.search(req)); return ResponseEntity.ok(userService.search(req));
} }


@@ -120,13 +119,10 @@ public class UserController{
@GetMapping("/{id}") @GetMapping("/{id}")
@PreAuthorize("hasAuthority('VIEW_USER')") @PreAuthorize("hasAuthority('VIEW_USER')")
public LoadUserRes load(@PathVariable long id) { public LoadUserRes load(@PathVariable long id) {
LoadUserRes test = new LoadUserRes(
return new LoadUserRes(
userService.find(id).orElseThrow(NotFoundException::new), userService.find(id).orElseThrow(NotFoundException::new),
userService.listUserAuthId(id), userService.listUserAuthId(id),
userService.listUserGroupId(id)); userService.listUserGroupId(id));
logger.info("Test List user2");
logger.info(test);
return test;
} }
@GetMapping("/user-info/{id}") @GetMapping("/user-info/{id}")
// @PreAuthorize("hasAuthority('VIEW_USER')") // @PreAuthorize("hasAuthority('VIEW_USER')")
@@ -147,7 +143,6 @@ public class UserController{
// @ResponseStatus(HttpStatus.CREATED) // @ResponseStatus(HttpStatus.CREATED)
// @PreAuthorize("hasAuthority('MAINTAIN_USER')") // @PreAuthorize("hasAuthority('MAINTAIN_USER')")
public IdRes newRecord(@RequestBody @Valid NewUserReq req) throws UnsupportedEncodingException { public IdRes newRecord(@RequestBody @Valid NewUserReq req) throws UnsupportedEncodingException {
System.out.println(req.getUsername());
return new IdRes(userService.newRecord(req).getId()); return new IdRes(userService.newRecord(req).getId());
} }




+ 4
- 0
src/main/resources/application.yml Wyświetl plik

@@ -27,6 +27,10 @@ scheduler:
syncOffsetDays: 0 syncOffsetDays: 0
inventoryLotExpiry: inventoryLotExpiry:
enabled: true enabled: true
# Job order: at 00:00:15 daily, process JOs whose planStart was yesterday (hide or reschedule).
jo:
planStart:
enabled: true


# Nav: PO stock_in_line pending/receiving within last N days (see ProductProcessService for 工單 QC/上架:今日+昨日). # Nav: PO stock_in_line pending/receiving within last N days (see ProductProcessService for 工單 QC/上架:今日+昨日).
fpsms: fpsms:


+ 130
- 0
src/main/resources/db/changelog/changes/20260430_02_2fi/01_truck_lane_version_snapshot.sql Wyświetl plik

@@ -0,0 +1,130 @@
-- liquibase formatted sql
-- changeset 2fi:20260430_03_truck_lane_version_snapshot
-- preconditions onFail:MARK_RAN
-- precondition-sql-check expectedResult:0 SELECT COUNT(*) FROM information_schema.tables WHERE table_schema = DATABASE() AND table_name = 'truck_lane_version'

CREATE TABLE IF NOT EXISTS `truck_lane_version`
(
`id` BIGINT NOT NULL AUTO_INCREMENT,
`created` DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
`createdBy` VARCHAR(30) NULL DEFAULT NULL,
`version` INT NOT NULL DEFAULT '0',
`modified` DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
`modifiedBy` VARCHAR(30) NULL DEFAULT NULL,
`deleted` TINYINT(1) NOT NULL DEFAULT '0',
`storeId` VARCHAR(10) NOT NULL,
`truckLanceCode` VARCHAR(100) NOT NULL,
`note` VARCHAR(500) NULL DEFAULT NULL,
CONSTRAINT pk_truck_lane_version PRIMARY KEY (`id`)
);

-- When upgrading an existing database, CREATE TABLE IF NOT EXISTS will not add missing columns.
-- Old DB snapshots might already have `truck_lane_version` without `storeId`, which would break the index creation below.
SET @col_tlv_storeId := (
SELECT COUNT(*)
FROM information_schema.columns
WHERE table_schema = DATABASE()
AND table_name = 'truck_lane_version'
AND column_name = 'storeId'
);
SET @sql_add_tlv_storeId := IF(
@col_tlv_storeId = 0,
'ALTER TABLE `truck_lane_version` ADD COLUMN `storeId` VARCHAR(10) NULL DEFAULT NULL AFTER `deleted`',
'SELECT 1'
);
PREPARE stmt_add_tlv_storeId FROM @sql_add_tlv_storeId;
EXECUTE stmt_add_tlv_storeId;
DEALLOCATE PREPARE stmt_add_tlv_storeId;

SET @idx_tlv := (
SELECT COUNT(*)
FROM information_schema.statistics
WHERE table_schema = DATABASE()
AND table_name = 'truck_lane_version'
AND index_name = 'idx_tlv_lane'
);
SET @col_tlv_truckLanceCode := (
SELECT COUNT(*)
FROM information_schema.columns
WHERE table_schema = DATABASE()
AND table_name = 'truck_lane_version'
AND column_name = 'truckLanceCode'
);
SET @sql_tlv := IF(
@idx_tlv = 0 AND @col_tlv_storeId > 0 AND @col_tlv_truckLanceCode > 0,
'CREATE INDEX idx_tlv_lane ON `truck_lane_version` (`storeId`, `truckLanceCode`, `created`)',
'SELECT 1'
);
PREPARE stmt_tlv FROM @sql_tlv;
EXECUTE stmt_tlv;
DEALLOCATE PREPARE stmt_tlv;

CREATE TABLE IF NOT EXISTS `truck_lane_version_line`
(
`id` BIGINT NOT NULL AUTO_INCREMENT,
`created` DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
`createdBy` VARCHAR(30) NULL DEFAULT NULL,
`version` INT NOT NULL DEFAULT '0',
`modified` DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
`modifiedBy` VARCHAR(30) NULL DEFAULT NULL,
`deleted` TINYINT(1) NOT NULL DEFAULT '0',
`truckLaneVersionId` BIGINT NOT NULL,
`truckRowId` BIGINT NOT NULL,
`shopCode` VARCHAR(50) NULL DEFAULT NULL,
`branchName` VARCHAR(255) NULL DEFAULT NULL,
`districtReference` VARCHAR(255) NULL DEFAULT NULL,
`loadingSequence` INT NULL DEFAULT NULL,
`departureTime` VARCHAR(30) NULL DEFAULT NULL,
`storeId` VARCHAR(10) NOT NULL,
`remark` VARCHAR(255) NULL DEFAULT NULL,
CONSTRAINT pk_truck_lane_version_line PRIMARY KEY (`id`),
CONSTRAINT fk_tlvl_version FOREIGN KEY (`truckLaneVersionId`) REFERENCES `truck_lane_version` (`id`)
);

SET @col_tlvl_storeId := (
SELECT COUNT(*)
FROM information_schema.columns
WHERE table_schema = DATABASE()
AND table_name = 'truck_lane_version_line'
AND column_name = 'storeId'
);
SET @sql_add_tlvl_storeId := IF(
@col_tlvl_storeId = 0,
'ALTER TABLE `truck_lane_version_line` ADD COLUMN `storeId` VARCHAR(10) NULL DEFAULT NULL AFTER `departureTime`',
'SELECT 1'
);
PREPARE stmt_add_tlvl_storeId FROM @sql_add_tlvl_storeId;
EXECUTE stmt_add_tlvl_storeId;
DEALLOCATE PREPARE stmt_add_tlvl_storeId;

SET @idx_tlvl_v := (
SELECT COUNT(*)
FROM information_schema.statistics
WHERE table_schema = DATABASE()
AND table_name = 'truck_lane_version_line'
AND index_name = 'idx_tlvl_version'
);
SET @sql_tlvl_v := IF(
@idx_tlvl_v = 0,
'CREATE INDEX idx_tlvl_version ON `truck_lane_version_line` (`truckLaneVersionId`)',
'SELECT 1'
);
PREPARE stmt_tlvl_v FROM @sql_tlvl_v;
EXECUTE stmt_tlvl_v;
DEALLOCATE PREPARE stmt_tlvl_v;

SET @idx_tlvl_tr := (
SELECT COUNT(*)
FROM information_schema.statistics
WHERE table_schema = DATABASE()
AND table_name = 'truck_lane_version_line'
AND index_name = 'idx_tlvl_truck_row'
);
SET @sql_tlvl_tr := IF(
@idx_tlvl_tr = 0,
'CREATE INDEX idx_tlvl_truck_row ON `truck_lane_version_line` (`truckRowId`)',
'SELECT 1'
);
PREPARE stmt_tlvl_tr FROM @sql_tlvl_tr;
EXECUTE stmt_tlvl_tr;
DEALLOCATE PREPARE stmt_tlvl_tr;

+ 46
- 0
src/main/resources/db/changelog/changes/20260430_02_2fi/02_truck_lane_version_snapshot_patch.sql Wyświetl plik

@@ -0,0 +1,46 @@
-- liquibase formatted sql
-- changeset 2fi:20260430_04_truck_lane_version_snapshot_patch
-- preconditions onFail:MARK_RAN
-- precondition-sql-check expectedResult:0 SELECT COUNT(*) FROM information_schema.columns WHERE table_schema = DATABASE() AND table_name = 'truck_lane_version_line' AND column_name = 'truckLanceCode'

ALTER TABLE `truck_lane_version`
MODIFY COLUMN `truckLanceCode` VARCHAR(100) NULL;

SET @col_tlvl_storeId := (
SELECT COUNT(*)
FROM information_schema.columns
WHERE table_schema = DATABASE()
AND table_name = 'truck_lane_version_line'
AND column_name = 'storeId'
);
SET @col_tlvl_tlc := (
SELECT COUNT(*)
FROM information_schema.columns
WHERE table_schema = DATABASE()
AND table_name = 'truck_lane_version_line'
AND column_name = 'truckLanceCode'
);
SET @sql_add_tlc := IF(
@col_tlvl_tlc = 0,
'ALTER TABLE `truck_lane_version_line` ADD COLUMN `truckLanceCode` VARCHAR(100) NULL DEFAULT NULL AFTER `truckRowId`',
'SELECT 1'
);
PREPARE stmt_add_tlc FROM @sql_add_tlc;
EXECUTE stmt_add_tlc;
DEALLOCATE PREPARE stmt_add_tlc;

SET @idx_tlvl_lane := (
SELECT COUNT(*)
FROM information_schema.statistics
WHERE table_schema = DATABASE()
AND table_name = 'truck_lane_version_line'
AND index_name = 'idx_tlvl_lane'
);
SET @sql_tlvl_lane := IF(
@idx_tlvl_lane = 0 AND @col_tlvl_storeId > 0 AND @col_tlvl_tlc > 0,
'CREATE INDEX idx_tlvl_lane ON `truck_lane_version_line` (`storeId`, `truckLanceCode`)',
'SELECT 1'
);
PREPARE stmt_tlvl_lane FROM @sql_tlvl_lane;
EXECUTE stmt_tlvl_lane;
DEALLOCATE PREPARE stmt_tlvl_lane;

+ 10
- 0
src/main/resources/db/changelog/changes/20260504_01_2fi/01_truck_add_logistic_id.sql Wyświetl plik

@@ -0,0 +1,10 @@
-- liquibase formatted sql
-- changeset 2fi:20260504_01_truck_add_logistic_id
-- preconditions onFail:MARK_RAN
-- precondition-sql-check expectedResult:0 SELECT COUNT(*) FROM information_schema.columns WHERE table_schema = DATABASE() AND table_name = 'truck' AND column_name = 'logisticId'

ALTER TABLE `truck`
ADD COLUMN `logisticId` INT NULL;

ALTER TABLE `truck`
ADD CONSTRAINT `fk_truck_logistic` FOREIGN KEY (`logisticId`) REFERENCES `logistic` (`id`);

+ 52
- 0
src/main/resources/db/changelog/changes/20260505_01_2fi/01_truck_lane_version_drop_store_id.sql Wyświetl plik

@@ -0,0 +1,52 @@
-- liquibase formatted sql
-- changeset 2fi:20260505_01_truck_lane_version_drop_store_id
-- preconditions onFail:MARK_RAN
-- precondition-sql-check expectedResult:1 SELECT COUNT(*) FROM information_schema.columns WHERE table_schema = DATABASE() AND table_name = 'truck_lane_version' AND column_name = 'storeId'

SET @idx_tlv := (
SELECT COUNT(*)
FROM information_schema.statistics
WHERE table_schema = DATABASE()
AND table_name = 'truck_lane_version'
AND index_name = 'idx_tlv_lane'
);
SET @sql_drop_idx := IF(
@idx_tlv > 0,
'DROP INDEX idx_tlv_lane ON `truck_lane_version`',
'SELECT 1'
);
PREPARE stmt_drop_idx FROM @sql_drop_idx;
EXECUTE stmt_drop_idx;
DEALLOCATE PREPARE stmt_drop_idx;

SET @col_tlv_sid := (
SELECT COUNT(*)
FROM information_schema.columns
WHERE table_schema = DATABASE()
AND table_name = 'truck_lane_version'
AND column_name = 'storeId'
);
SET @sql_drop_col := IF(
@col_tlv_sid > 0,
'ALTER TABLE `truck_lane_version` DROP COLUMN `storeId`',
'SELECT 1'
);
PREPARE stmt_drop_col FROM @sql_drop_col;
EXECUTE stmt_drop_col;
DEALLOCATE PREPARE stmt_drop_col;

SET @idx_new := (
SELECT COUNT(*)
FROM information_schema.statistics
WHERE table_schema = DATABASE()
AND table_name = 'truck_lane_version'
AND index_name = 'idx_tlv_lane_created'
);
SET @sql_new_idx := IF(
@idx_new = 0,
'CREATE INDEX idx_tlv_lane_created ON `truck_lane_version` (`truckLanceCode`, `created`)',
'SELECT 1'
);
PREPARE stmt_new_idx FROM @sql_new_idx;
EXECUTE stmt_new_idx;
DEALLOCATE PREPARE stmt_new_idx;

+ 37
- 0
src/main/resources/db/changelog/changes/20260507_01_2fi/01_truck_lane_version_line_add_logistic_id.sql Wyświetl plik

@@ -0,0 +1,37 @@
-- liquibase formatted sql
-- changeset 2fi:20260507_01_truck_lane_version_line_add_logisticId
-- preconditions onFail:MARK_RAN
-- precondition-sql-check expectedResult:0 SELECT COUNT(*) FROM information_schema.columns WHERE table_schema = DATABASE() AND table_name = 'truck_lane_version_line' AND column_name = 'logisticId'

SET @col_tlvl_lid := (
SELECT COUNT(*)
FROM information_schema.columns
WHERE table_schema = DATABASE()
AND table_name = 'truck_lane_version_line'
AND column_name = 'logisticId'
);
SET @sql_add_tlvl_lid := IF(
@col_tlvl_lid = 0,
'ALTER TABLE `truck_lane_version_line` ADD COLUMN `logisticId` BIGINT NULL DEFAULT NULL AFTER `remark`',
'SELECT 1'
);
PREPARE stmt_add_tlvl_lid FROM @sql_add_tlvl_lid;
EXECUTE stmt_add_tlvl_lid;
DEALLOCATE PREPARE stmt_add_tlvl_lid;

SET @idx_tlvl_lid := (
SELECT COUNT(*)
FROM information_schema.statistics
WHERE table_schema = DATABASE()
AND table_name = 'truck_lane_version_line'
AND index_name = 'idx_tlvl_logisticId'
);
SET @sql_tlvl_lid := IF(
@idx_tlvl_lid = 0,
'CREATE INDEX idx_tlvl_logisticId ON `truck_lane_version_line` (`logisticId`)',
'SELECT 1'
);
PREPARE stmt_tlvl_lid FROM @sql_tlvl_lid;
EXECUTE stmt_tlvl_lid;
DEALLOCATE PREPARE stmt_tlvl_lid;


+ 20
- 0
src/main/resources/db/changelog/changes/20260512_fai/01_m18_bom_shop_sync_settings.sql Wyświetl plik

@@ -0,0 +1,20 @@
--liquibase formatted sql
--changeset fai:20260512_m18_bom_shop_sync_settings

INSERT INTO `settings` (`name`, `value`, `category`, `type`)
SELECT v.name, v.value, v.category, v.type
FROM (
SELECT 'M18.bom.shop.sync.enabled' AS name, 'false' AS value, 'M18' AS category, 'boolean' AS type
) v
WHERE NOT EXISTS (
SELECT 1 FROM `settings` s WHERE s.name = 'M18.bom.shop.sync.enabled'
);

INSERT INTO `settings` (`name`, `value`, `category`, `type`)
SELECT v.name, v.value, v.category, v.type
FROM (
SELECT 'M18.bom.shop.sync.allowedBomIds' AS name, '78,274' AS value, 'M18' AS category, 'string' AS type
) v
WHERE NOT EXISTS (
SELECT 1 FROM `settings` s WHERE s.name = 'M18.bom.shop.sync.allowedBomIds'
);

+ 24
- 0
src/main/resources/db/changelog/changes/20260512_fai/02_m18_bom_shop_sync_log.sql Wyświetl plik

@@ -0,0 +1,24 @@
--liquibase formatted sql
--changeset fai:20260512_m18_bom_shop_sync_log

CREATE TABLE IF NOT EXISTS `m18_bom_shop_sync_log` (
`id` BIGINT NOT NULL AUTO_INCREMENT,
`created` DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
`createdBy` VARCHAR(30) NULL DEFAULT NULL,
`version` INT NOT NULL DEFAULT '0',
`modified` DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
`modifiedBy` VARCHAR(30) NULL DEFAULT NULL,
`deleted` TINYINT(1) NOT NULL DEFAULT '0',

`bom_id` BIGINT NOT NULL COMMENT 'FPSMS bom.id',
`m18_record_id` BIGINT NULL DEFAULT NULL COMMENT 'M18 udfBomForShop record id when returned',
`m18_api_status` TINYINT(1) NOT NULL COMMENT 'M18 response status field',
`synced` TINYINT(1) NOT NULL COMMENT 'FPSMS treat as success (e.g. updated bom.m18Id)',
`message` VARCHAR(4000) NULL DEFAULT NULL COMMENT 'Summary / errors',
`request_json` LONGTEXT NULL COMMENT 'PUT body sent to M18',
`response_json` LONGTEXT NULL COMMENT 'Parsed M18 response or error JSON',

CONSTRAINT pk_m18_bom_shop_sync_log PRIMARY KEY (`id`),
KEY `idx_m18_bom_shop_sync_log_bom_id` (`bom_id`),
KEY `idx_m18_bom_shop_sync_log_created` (`created`)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci;

+ 4
- 0
src/main/resources/db/changelog/changes/20260513_do2_1pm/01_update_do2_schedule_to_13.sql Wyświetl plik

@@ -0,0 +1,4 @@
--liquibase formatted sql
--changeset fpsms:20260513_do2_schedule_1pm

UPDATE `settings` SET `value` = '0 0 13 * * *' WHERE `name` = 'SCHEDULE.m18.do2';

+ 18
- 0
src/main/resources/db/changelog/changes/20260514_Enson/01_setting.sql Wyświetl plik

@@ -0,0 +1,18 @@
--liquibase formatted sql

-- DO 樓層供應商代碼(逗號分隔),name 須與前端 constants 一致。預設值對齊既有硬編碼邏輯,後端改讀 settings 後才會生效。
--changeset Enson:20260514-01
INSERT INTO `fpsmsdb`.`settings` (`name`, `value`, `category`, `type`)
SELECT 'DO.floor.suppliers.2F', 'P07,P06D,P06Y', 'DO_FLOOR', 'string'
FROM DUAL
WHERE NOT EXISTS (
SELECT 1 FROM `fpsmsdb`.`settings` WHERE `name` = 'DO.floor.suppliers.2F'
);

--changeset Enson:20260514-02
INSERT INTO `fpsmsdb`.`settings` (`name`, `value`, `category`, `type`)
SELECT 'DO.floor.suppliers.4F', 'P06B', 'DO_FLOOR', 'string'
FROM DUAL
WHERE NOT EXISTS (
SELECT 1 FROM `fpsmsdb`.`settings` WHERE `name` = 'DO.floor.suppliers.4F'
);

+ 6
- 0
src/main/resources/db/changelog/changes/20260514_Enson/02_setting.sql Wyświetl plik

@@ -0,0 +1,6 @@
--liquibase formatted sql

-- 修改 delivery_order 表的 isExtra 欄位為 isExtra
--changeset Enson:20260514-03
ALTER TABLE `delivery_order` CHANGE COLUMN `isEtra` `isExtra` TINYINT(1) NOT NULL DEFAULT 0;


+ 7
- 0
src/main/resources/db/changelog/changes/20260515_fpsms/01_m18_bom_shop_sync_log_columns.sql Wyświetl plik

@@ -0,0 +1,7 @@
--liquibase formatted sql
--changeset fpsms:20260515_m18_bom_shop_sync_log_columns

ALTER TABLE `m18_bom_shop_sync_log`
ADD COLUMN `finished_item_code` VARCHAR(100) NULL COMMENT 'BOM finished-good item code' AFTER `bom_id`,
ADD COLUMN `m18_header_code` VARCHAR(200) NULL COMMENT 'M18 header code BOM+item+Vnnn' AFTER `finished_item_code`,
ADD COLUMN `request_fingerprint` VARCHAR(64) NULL COMMENT 'SHA-256 of normalized payload for change detection' AFTER `m18_header_code`;

+ 1
- 0
src/main/resources/log4j2-prod-linux.yml Wyświetl plik

@@ -11,6 +11,7 @@ Configutation:
filePattern: ${log_location}fpsms-all.log.%i.gz filePattern: ${log_location}fpsms-all.log.%i.gz
PatternLayout: PatternLayout:
Pattern: "%d %p [%l] - %m%n" Pattern: "%d %p [%l] - %m%n"
charset: UTF-8
Policies: Policies:
SizeBasedTriggeringPolicy: SizeBasedTriggeringPolicy:
size: 4096KB size: 4096KB


+ 1
- 0
src/main/resources/log4j2-prod-win.yml Wyświetl plik

@@ -11,6 +11,7 @@ Configutation:
filePattern: ${log_location}fpsms-all.log.%i.gz filePattern: ${log_location}fpsms-all.log.%i.gz
PatternLayout: PatternLayout:
Pattern: "%d %p [%l] - %m%n" Pattern: "%d %p [%l] - %m%n"
charset: UTF-8
Policies: Policies:
SizeBasedTriggeringPolicy: SizeBasedTriggeringPolicy:
size: 4096KB size: 4096KB


+ 1
- 0
src/main/resources/log4j2.yml Wyświetl plik

@@ -10,6 +10,7 @@ Configutation:
target: SYSTEM_OUT target: SYSTEM_OUT
PatternLayout: PatternLayout:
pattern: ${log_pattern} pattern: ${log_pattern}
charset: UTF-8
Loggers: Loggers:
Root: Root:
level: info level: info


Ładowanie…
Anuluj
Zapisz