浏览代码

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 个月前
父节点
当前提交
5b61294a53
共有 98 个文件被更改,包括 5857 次插入469 次删除
  1. +2
    -1
      .gitignore
  2. +78
    -19
      python/Bag3.py
  3. 二进制
      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 查看文件

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


+ 78
- 19
python/Bag3.py 查看文件

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

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


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


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


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


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


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

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

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

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

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


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


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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

open fun saveDeliveryOrders(request: M18CommonRequest): SyncResult {
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
* [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.
* @param newOnly when true, skip if a non-deleted local DO already exists with the same `code`.
*/
open fun saveDeliveryOrderByCode(
code: String,
isEtraSync: Boolean = false,
isExtraSync: Boolean = false,
newOnly: Boolean = false,
): SyncResult {
if (newOnly && deliveryOrderRepository.existsByCodeAndDeletedIsFalse(code)) {
@@ -210,12 +210,12 @@ open class M18DeliveryOrderService(
query = conds
)

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

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

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

val saveDeliveryOrderResponse =


+ 14
- 4
src/main/java/com/ffii/fpsms/m18/web/M18TestController.kt 查看文件

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

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

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

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

@GetMapping("/test/do-by-code")
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")
fun testSyncDoByCodeExtra(@RequestParam code: String): SyncResult {
// 加單 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")


+ 1
- 1
src/main/java/com/ffii/fpsms/modules/bag/service/bagService.kt 查看文件

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


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

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

/**
* Delivery orders: order count and total line qty by date.
* Uses delivery_order.completeDate or estimatedArrivalDate for date.
* X-axis date: [delivery_order.estimatedArrivalDate] only (no completeDate/orderDate fallback).
* Rows without estimatedArrivalDate are excluded.
*/
fun getDeliveryOrderByDate(startDate: LocalDate?, endDate: LocalDate?): List<Map<String, Any>> {
val args = mutableMapOf<String, Any>()
val startSql = if (startDate != null) {
args["startDate"] = startDate.toString()
"AND DATE(COALESCE(do.completeDate, do.estimatedArrivalDate, do.orderDate)) >= :startDate"
"AND DATE(do.estimatedArrivalDate) >= :startDate"
} else ""
val endSql = if (endDate != null) {
args["endDate"] = endDate.toString()
"AND DATE(COALESCE(do.completeDate, do.estimatedArrivalDate, do.orderDate)) <= :endDate"
"AND DATE(do.estimatedArrivalDate) <= :endDate"
} else ""
val sql = """
SELECT
DATE_FORMAT(COALESCE(do.completeDate, do.estimatedArrivalDate, do.orderDate), '%Y-%m-%d') AS date,
DATE_FORMAT(do.estimatedArrivalDate, '%Y-%m-%d') AS date,
COUNT(DISTINCT do.id) AS orderCount,
COALESCE(SUM(dol.qty), 0) AS totalQty
FROM delivery_order do
LEFT JOIN delivery_order_line dol ON dol.deliveryOrderId = do.id AND dol.deleted = 0
WHERE do.deleted = 0 $startSql $endSql
GROUP BY DATE(COALESCE(do.completeDate, do.estimatedArrivalDate, do.orderDate))
WHERE do.deleted = 0 AND do.estimatedArrivalDate IS NOT NULL $startSql $endSql
GROUP BY DATE(do.estimatedArrivalDate)
ORDER BY date
""".trimIndent()
return jdbcDao.queryForList(sql, args)
@@ -529,17 +530,32 @@ open class ChartService(
* Stock in vs stock out by date.
* Stock in: stock_in_line.acceptedQty, date from stock_in.completeDate or receiptDate/created.
* Stock out: stock_out_line.qty, date from stock_out.completeDate or created.
*
* Date range is applied inside each UNION branch (predicate pushdown) so we do not aggregate
* all history before filtering. Reads filtered headers first via STRAIGHT_JOIN (si/so then lines).
*/
fun getStockInOutByDate(startDate: LocalDate?, endDate: LocalDate?): List<Map<String, Any>> {
val args = mutableMapOf<String, Any>()
val startSql = if (startDate != null) {
args["startDate"] = startDate.toString()
"AND u.dt >= :startDate"
} else ""
val endSql = if (endDate != null) {
args["endDate"] = endDate.toString()
"AND u.dt <= :endDate"
} else ""
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 = """
SELECT DATE_FORMAT(u.dt, '%Y-%m-%d') AS date,
COALESCE(SUM(u.inQty), 0) AS inQty,
@@ -547,16 +563,16 @@ open class ChartService(
FROM (
SELECT DATE(COALESCE(si.completeDate, sil.receiptDate, si.created)) AS dt,
SUM(COALESCE(sil.acceptedQty, 0)) AS inQty, 0 AS outQty
FROM stock_in_line sil
INNER JOIN stock_in si ON sil.stockInId = si.id AND si.deleted = 0
WHERE sil.deleted = 0
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))
UNION ALL
SELECT DATE(COALESCE(so.completeDate, so.created)) AS dt,
0 AS inQty, SUM(COALESCE(sol.qty, 0)) AS outQty
FROM stock_out_line sol
INNER JOIN stock_out so ON sol.stockOutId = so.id AND so.deleted = 0
WHERE sol.deleted = 0
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))
) u
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).
* Period filter: [delivery_order.estimatedArrivalDate] only; null ETA excluded.
* Uses STRAIGHT_JOIN so MySQL reads filtered `delivery_order` first (avoids full scan on `delivery_order_line`).
*/
fun getTopDeliveryItemsItemOptions(startDate: LocalDate?, endDate: LocalDate?): List<Map<String, Any>> {
val args = mutableMapOf<String, Any>()
val startSql = if (startDate != null) {
args["startDate"] = startDate.toString()
"AND DATE(COALESCE(do.completeDate, do.estimatedArrivalDate, do.orderDate)) >= :startDate"
"AND DATE(do.estimatedArrivalDate) >= :startDate"
} else ""
val endSql = if (endDate != null) {
args["endDate"] = endDate.toString()
"AND DATE(COALESCE(do.completeDate, do.estimatedArrivalDate, do.orderDate)) <= :endDate"
"AND DATE(do.estimatedArrivalDate) <= :endDate"
} else ""
val sql = """
SELECT DISTINCT it.code AS itemCode, COALESCE(it.name, '') AS itemName
FROM delivery_order_line dol
INNER JOIN delivery_order do ON dol.deliveryOrderId = do.id AND do.deleted = 0
INNER JOIN items it ON dol.itemId = it.id AND it.deleted = 0
WHERE dol.deleted = 0 $startSql $endSql
FROM delivery_order do
STRAIGHT_JOIN delivery_order_line dol ON dol.deliveryOrderId = do.id AND dol.deleted = 0
STRAIGHT_JOIN items it ON it.id = dol.itemId AND it.deleted = 0
WHERE do.deleted = 0 AND do.estimatedArrivalDate IS NOT NULL $startSql $endSql
ORDER BY it.code
""".trimIndent()
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).
* Period filter: [delivery_order.estimatedArrivalDate] only; null ETA excluded.
* Uses STRAIGHT_JOIN so MySQL reads filtered `delivery_order` first (avoids full scan on `delivery_order_line`).
*/
fun getTopDeliveryItems(
startDate: LocalDate?,
@@ -602,11 +622,11 @@ open class ChartService(
val args = mutableMapOf<String, Any>("limit" to limit)
val startSql = if (startDate != null) {
args["startDate"] = startDate.toString()
"AND DATE(COALESCE(do.completeDate, do.estimatedArrivalDate, do.orderDate)) >= :startDate"
"AND DATE(do.estimatedArrivalDate) >= :startDate"
} else ""
val endSql = if (endDate != null) {
args["endDate"] = endDate.toString()
"AND DATE(COALESCE(do.completeDate, do.estimatedArrivalDate, do.orderDate)) <= :endDate"
"AND DATE(do.estimatedArrivalDate) <= :endDate"
} else ""
val itemSql = if (!itemCodes.isNullOrEmpty()) {
val codes = itemCodes.map { it.trim() }.filter { it.isNotBlank() }
@@ -620,10 +640,10 @@ open class ChartService(
it.code AS itemCode,
it.name AS itemName,
SUM(COALESCE(dol.qty, 0)) AS totalQty
FROM delivery_order_line dol
INNER JOIN delivery_order do ON dol.deliveryOrderId = do.id AND do.deleted = 0
INNER JOIN items it ON dol.itemId = it.id AND it.deleted = 0
WHERE dol.deleted = 0 $startSql $endSql $itemSql
FROM delivery_order do
STRAIGHT_JOIN delivery_order_line dol ON dol.deliveryOrderId = do.id AND dol.deleted = 0
STRAIGHT_JOIN items it ON it.id = dol.itemId AND it.deleted = 0
WHERE do.deleted = 0 AND do.estimatedArrivalDate IS NOT NULL $startSql $endSql $itemSql
GROUP BY dol.itemId, it.code, it.name
ORDER BY totalQty DESC
LIMIT :limit
@@ -721,23 +741,27 @@ open class ChartService(

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


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

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

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

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

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

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



+ 10
- 0
src/main/java/com/ffii/fpsms/modules/common/SettingNames.java 查看文件

@@ -41,6 +41,11 @@ public abstract class SettingNames {
*/
public static final String M18_UNITS_SYNC_INITIAL_FULL_SYNC_DONE = "M18.units.sync.initialFullSyncDone";

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

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

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

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

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


+ 56
- 30
src/main/java/com/ffii/fpsms/modules/common/scheduler/service/SchedulerService.kt 查看文件

@@ -10,6 +10,7 @@ import com.ffii.fpsms.m18.entity.SchedulerSyncLog
import com.ffii.fpsms.m18.entity.SchedulerSyncLogRepository
import com.ffii.fpsms.m18.model.SyncResult
import com.ffii.fpsms.modules.common.SettingNames
import com.ffii.fpsms.modules.jobOrder.service.JobOrderPlanStartAutoService
import com.ffii.fpsms.modules.master.service.ProductionScheduleService
import com.ffii.fpsms.modules.stock.service.SearchCompletedDnService
import com.ffii.fpsms.modules.stock.service.InventoryLotLineService
@@ -42,6 +43,7 @@ open class SchedulerService(
@Value("\${scheduler.inventoryLotExpiry.enabled:true}") val inventoryLotExpiryEnabled: Boolean,
/** When false (default), M18 PO / DO1 / DO2 / master-data cron jobs are not registered — use true in production only. */
@Value("\${scheduler.m18Sync.enabled:false}") val m18SyncEnabled: Boolean,
@Value("\${scheduler.jo.planStart.enabled:true}") val jobOrderPlanStartAutoEnabled: Boolean,
val settingsService: SettingsService,
/**
* 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 m18GrnCodeSyncService: M18GrnCodeSyncService,
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)
val dataStringFormat = DateTimeFormatter.ofPattern("yyyy-MM-dd")
val dateTimeStringFormat = DateTimeFormatter.ofPattern("yyyy-MM-dd HH:mm:ss")
@@ -80,6 +91,8 @@ open class SchedulerService(
var scheduledGrnCodeSync: ScheduledFuture<*>? = null
var scheduledInventoryLotExpiry: ScheduledFuture<*>? = null

var scheduledJobOrderPlanStart: ScheduledFuture<*>? = null

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

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

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. */
fun scheduleInventoryLotExpiry() {
if (!inventoryLotExpiryEnabled) {
@@ -455,7 +505,7 @@ open class SchedulerService(
val ysd = today.minusDays(1L)
val tmr = today.plusDays(1L)

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

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

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

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


+ 5
- 0
src/main/java/com/ffii/fpsms/modules/common/scheduler/web/SchedulerController.kt 查看文件

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

+ 2
- 2
src/main/java/com/ffii/fpsms/modules/deliveryOrder/entity/DeliveryOrder.kt 查看文件

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

/** 加單:由 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 查看文件

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

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


+ 3
- 3
src/main/java/com/ffii/fpsms/modules/deliveryOrder/entity/models/DeliveryOrderInfo.kt 查看文件

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

@get:Value("#{target.isEtra}")
val isEtra: Boolean
@get:Value("#{target.isExtra}")
val isExtra: Boolean
}
data class DeliveryOrderInfoLiteDto(
val id: Long,
@@ -61,5 +61,5 @@ data class DeliveryOrderInfoLiteDto(
val supplierName: String?,
val shopAddress: 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 查看文件

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

@Service
open class DeliveryOrderService(
private val deliveryOrderRepository: DeliveryOrderRepository,
@@ -121,23 +120,23 @@ open class DeliveryOrderService(
private val doPickOrderLineRepository: DoPickOrderLineRepository,
private val doPickOrderLineRecordRepository: DoPickOrderLineRecordRepository,
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(
code: String?,
shopName: String?,
@@ -147,7 +146,7 @@ open class DeliveryOrderService(
pageSize: Int?,
truckLanceCode: String?,
floor: String? = null,
isEtra: Boolean? = null,
isExtra: Boolean? = null,
): RecordsRes<DeliveryOrderInfoLiteDto> {

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

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

// ✅ 优化3: 收集所有需要查询的 shopId 和日期组合(只处理预过滤后的记录)
val shopIdAndDatePairs = preFilteredContent.mapNotNull { info ->
@@ -191,11 +191,7 @@ open class DeliveryOrderService(
val targetDate = estimatedArrivalDate.toLocalDate()
val dayAbbr = getDayOfWeekAbbr(targetDate)
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)
} else {
null
@@ -217,11 +213,7 @@ open class DeliveryOrderService(
val processedRecords = preFilteredContent.map { info ->
val deliveryOrder = deliveryOrdersMap[info.id]
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 shopId = shop?.id
val estimatedArrivalDate = info.estimatedArrivalDate
@@ -248,7 +240,7 @@ open class DeliveryOrderService(
supplierName = info.supplierName,
shopAddress = info.shopAddress,
truckLanceCode = calculatedTruckLanceCode,
isEtra = deliveryOrdersMap[info.id]?.isEtra ?: info.isEtra,
isExtra = deliveryOrdersMap[info.id]?.isExtra ?: info.isExtra,
)
}.filter { dto ->
val dtoTruckLanceCode = dto.truckLanceCode?.lowercase() ?: ""
@@ -279,19 +271,16 @@ open class DeliveryOrderService(
status = statusEnum,
etaStart = etaStart,
etaEnd = etaEnd,
isEtra = isEtra,
isExtra = isExtra,
allowedSupplierCodes = allowedSupplierCodes,
pageable = PageRequest.of(page.coerceAtLeast(0), size),
)

val (floorSuppliers2F, floorSuppliers4F) = loadDoFloorSupplierLists()
val records = result.content.map { info ->
val deliveryOrder = deliveryOrderRepository.findByIdAndDeletedIsFalse(info.id)
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 shopId = shop?.id
val estimatedArrivalDate = info.estimatedArrivalDate
@@ -315,7 +304,7 @@ open class DeliveryOrderService(
supplierName = info.supplierName,
shopAddress = info.shopAddress,
truckLanceCode = calculatedTruckLanceCode,
isEtra = deliveryOrder?.isEtra ?: info.isEtra,
isExtra = deliveryOrder?.isExtra ?: info.isExtra,
)
}

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

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

val processedRecords = allResult.content.map { info ->
val deliveryOrder = deliveryOrdersMap[info.id]
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 shopId = shop?.id
val infoEta = info.estimatedArrivalDate
@@ -445,7 +431,7 @@ open class DeliveryOrderService(
supplierName = info.supplierName,
shopAddress = info.shopAddress,
truckLanceCode = calculatedTruckLanceCode,
isEtra = deliveryOrdersMap[info.id]?.isEtra ?: info.isEtra,
isExtra = deliveryOrdersMap[info.id]?.isExtra ?: info.isExtra,
)
}.filter { dto -> TruckLaneSearchSpec.isUnassignedResolvedLane(dto.truckLanceCode) }

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

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

println(" DEBUG: Target date: $targetDate, Date prefix: $datePrefix")
// 新逻辑:根据 supplier code 决定楼层
// 如果 supplier code 是 "P06B",使用 4F,否则使用 2F
// 新逻辑:根据 supplier code 决定楼层(清單來自 settings)
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")

@@ -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 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(" - Supplier code: $supplierCode")
@@ -1936,7 +1914,8 @@ val inventoryLotLine = illId?.let { inventoryLotLineMap[it] }
truckDepartureTime = effectiveTruck.departureTime,
truckLanceCode = effectiveTruck.truckLanceCode,
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)
val targetDate = deliveryOrder.estimatedArrivalDate?.toLocalDate() ?: LocalDate.now()
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 trucks = truckRepository.findByShopIdAndDeletedFalse(shopId)
@@ -2094,7 +2070,8 @@ val inventoryLotLine = illId?.let { inventoryLotLineMap[it] }
truckDepartureTime = effectiveTruck.departureTime,
truckLanceCode = effectiveTruck.truckLanceCode,
loadingSequence = effectiveTruck.loadingSequence,
usedDefaultTruck = usedDefaultTruck
usedDefaultTruck = usedDefaultTruck,
isExtra = deliveryOrder.isExtra ?: false,
)
}

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

val shopIdAndDatePairs = rows.mapNotNull { info ->
val d = deliveryOrdersMap[info.id]
@@ -2149,11 +2127,7 @@ val inventoryLotLine = illId?.let { inventoryLotLineMap[it] }
val targetDate = eta.toLocalDate()
val dayAbbr = getDayOfWeekAbbr(targetDate)
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)
} else {
null
@@ -2169,11 +2143,7 @@ val inventoryLotLine = illId?.let { inventoryLotLineMap[it] }
return rows.map { info ->
val deliveryOrder = deliveryOrdersMap[info.id]
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 infoEta = info.estimatedArrivalDate
val calculatedTruckLanceCode =
@@ -2194,14 +2164,14 @@ val inventoryLotLine = illId?.let { inventoryLotLineMap[it] }
supplierName = info.supplierName,
shopAddress = info.shopAddress,
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];無命中時再退回同樓層最早出發。
*/
private fun resolveTruckForShopFloorAndDay(


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

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

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

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

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

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

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

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

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

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

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

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

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

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

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


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

@@ -144,6 +144,9 @@ open class DoWorkbenchDopoAssignmentService(
sql.append(" AND dop.loadingSequence = :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.
// This avoids forcing the frontend to refresh when a single picked candidate is concurrently assigned.
val candidateLimit = 50
@@ -247,6 +250,9 @@ open class DoWorkbenchDopoAssignmentService(
sql.append(" AND dop.loadingSequence = :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
if (shouldOrderBySequenceV1) {
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
}

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

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


+ 178
- 10
src/main/java/com/ffii/fpsms/modules/deliveryOrder/service/DoWorkbenchMainService.kt 查看文件

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

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

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.LaneRow
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.WorkbenchTicketReleaseTableResponse
import com.ffii.fpsms.modules.user.service.UserService
@@ -670,6 +672,7 @@ return MessageResponse(
val releaseFilterClause = when (rt) {
"batch" -> " AND LOWER(COALESCE(dop.releaseType, '')) = 'batch' "
"single" -> " AND LOWER(COALESCE(dop.releaseType, '')) = 'single' "
"isExtra" -> " AND LOWER(COALESCE(dop.releaseType, '')) = 'isExtra' "
else -> ""
}
val sql = """
@@ -812,6 +815,7 @@ return MessageResponse(
unassigned = it.unassigned,
total = it.total,
handlerName = it.handlerName,
storeId = actualStoreId,
)
}
.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(
shopName: String?,
storeId: String?,
truck: String?,
releaseTypeFilter: String? = null,
): 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).
* 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(
shopName: String?,
storeId: String?,
truck: String?,
requiredDeliveryDate: LocalDate? = null,
releaseTypeFilter: String? = null,
): 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`.
@@ -1362,7 +1523,11 @@ return MessageResponse(
dop.deliveryNoteCode = CodeGenerator.generateNo(prefix = prefix, midfix = midfix, latestCode = latestCode)
}
deliveryOrderPickOrderRepository.save(dop)
<<<<<<< HEAD

=======
>>>>>>> e9f1f48edb57d3696af3ffb23bc40d9644c8c44f
}
markDeliveryOrdersCompletedForDeliveryOrderPickOrder(deliveryOrderPickOrderId)
return MessageResponse(
@@ -1469,6 +1634,7 @@ return MessageResponse(
truck: String?,
beforeToday: Boolean,
equalsDeliveryDate: LocalDate? = null,
releaseTypeFilter: String? = null,
): List<ReleasedDoPickOrderListItem> {
val today = LocalDate.now()
val params = mutableMapOf<String, Any>()
@@ -1519,6 +1685,10 @@ return MessageResponse(
sqlBuilder.append(" AND (dop.shopName LIKE :shopPat OR dop.shopCode LIKE :shopPat) ")
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 ")
val rows: List<Map<String, Any?>> = try {
jdbcDao.queryForList(sqlBuilder.toString(), params)
@@ -1913,6 +2083,7 @@ return MessageResponse(
tryCompleteDeliveryOrderPickOrderTicketCompleted(poId)
}
}

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

private fun postWorkbenchPickSideEffects(savedStockOutLine: StockOutLine, deltaQty: BigDecimal, createLedger: Boolean = true) {
if (deltaQty <= BigDecimal.ZERO) return
val wall0 = System.nanoTime()
@@ -2230,9 +2402,10 @@ return MessageResponse(
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,
* 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) {
val dopRow = jdbcDao.queryForMap(
@@ -2307,7 +2480,6 @@ return MessageResponse(
deliveryOrderRepository.save(deliveryOrder)
}
}

private fun checkWorkbenchPickOrderLineCompleted(pickOrderLineId: Long, allStockOutLines: List<StockOutLineInfo>) {
val pol = pickOrderLineRepository.findById(pickOrderLineId).orElse(null) ?: 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> {
validateWorkbenchCartonReprintRange(
fromCarton = request.fromCarton,


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

@@ -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(
requiredDate: LocalDate,
storeDisplay: String,
ticketLetter: 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 floor = storeDisplay.replace("/", "").trim()
val prefix = "TI-$ticketLetter-$ymd-$floor-"
@@ -397,6 +400,9 @@ open class DoWorkbenchReleaseService(
private fun nextDeliveryOrderPickOrderSingleTicketNo(requiredDate: LocalDate, storeDisplay: String): String =
nextDeliveryOrderPickOrderTicketNo(requiredDate, storeDisplay, "S")

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

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

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

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

@@ -477,7 +479,16 @@ open class DoWorkbenchReleaseService(
(storeId ?: "2/F").replace("/", "").trim()
}
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)
} else {
nextDeliveryOrderPickOrderBatchTicketNo(requiredDate, ticketFloorSegment)


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

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

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

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



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

@@ -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`). */
@GetMapping("/released")
fun getWorkbenchReleasedDoPickOrders(
@RequestParam(required = false) shopName: String?,
@RequestParam(required = false) storeId: String?,
@RequestParam(required = false) truck: String?
@RequestParam(required = false) truck: String?,
@RequestParam(required = false) releaseType: String?,
): List<ReleasedDoPickOrderListItem> {
return doWorkbenchMainService.findWorkbenchReleasedDeliveryOrderPickOrdersForSelection(shopName, storeId, truck)
return doWorkbenchMainService.findWorkbenchReleasedDeliveryOrderPickOrdersForSelection(
shopName,
storeId,
truck,
releaseTypeFilter = releaseType,
)
}

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



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

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

@@ -51,7 +51,18 @@ data class LaneBtn(
val unassigned: Int,
val total: Int,
// 同一 truckLanceCode + loadingSequence 的 handler 去重后逗号拼接
val handlerName: String? = null
val handlerName: String? = null,
/** Workbench Etra lane: `delivery_order_pick_order.storeId` (2/F, 4/F, …) for assign / modal scope */
val storeId: String? = null,
/** Workbench Etra / lane row: `truckDepartureTime` as ISO local time string for assign-by-lane */
val truckDepartureTime: String? = null,
)

/** All Etra (`releaseType=isExtra`) tickets for a day, grouped by shop then truck (no 2F/4F split in UI). */
data class WorkbenchEtraShopLaneGroup(
val shopCode: String?,
val shopName: String?,
val lanes: List<LaneBtn>,
)
data class AssignByLaneRequest(
val userId: Long,
@@ -59,7 +70,9 @@ data class AssignByLaneRequest(
val truckDepartureTime: String?, // 可选:限定出车时间
val truckLanceCode: String ,
val loadingSequence: Int? = null,
val requiredDate: LocalDate? // 必填:车道编号
val requiredDate: LocalDate?, // 必填:车道编号
/** When `isExtra`, assignment candidates are limited to `releaseType = isExtra` rows. */
val releaseType: String? = null,
)
data class DoPickOrderSummaryItem(
val truckDepartureTime: java.time.LocalTime?,


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

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

val truckDepartureTime: LocalTime?,
val truckLanceCode: String?,
val loadingSequence: Int?
val loadingSequence: Int?,
val isExtra: Boolean = false,
)
data class SearchDeliveryOrderInfoRequest(
val code: String?,
@@ -31,8 +32,8 @@ data class SearchDeliveryOrderInfoRequest(
val pageSize: Int?,
val pageNum: Int?,
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,
/** 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 查看文件

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

data class SaveDeliveryOrderStatusRequest(


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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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


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

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

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

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

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

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

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

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


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

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

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

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


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

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

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

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

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

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

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

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

return logisticRepository.save(entity)
}

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

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


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

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

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

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

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

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

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

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

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


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

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

import jakarta.validation.constraints.NotNull

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


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

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

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


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

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

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

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


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

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

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

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

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

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

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

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(
nativeQuery = true,
value = """


+ 144
- 0
src/main/java/com/ffii/fpsms/modules/master/service/BomService.kt 查看文件

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

@Service
open class BomService(
@@ -52,6 +61,10 @@ open class BomService(
private val itemUomService: ItemUomService,
private val jobOrderRepository: JobOrderRepository,
private val productProcessRepository: ProductProcessRepository,
private val m18BomForShopService: M18BomForShopService,
private val m18BomShopSyncLogRepository: M18BomShopSyncLogRepository,
private val objectMapper: ObjectMapper,
private val settingsService: SettingsService,
@Value("\${bom.import.temp-dir:\${java.io.tmpdir}/fpsms-bom-import}") private val bomImportTempDir: String,
) {
open fun uploadBomFiles(files: List<MultipartFile>): BomUploadResponse {
@@ -119,6 +132,29 @@ open class BomService(
?: 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 {

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

/**
* 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 {
val equipmentId = pReq.equipmentId
val equipmentCode = pReq.equipmentCode?.trim().orEmpty()


+ 14
- 0
src/main/java/com/ffii/fpsms/modules/master/service/ItemUomService.kt 查看文件

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

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

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


+ 12
- 0
src/main/java/com/ffii/fpsms/modules/master/web/BomController.kt 查看文件

@@ -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.EditBomRequest
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.nio.file.Files
import org.springframework.core.io.FileSystemResource
@@ -120,6 +122,16 @@ fun downloadBomFormatIssueLog(
// fun exportProblematicBom() {
// 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")
fun getBomDetail(@PathVariable id: Long): BomDetailResponse {
return bomService.getBomDetail(id)


+ 14
- 0
src/main/java/com/ffii/fpsms/modules/master/web/models/BomIdByItemCodeResponse.kt 查看文件

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

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

import com.ffii.core.entity.BaseEntity
import com.ffii.fpsms.modules.logistic.entity.Logistic
import com.ffii.fpsms.modules.master.entity.Shop
import jakarta.persistence.*
import jakarta.validation.constraints.NotNull
@@ -42,4 +43,8 @@ open class Truck : BaseEntity<Long>() {
@Column(name = "remark")
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 查看文件

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

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

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

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

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

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.repository.query.Param
import org.springframework.stereotype.Repository
@@ -32,8 +34,81 @@ interface TruckRepository : AbstractRepository<Truck, Long> {
fun findByTruckLanceCode(truckLanceCode: String): Truck?
@Query("SELECT t FROM Truck t WHERE t.truckLanceCode = :truckLanceCode AND t.deleted = false")
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 findByShopCodeAndStoreId(shopCode: String, storeId: String): Truck?
/** 同店同樓層重複列時取 id 最小一筆,避免 NonUniqueResultException */
fun findFirstByShopCodeAndStoreIdAndDeletedFalseOrderByIdAsc(
shopCode: String,
storeId: String,
): Truck?


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


+ 3
- 0
src/main/java/com/ffii/fpsms/modules/pickOrder/service/HierarchicalFgPayloadAssembler.kt 查看文件

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


+ 202
- 0
src/main/java/com/ffii/fpsms/modules/pickOrder/service/RouteLaneExcelSupport.kt 查看文件

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

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

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

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

@@ -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
文件差异内容过多而无法显示
查看文件


+ 218
- 15
src/main/java/com/ffii/fpsms/modules/pickOrder/web/TruckController.kt 查看文件

@@ -7,7 +7,11 @@ import org.springframework.web.bind.ServletRequestBindingException
import jakarta.servlet.http.HttpServletRequest
import org.apache.poi.ss.usermodel.Workbook
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 java.nio.charset.StandardCharsets
import org.springframework.web.bind.annotation.*
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.entity.TruckRepository
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.toLaneCombinationResponse
import jakarta.validation.Valid

@RestController
@RequestMapping("/truck")
class TruckController(
open class TruckController(
private val truckService: TruckService,
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")
@Throws(ServletRequestBindingException::class)
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}")
@@ -136,7 +290,7 @@ class TruckController(
type = "truck",
message = if (truck != null) "Truck lane updated successfully" else "Truck lane not found",
errorPosition = null,
entity = truck
entity = null
)
} catch (e: Exception) {
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")
fun deleteTruckLane(@Valid @RequestBody request: deleteTruckLane): MessageResponse {
try {
@@ -178,8 +358,10 @@ class TruckController(
}

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

/**
* 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")
fun findAllUniqueShopNamesAndCodesFromTrucks(): List<Map<String, String>> {
return truckService.findAllUniqueShopNamesAndCodesFromTrucks()


+ 73
- 0
src/main/java/com/ffii/fpsms/modules/pickOrder/web/TruckLaneVersionController.kt 查看文件

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

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

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

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

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

@@ -1,4 +1,5 @@
package com.ffii.fpsms.modules.pickOrder.web.models
import jakarta.validation.constraints.NotBlank
import java.time.LocalTime
data class SaveTruckRequest(
val id: Long? = null,
@@ -11,6 +12,7 @@ data class SaveTruckRequest(
val loadingSequence: Int,
val remark: String? = null,
val districtReference: String? = null,
val logisticId: Long? = null,
)
data class SaveTruckLane(
val id: Long,
@@ -19,7 +21,10 @@ data class SaveTruckLane(
val loadingSequence: Long,
val districtReference: 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(
val id: Long
@@ -39,4 +44,13 @@ data class CreateTruckWithoutShopRequest(
val loadingSequence: Int = 0,
val districtReference: 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 查看文件

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

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

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

@@ -2385,11 +2385,11 @@ open class ProductProcessService(
val allLines = productProcessLineRepository.findByProductProcess_Id(productProcessId)
.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 ->
println(" id=${line.id}, seqNo=${line.seqNo}, bomProcessId=${line.bomProcess?.id}")
//println(" id=${line.id}, seqNo=${line.seqNo}, bomProcessId=${line.bomProcess?.id}")
}

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

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

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

println("Remaining lines (excluding new created):")
//println("Remaining lines (excluding new created):")
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...)
val expectedSeqNoMap = remainingLines.mapIndexed { index, line ->
@@ -2430,7 +2430,7 @@ open class ProductProcessService(
val bomProcessId = line.bomProcess?.id
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) {
println(" -> No bomProcessId, marking as new created")
@@ -2442,7 +2442,7 @@ open class ProductProcessService(
// 查找这个 bomProcessId 在 BOM 中的实际 seqNo
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) {
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")

return newCreatedLineIds


+ 32
- 0
src/main/java/com/ffii/fpsms/modules/purchaseOrder/entity/PurchaseOrderLineRepository.kt 查看文件

@@ -3,6 +3,7 @@ package com.ffii.fpsms.modules.purchaseOrder.entity
import com.ffii.core.support.AbstractRepository
import com.ffii.fpsms.modules.purchaseOrder.entity.projections.PurchaseOrderLineInfo
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.repository.query.Param
import org.springframework.stereotype.Repository
@@ -10,6 +11,37 @@ import java.io.Serializable

@Repository
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 findAllPurchaseOrderLineInfoByPurchaseOrderIdAndDeletedIsFalse(purchaseOrderId: Long): List<PurchaseOrderLineInfo>
fun findAllByPurchaseOrderIdAndDeletedIsFalse(purchaseOrderId: Long): List<PurchaseOrderLine>


+ 40
- 30
src/main/java/com/ffii/fpsms/modules/report/service/FGStockOutTraceabilityReportService.kt 查看文件

@@ -9,13 +9,23 @@ class FGStockOutTraceabilityReportService(
) {
fun getDistinctHandlersForFGStockOutTraceability(): List<String> {
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()

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

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

val handlerSql = buildMultiValueExactClause(
handler,
"COALESCE(picker_user.name, modified_user.name, '')",
"COALESCE(picker_user.name, modified_user.name, IFNULL(dopo.handlerName, ''))",
"handler",
args,
)
@@ -85,13 +95,13 @@ class FGStockOutTraceabilityReportService(
val sql = """
SELECT
IFNULL(DATE_FORMAT(
IFNULL(dpor.RequiredDeliveryDate, do.estimatedArrivalDate),
IFNULL(dopo.requiredDeliveryDate, do.estimatedArrivalDate),
'%Y-%m-%d'
), '') AS deliveryDate,
IFNULL(it.code, '') AS itemNo,
IFNULL(it.name, '') AS itemName,
IFNULL(uc.udfudesc, '') AS unitOfMeasure,
IFNULL(dpor.deliveryNoteCode, '') AS dnNo,
IFNULL(dopo.deliveryNoteCode, '') AS dnNo,
CAST(IFNULL(sp.id, 0) AS CHAR) AS customerId,
IFNULL(sp.name, '') AS customerName,
FORMAT(
@@ -109,11 +119,13 @@ class FGStockOutTraceabilityReportService(
COALESCE(
picker_user.name,
modified_user.name,
dopo.handlerName,
''
) AS handler,
COALESCE(
picker_user.name,
modified_user.name,
dopo.handlerName,
''
) AS pickedBy,
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
) AS totalStockOutQty,
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
ON dpolr.do_order_id = do.id
ON po.doId = do.id
AND do.deleted = 0
LEFT JOIN shop sp
ON do.shopId = sp.id
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
ON do.id = dol.deliveryOrderId
ON dol.deliveryOrderId = do.id
AND dol.itemId = pol.itemId
AND dol.deleted = 0
LEFT JOIN items it
ON dol.itemId = it.id
@@ -144,13 +159,6 @@ class FGStockOutTraceabilityReportService(
AND iu.stockUnit = 1
LEFT JOIN uom_conversion uc
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
ON pol.id = sol.pickOrderLineId
AND sol.itemId = it.id
@@ -176,7 +184,8 @@ class FGStockOutTraceabilityReportService(
AND modified_user.deleted = 0
AND sol.handled_by IS NULL
WHERE
dpolr.deleted = 0
dopo.deleted = 0
AND dopo.ticketStatus = 'completed'
$stockCategorySql
$stockSubCategorySql
$itemCodeSql
@@ -186,12 +195,13 @@ class FGStockOutTraceabilityReportService(
$handlerSql
GROUP BY
sol.id,
dpor.RequiredDeliveryDate,
dopo.requiredDeliveryDate,
dopo.handlerName,
do.estimatedArrivalDate,
it.code,
it.name,
uc.udfudesc,
dpor.deliveryNoteCode,
dopo.deliveryNoteCode,
sp.id,
sp.name,
sol.qty,


+ 44
- 43
src/main/java/com/ffii/fpsms/modules/report/service/ReportService.kt 查看文件

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

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

FORMAT(ROUND(IFNULL(IFNULL(sol.qty, dol.qty), 0), 0), 0) AS qty,
COALESCE(
dpor.TruckLanceCode,
dopo.truckLanceCode,
(SELECT t2.TruckLanceCode
FROM truck t2
WHERE t2.shopId = do.shopId
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 (
(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
WHERE t3.shopId = do.shopId AND t3.deleted = 0
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('%',
CASE DAYNAME(IFNULL(dpor.RequiredDeliveryDate, do.estimatedArrivalDate))
CASE DAYNAME(IFNULL(dopo.requiredDeliveryDate, do.estimatedArrivalDate))
WHEN 'Monday' THEN 'Mon'
WHEN 'Tuesday' THEN 'Tue'
WHEN 'Wednesday' THEN 'Wed'
@@ -170,11 +166,7 @@ FORMAT(ROUND(IFNULL(IFNULL(sol.qty, dol.qty), 0), 0), 0) AS qty,
ELSE ''
END, '%'))
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
LIMIT 1),
@@ -183,13 +175,12 @@ FORMAT(ROUND(IFNULL(IFNULL(sol.qty, dol.qty), 0), 0), 0) AS qty,
'' AS driver,
IFNULL(do.code, '') AS deliveryOrderNo,
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
ON dpolr.do_order_id = do.id
ON po.doId = do.id
AND do.deleted = 0
LEFT JOIN shop supplier
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
ON do.shopId = sp.id
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
ON do.id = dol.deliveryOrderId
ON dol.deliveryOrderId = do.id
AND dol.itemId = pol.itemId
AND dol.deleted = 0
LEFT JOIN items it
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
LEFT JOIN uom_conversion uc
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
ON pol.id = sol.pickOrderLineId
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
AND sil.deleted = 0
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
$stockCategorySql
$stockSubCategorySql
@@ -258,13 +249,23 @@ return result

fun getDistinctHandlersForFGStockOutTraceability(): List<String> {
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()
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 查看文件

@@ -5,6 +5,7 @@ import java.util.List;
import org.springframework.http.HttpStatus;
import org.springframework.web.bind.annotation.GetMapping;
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.RequestBody;
import org.springframework.web.bind.annotation.RequestMapping;
@@ -41,13 +42,24 @@ public class SettingsController{
// @PreAuthorize("hasAuthority('ADMIN')")
@ResponseStatus(HttpStatus.NO_CONTENT)
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)
.orElseThrow(NotFoundException::new);
if (!this.settingsService.validateType(entity.getType(), body.value)) {
if (!this.settingsService.validateType(entity.getType(), body.getValue())) {
throw new BadRequestException();
}

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



+ 16
- 5
src/main/java/com/ffii/fpsms/modules/stock/entity/InventoryRepository.kt 查看文件

@@ -14,11 +14,22 @@ import java.util.Optional
interface InventoryRepository: AbstractRepository<Inventory, Long> {
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>

@Query("SELECT i FROM Inventory i " +


+ 13
- 12
src/main/java/com/ffii/fpsms/modules/stock/entity/StockLedgerRepository.kt 查看文件

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

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.repository.query.Param
import org.springframework.stereotype.Repository
import java.time.LocalDate
import java.util.Optional
import java.time.LocalDateTime
@Repository
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 (:itemName IS NULL OR i.name LIKE CONCAT('%', :itemName, '%'))
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(
@Param("itemCode") itemCode: String?,
@Param("itemName") itemName: 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("""
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 (:itemName IS NULL OR i.name LIKE CONCAT('%', :itemName, '%'))
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(
@Param("itemCode") itemCode: String?,
@Param("itemName") itemName: String?,
@Param("type") type: String?,
@Param("startDate") startDate: LocalDate?,
@Param("endDate") endDate: LocalDate?
@Param("startDateTime") startDateTime: LocalDateTime?,
@Param("endDateExclusive") endDateExclusive: LocalDateTime?
): Long




+ 19
- 34
src/main/java/com/ffii/fpsms/modules/stock/service/StockTakeRecordService.kt 查看文件

@@ -16,6 +16,7 @@ import java.time.LocalDateTime
import java.math.BigDecimal
import com.ffii.fpsms.modules.user.entity.UserRepository
import org.springframework.data.domain.PageRequest
import org.springframework.data.domain.Sort
import com.ffii.core.response.RecordsRes
import com.ffii.fpsms.modules.stock.service.InventoryLotLineService
import com.ffii.fpsms.modules.stock.entity.StockTakeLine
@@ -2741,40 +2742,32 @@ open fun searchStockTransactions(request: SearchStockTransactionRequest): Record
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,
itemName = itemName,
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 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
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 查看文件

@@ -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.web.model.StockOutStatus
import com.ffii.fpsms.modules.common.SecurityUtils
import com.ffii.fpsms.modules.deliveryOrder.service.DoFloorSupplierSettingsService
@Service
open class SuggestedPickLotService(
val suggestedPickLotRepository: SuggestPickLotRepository,
@@ -57,7 +58,8 @@ open class SuggestedPickLotService(
val failInventoryLotLineRepository: FailInventoryLotLineRepository,
val stockOutRepository: StockOutRepository,
val itemRepository: ItemsRepository,
val stockOutLineRepository: StockOutLIneRepository
val stockOutLineRepository: StockOutLIneRepository,
private val doFloorSupplierSettingsService: DoFloorSupplierSettingsService,
) {

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

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


+ 14
- 1
src/main/java/com/ffii/fpsms/modules/stock/web/StockTakeRecordController.kt 查看文件

@@ -32,7 +32,8 @@ class StockTakeRecordController(
@RequestParam(required = false) stockTakeSections: String?,
@RequestParam(required = false) status: 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> {
var all = stockOutRecordService.AllPickedStockTakeList()
if (sectionDescription != null && sectionDescription != "All") {
@@ -71,6 +72,18 @@ class StockTakeRecordController(
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 fromIndex = pageNum * pageSize
val toIndex = minOf(fromIndex + pageSize, total)


+ 8
- 0
src/main/java/com/ffii/fpsms/modules/stock/web/model/CreateStockTakeForSectionsRequest.kt 查看文件

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

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

import java.util.Date;
import java.util.LinkedHashMap;
import java.util.List;
import java.util.Map;
import java.util.stream.Collectors;
@@ -209,4 +210,13 @@ public class GroupService extends AbstractBaseEntityService<Group, Long, GroupRe
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 查看文件

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


+ 9
- 1
src/main/java/com/ffii/fpsms/modules/user/web/GroupController.java 查看文件

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

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

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.PathVariable;
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.RequestMapping;
import org.springframework.web.bind.annotation.ResponseStatus;
@@ -80,7 +82,6 @@ public class GroupController{

@GetMapping("/auth/{target}/{id}")
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<>();
if (id != 0){
if (target.equals("group")){
@@ -94,4 +95,11 @@ public class GroupController{
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 查看文件

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

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



+ 4
- 0
src/main/resources/application.yml 查看文件

@@ -27,6 +27,10 @@ scheduler:
syncOffsetDays: 0
inventoryLotExpiry:
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/上架:今日+昨日).
fpsms:


+ 130
- 0
src/main/resources/db/changelog/changes/20260430_02_2fi/01_truck_lane_version_snapshot.sql 查看文件

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

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

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

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

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

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

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

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

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

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

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

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


+ 1
- 0
src/main/resources/log4j2-prod-win.yml 查看文件

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


+ 1
- 0
src/main/resources/log4j2.yml 查看文件

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


正在加载...
取消
保存