From 891a929e1fceb8602275866683437fa0911872bf Mon Sep 17 00:00:00 2001 From: "B.E.N.S.O.N" Date: Tue, 12 May 2026 18:04:29 +0800 Subject: [PATCH 01/11] User Page Update --- .../ffii/fpsms/modules/user/service/GroupService.java | 10 ++++++++++ .../ffii/fpsms/modules/user/service/UserService.java | 8 ++++---- .../ffii/fpsms/modules/user/web/GroupController.java | 10 +++++++++- .../ffii/fpsms/modules/user/web/UserController.java | 7 +------ 4 files changed, 24 insertions(+), 11 deletions(-) diff --git a/src/main/java/com/ffii/fpsms/modules/user/service/GroupService.java b/src/main/java/com/ffii/fpsms/modules/user/service/GroupService.java index 2d41d9e..79a4f5c 100644 --- a/src/main/java/com/ffii/fpsms/modules/user/service/GroupService.java +++ b/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>> listAuthForUsers(List userIds) { + Map>> result = new LinkedHashMap<>(); + for (Integer userId : userIds) { + result.put(userId, listAuth(Map.of("userId", userId))); + } + return result; + } + } diff --git a/src/main/java/com/ffii/fpsms/modules/user/service/UserService.java b/src/main/java/com/ffii/fpsms/modules/user/service/UserService.java index fa9e7df..9c57e79 100644 --- a/src/main/java/com/ffii/fpsms/modules/user/service/UserService.java +++ b/src/main/java/com/ffii/fpsms/modules/user/service/UserService.java @@ -185,8 +185,8 @@ public class UserService extends AbstractBaseEntityService> authComboJson(HttpServletRequest request, @PathVariable("id") int id, @PathVariable("target") String target) throws ServletRequestBindingException { - System.out.println(request); Map 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>> authBatchByUserIds( + @RequestParam("userIds") List userIds + ) { + return groupService.listAuthForUsers(userIds); + } + } diff --git a/src/main/java/com/ffii/fpsms/modules/user/web/UserController.java b/src/main/java/com/ffii/fpsms/modules/user/web/UserController.java index af4abda..c464cda 100644 --- a/src/main/java/com/ffii/fpsms/modules/user/web/UserController.java +++ b/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(@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()); } From 4b83633f28162543a422cbe852c051fc5100e07f Mon Sep 17 00:00:00 2001 From: "vluk@2fi-solutions.com.hk" Date: Wed, 13 May 2026 15:20:32 +0800 Subject: [PATCH 02/11] updated Bag3, adding m18 BOM syn, delete the DO2_EXTRA syn, move the DO2 sync to 1pm --- python/Bag3.py | 97 ++++++-- python/__pycache__/Bag3.cpython-313.pyc | Bin 0 -> 135895 bytes .../fpsms/m18/entity/M18BomShopSyncLog.kt | 39 +++ .../m18/entity/M18BomShopSyncLogRepository.kt | 5 + .../m18/model/M18BomForShopSaveRequest.kt | 86 +++++++ .../m18/model/M18BomShopSyncTriggerResult.kt | 14 ++ .../fpsms/m18/service/M18BomForShopService.kt | 223 ++++++++++++++++++ .../ffii/fpsms/m18/web/M18TestController.kt | 12 +- .../fpsms/modules/common/SettingNames.java | 5 + .../scheduler/service/SchedulerService.kt | 42 +--- .../service/PlasticBagPrinterService.kt | 9 +- .../modules/master/service/BomService.kt | 141 +++++++++++ .../modules/master/service/ItemUomService.kt | 14 ++ .../fpsms/modules/master/web/BomController.kt | 12 + .../web/models/BomIdByItemCodeResponse.kt | 14 ++ .../20260118_fai/01_insert_scheduler.sql | 2 +- .../01_m18_bom_shop_sync_settings.sql | 20 ++ .../20260512_fai/02_m18_bom_shop_sync_log.sql | 24 ++ .../01_update_do2_schedule_to_13.sql | 4 + src/main/resources/log4j2-prod-linux.yml | 1 + src/main/resources/log4j2-prod-win.yml | 1 + src/main/resources/log4j2.yml | 1 + 22 files changed, 712 insertions(+), 54 deletions(-) create mode 100644 python/__pycache__/Bag3.cpython-313.pyc create mode 100644 src/main/java/com/ffii/fpsms/m18/entity/M18BomShopSyncLog.kt create mode 100644 src/main/java/com/ffii/fpsms/m18/entity/M18BomShopSyncLogRepository.kt create mode 100644 src/main/java/com/ffii/fpsms/m18/model/M18BomForShopSaveRequest.kt create mode 100644 src/main/java/com/ffii/fpsms/m18/model/M18BomShopSyncTriggerResult.kt create mode 100644 src/main/java/com/ffii/fpsms/m18/service/M18BomForShopService.kt create mode 100644 src/main/java/com/ffii/fpsms/modules/master/web/models/BomIdByItemCodeResponse.kt create mode 100644 src/main/resources/db/changelog/changes/20260512_fai/01_m18_bom_shop_sync_settings.sql create mode 100644 src/main/resources/db/changelog/changes/20260512_fai/02_m18_bom_shop_sync_log.sql create mode 100644 src/main/resources/db/changelog/changes/20260513_do2_1pm/01_update_do2_schedule_to_13.sql diff --git a/python/Bag3.py b/python/Bag3.py index 63b5d83..887c401 100644 --- a/python/Bag3.py +++ b/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: diff --git a/python/__pycache__/Bag3.cpython-313.pyc b/python/__pycache__/Bag3.cpython-313.pyc new file mode 100644 index 0000000000000000000000000000000000000000..a01ec70f11042aa6ae472c4dfdffdae5b2685cbc GIT binary patch literal 135895 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 M_bTQ8sNezrKdrMNYXATM literal 0 HcmV?d00001 diff --git a/src/main/java/com/ffii/fpsms/m18/entity/M18BomShopSyncLog.kt b/src/main/java/com/ffii/fpsms/m18/entity/M18BomShopSyncLog.kt new file mode 100644 index 0000000..5d85df5 --- /dev/null +++ b/src/main/java/com/ffii/fpsms/m18/entity/M18BomShopSyncLog.kt @@ -0,0 +1,39 @@ +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() { + + @NotNull + @Column(name = "bom_id", nullable = false) + open var bomId: Long? = 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 +} diff --git a/src/main/java/com/ffii/fpsms/m18/entity/M18BomShopSyncLogRepository.kt b/src/main/java/com/ffii/fpsms/m18/entity/M18BomShopSyncLogRepository.kt new file mode 100644 index 0000000..bf6c04c --- /dev/null +++ b/src/main/java/com/ffii/fpsms/m18/entity/M18BomShopSyncLogRepository.kt @@ -0,0 +1,5 @@ +package com.ffii.fpsms.m18.entity + +import com.ffii.core.support.AbstractRepository + +interface M18BomShopSyncLogRepository : AbstractRepository diff --git a/src/main/java/com/ffii/fpsms/m18/model/M18BomForShopSaveRequest.kt b/src/main/java/com/ffii/fpsms/m18/model/M18BomForShopSaveRequest.kt new file mode 100644 index 0000000..7b83edc --- /dev/null +++ b/src/main/java/com/ffii/fpsms/m18/model/M18BomForShopSaveRequest.kt @@ -0,0 +1,86 @@ +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, +) + +/** + * 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, + 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, +) + +/** + * Line payload for `udfproduct.values[]`. **`udfBaseUnit`** is the FPSMS UOM **code** for the line. + * **`udfpurchaseUnit`** / **`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, + val udfSupplier: Long? = null, + /** Line sequence, e.g. " 1" */ + val itemNo: String? = null, + val udfoptions: String? = null, + val udfoption: Number? = null, + val udfYieldRate: Number? = null, +) diff --git a/src/main/java/com/ffii/fpsms/m18/model/M18BomShopSyncTriggerResult.kt b/src/main/java/com/ffii/fpsms/m18/model/M18BomShopSyncTriggerResult.kt new file mode 100644 index 0000000..8f78e08 --- /dev/null +++ b/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, +) diff --git a/src/main/java/com/ffii/fpsms/m18/service/M18BomForShopService.kt b/src/main/java/com/ffii/fpsms/m18/service/M18BomForShopService.kt new file mode 100644 index 0000000..4ab7dbf --- /dev/null +++ b/src/main/java/com/ffii/fpsms/m18/service/M18BomForShopService.kt @@ -0,0 +1,223 @@ +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.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 org.slf4j.Logger +import org.slf4j.LoggerFactory +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.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 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") + } + + @Suppress("DEPRECATION") + private val objectMapper: ObjectMapper = jacksonObjectMapper().apply { + disable(JsonGenerator.Feature.ESCAPE_NON_ASCII) + } + + /** + * Builds M18 save body from a persisted BOM (materials loaded). + * [headerM18IdOverride] optional M18 header record id (e.g. from `/bom/by-item-code` `bomM18Id`) when DB column is stale. + * Otherwise uses [Bom.m18Id] when set for **update**; omitted for **create**. + * Returns null if required M18 ids are missing (caller should log and skip). + */ + open fun buildSaveRequest(bom: Bom, headerM18IdOverride: Long? = null): M18BomForShopSaveRequest? { + val code = bom.code ?: return null + val flowTypeId = resolveFlowTypeId(code) + 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 effectiveHeaderM18Id = + headerM18IdOverride?.takeIf { it > 0 } ?: bom.m18Id?.takeIf { it > 0 } + val header = M18MainUdfBomForShopValue( + id = effectiveHeaderM18Id?.toString(), + code = code, + beId = bomShopMainBeId, + desc = bom.name ?: bom.description, + descEn = bom.name ?: bom.description, + udfBomCode = deriveUdfBomCode(code), + rev = deriveRev(code), + udfUnit = udfUnit, + udfHarvest = udfHarvest, + udfHarvestUnit = udfHarvestUnit, + udfEffectiveDate = udfEffectiveDate, + udfYieldratePP = bom.yield, + udftypeoffood = "半成品", + staffId = 232, + flowTypeId = flowTypeId, + virDeptId = 117, + status = "Y", + ) + + 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=${bom.id} code=$code has no materials; skipping M18 save") + return null + } + + logger.info( + "[M18 BOM] buildSaveRequest fpsmsBomId=${bom.id} code=$code mainM18Id=$effectiveHeaderM18Id " + + "(m18HeaderIdOverride=$headerM18IdOverride, bom.m18Id=${bom.m18Id})", + ) + + return M18BomForShopSaveRequest( + udfbomforshop = M18MainUdfBomForShopWrapper(values = listOf(header)), + udfproduct = M18UdfProductWrapper(values = lines), + ) + } + + /** + * 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 { + 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 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 = 0L, + itemNo = String.format("%6d", lineNo), + udfoptions = "", + udfoption = 0.0, + udfYieldRate = 0.0, + ) + } + + private fun deriveUdfBomCode(fullCode: String): String { + val v = Regex("^(.*)V(\\d+)$").find(fullCode) + return if (v != null) v.groupValues[1] else fullCode + } + + private fun deriveRev(fullCode: String): String? { + val v = Regex("^.*V(\\d+)$").find(fullCode) ?: return null + return v.groupValues[1] + } + + 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 { + val queryParams = LinkedMultiValueMap().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( + 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) + } + } +} diff --git a/src/main/java/com/ffii/fpsms/m18/web/M18TestController.kt b/src/main/java/com/ffii/fpsms/m18/web/M18TestController.kt index 5fbafb9..4da2df9 100644 --- a/src/main/java/com/ffii/fpsms/m18/web/M18TestController.kt +++ b/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) diff --git a/src/main/java/com/ffii/fpsms/modules/common/SettingNames.java b/src/main/java/com/ffii/fpsms/modules/common/SettingNames.java index 61cb2b7..e0fa020 100644 --- a/src/main/java/com/ffii/fpsms/modules/common/SettingNames.java +++ b/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"; diff --git a/src/main/java/com/ffii/fpsms/modules/common/scheduler/service/SchedulerService.kt b/src/main/java/com/ffii/fpsms/modules/common/scheduler/service/SchedulerService.kt index ba3b879..beaabe3 100644 --- a/src/main/java/com/ffii/fpsms/modules/common/scheduler/service/SchedulerService.kt +++ b/src/main/java/com/ffii/fpsms/modules/common/scheduler/service/SchedulerService.kt @@ -57,6 +57,12 @@ open class SchedulerService( val m18GrnCodeSyncService: M18GrnCodeSyncService, val inventoryLotLineService: InventoryLotLineService, ) { + 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 * * *" + } + 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") @@ -206,7 +212,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() { @@ -455,7 +461,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 +471,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 +497,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( diff --git a/src/main/java/com/ffii/fpsms/modules/jobOrder/service/PlasticBagPrinterService.kt b/src/main/java/com/ffii/fpsms/modules/jobOrder/service/PlasticBagPrinterService.kt index fcad435..78dcb59 100644 --- a/src/main/java/com/ffii/fpsms/modules/jobOrder/service/PlasticBagPrinterService.kt +++ b/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 diff --git a/src/main/java/com/ffii/fpsms/modules/master/service/BomService.kt b/src/main/java/com/ffii/fpsms/modules/master/service/BomService.kt index 47bc322..efa8f93 100644 --- a/src/main/java/com/ffii/fpsms/modules/master/service/BomService.kt +++ b/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): 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,111 @@ 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 + 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() diff --git a/src/main/java/com/ffii/fpsms/modules/master/service/ItemUomService.kt b/src/main/java/com/ffii/fpsms/modules/master/service/ItemUomService.kt index adc2179..275b80c 100644 --- a/src/main/java/com/ffii/fpsms/modules/master/service/ItemUomService.kt +++ b/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) } diff --git a/src/main/java/com/ffii/fpsms/modules/master/web/BomController.kt b/src/main/java/com/ffii/fpsms/modules/master/web/BomController.kt index 79be196..5e97b94 100644 --- a/src/main/java/com/ffii/fpsms/modules/master/web/BomController.kt +++ b/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) diff --git a/src/main/java/com/ffii/fpsms/modules/master/web/models/BomIdByItemCodeResponse.kt b/src/main/java/com/ffii/fpsms/modules/master/web/models/BomIdByItemCodeResponse.kt new file mode 100644 index 0000000..da7f821 --- /dev/null +++ b/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, +) diff --git a/src/main/resources/db/changelog/changes/20260118_fai/01_insert_scheduler.sql b/src/main/resources/db/changelog/changes/20260118_fai/01_insert_scheduler.sql index 0b3cee9..ad4287b 100644 --- a/src/main/resources/db/changelog/changes/20260118_fai/01_insert_scheduler.sql +++ b/src/main/resources/db/changelog/changes/20260118_fai/01_insert_scheduler.sql @@ -9,7 +9,7 @@ WHERE NOT EXISTS ( ); INSERT INTO `fpsmsdb`.`settings` (`name`, `value`, `category`, `type`) -SELECT 'SCHEDULE.m18.do2', '0 0 11 * * *', 'SCHEDULE', 'string' +SELECT 'SCHEDULE.m18.do2', '0 0 13 * * *', 'SCHEDULE', 'string' FROM DUAL WHERE NOT EXISTS ( SELECT 1 FROM `fpsmsdb`.`settings` WHERE `name` = 'SCHEDULE.m18.do2' diff --git a/src/main/resources/db/changelog/changes/20260512_fai/01_m18_bom_shop_sync_settings.sql b/src/main/resources/db/changelog/changes/20260512_fai/01_m18_bom_shop_sync_settings.sql new file mode 100644 index 0000000..cf1102c --- /dev/null +++ b/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' +); diff --git a/src/main/resources/db/changelog/changes/20260512_fai/02_m18_bom_shop_sync_log.sql b/src/main/resources/db/changelog/changes/20260512_fai/02_m18_bom_shop_sync_log.sql new file mode 100644 index 0000000..e0e1805 --- /dev/null +++ b/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; diff --git a/src/main/resources/db/changelog/changes/20260513_do2_1pm/01_update_do2_schedule_to_13.sql b/src/main/resources/db/changelog/changes/20260513_do2_1pm/01_update_do2_schedule_to_13.sql new file mode 100644 index 0000000..0c849bb --- /dev/null +++ b/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'; diff --git a/src/main/resources/log4j2-prod-linux.yml b/src/main/resources/log4j2-prod-linux.yml index c90a291..f19e1f1 100644 --- a/src/main/resources/log4j2-prod-linux.yml +++ b/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 diff --git a/src/main/resources/log4j2-prod-win.yml b/src/main/resources/log4j2-prod-win.yml index d4f8a75..3152168 100644 --- a/src/main/resources/log4j2-prod-win.yml +++ b/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 diff --git a/src/main/resources/log4j2.yml b/src/main/resources/log4j2.yml index e3b20ff..8977b61 100644 --- a/src/main/resources/log4j2.yml +++ b/src/main/resources/log4j2.yml @@ -10,6 +10,7 @@ Configutation: target: SYSTEM_OUT PatternLayout: pattern: ${log_pattern} + charset: UTF-8 Loggers: Root: level: info From dd348f36ae6b2f27f305cdd9dca989dadf16e4ba Mon Sep 17 00:00:00 2001 From: "vluk@2fi-solutions.com.hk" Date: Wed, 13 May 2026 21:47:40 +0800 Subject: [PATCH 03/11] adding for bom sync --- .gitignore | 3 +- .../fpsms/m18/entity/M18BomShopSyncLog.kt | 9 + .../m18/entity/M18BomShopSyncLogRepository.kt | 9 +- .../m18/model/M18BomForShopSaveRequest.kt | 12 +- .../fpsms/m18/service/M18BomForShopService.kt | 239 +++++++++++++++--- .../modules/master/service/BomService.kt | 3 + .../entity/PurchaseOrderLineRepository.kt | 32 +++ .../20260118_fai/01_insert_scheduler.sql | 2 +- .../01_m18_bom_shop_sync_log_columns.sql | 7 + 9 files changed, 278 insertions(+), 38 deletions(-) create mode 100644 src/main/resources/db/changelog/changes/20260515_fpsms/01_m18_bom_shop_sync_log_columns.sql diff --git a/.gitignore b/.gitignore index 28da35d..f0ac852 100644 --- a/.gitignore +++ b/.gitignore @@ -37,4 +37,5 @@ out/ .vscode/ package-lock.json python/Bag3.spec -python/dist/Bag3.exe +python/dist + diff --git a/src/main/java/com/ffii/fpsms/m18/entity/M18BomShopSyncLog.kt b/src/main/java/com/ffii/fpsms/m18/entity/M18BomShopSyncLog.kt index 5d85df5..64578c4 100644 --- a/src/main/java/com/ffii/fpsms/m18/entity/M18BomShopSyncLog.kt +++ b/src/main/java/com/ffii/fpsms/m18/entity/M18BomShopSyncLog.kt @@ -17,6 +17,15 @@ open class M18BomShopSyncLog : BaseEntity() { @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 diff --git a/src/main/java/com/ffii/fpsms/m18/entity/M18BomShopSyncLogRepository.kt b/src/main/java/com/ffii/fpsms/m18/entity/M18BomShopSyncLogRepository.kt index bf6c04c..ee1b83a 100644 --- a/src/main/java/com/ffii/fpsms/m18/entity/M18BomShopSyncLogRepository.kt +++ b/src/main/java/com/ffii/fpsms/m18/entity/M18BomShopSyncLogRepository.kt @@ -2,4 +2,11 @@ package com.ffii.fpsms.m18.entity import com.ffii.core.support.AbstractRepository -interface M18BomShopSyncLogRepository : AbstractRepository +interface M18BomShopSyncLogRepository : AbstractRepository { + fun findFirstByBomIdOrderByIdDesc(bomId: Long): M18BomShopSyncLog? + + fun findTop100ByBomIdOrderByIdDesc(bomId: Long): List + + /** Successful M18 udfBomForShop saves only — used for `BOM{item}Vnnn` version allocation. */ + fun findTop100ByBomIdAndSyncedIsTrueOrderByIdDesc(bomId: Long): List +} diff --git a/src/main/java/com/ffii/fpsms/m18/model/M18BomForShopSaveRequest.kt b/src/main/java/com/ffii/fpsms/m18/model/M18BomForShopSaveRequest.kt index 7b83edc..6dfe3bc 100644 --- a/src/main/java/com/ffii/fpsms/m18/model/M18BomForShopSaveRequest.kt +++ b/src/main/java/com/ffii/fpsms/m18/model/M18BomForShopSaveRequest.kt @@ -53,6 +53,8 @@ data class M18MainUdfBomForShopValue( @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, @@ -66,7 +68,7 @@ data class M18UdfProductWrapper( /** * Line payload for `udfproduct.values[]`. **`udfBaseUnit`** is the FPSMS UOM **code** for the line. - * **`udfpurchaseUnit`** / **`udfPackingUnit`** / **`udfPackingQty`** / **`udfproremark`** are not sent. + * **`udfPackingUnit`** / **`udfPackingQty`** / **`udfproremark`** are not sent. */ @JsonInclude(JsonInclude.Include.NON_NULL) data class M18UdfProductSaveValue( @@ -77,7 +79,15 @@ data class M18UdfProductSaveValue( 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, diff --git a/src/main/java/com/ffii/fpsms/m18/service/M18BomForShopService.kt b/src/main/java/com/ffii/fpsms/m18/service/M18BomForShopService.kt index 4ab7dbf..f74dd70 100644 --- a/src/main/java/com/ffii/fpsms/m18/service/M18BomForShopService.kt +++ b/src/main/java/com/ffii/fpsms/m18/service/M18BomForShopService.kt @@ -5,6 +5,8 @@ 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 @@ -14,14 +16,17 @@ 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 /** @@ -33,6 +38,8 @@ 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) @@ -51,6 +58,9 @@ open class M18BomForShopService( 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") @@ -58,55 +68,104 @@ open class M18BomForShopService( 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 (e.g. from `/bom/by-item-code` `bomM18Id`) when DB column is stale. - * Otherwise uses [Bom.m18Id] when set for **update**; omitted for **create**. - * Returns null if required M18 ids are missing (caller should log and skip). + * [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 code = bom.code ?: return null - val flowTypeId = resolveFlowTypeId(code) + 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 effectiveHeaderM18Id = - headerM18IdOverride?.takeIf { it > 0 } ?: bom.m18Id?.takeIf { it > 0 } + 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 = effectiveHeaderM18Id?.toString(), - code = code, + id = headerM18IdForRequest?.toString(), + code = headerCode, beId = bomShopMainBeId, desc = bom.name ?: bom.description, descEn = bom.name ?: bom.description, - udfBomCode = deriveUdfBomCode(code), - rev = deriveRev(code), + 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", ) - 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=${bom.id} code=$code has no materials; skipping M18 save") - return null - } - logger.info( - "[M18 BOM] buildSaveRequest fpsmsBomId=${bom.id} code=$code mainM18Id=$effectiveHeaderM18Id " + - "(m18HeaderIdOverride=$headerM18IdOverride, bom.m18Id=${bom.m18Id})", + "[M18 BOM] buildSaveRequest fpsmsBomId=$bomId routingCode=$routingCode itemCode=$itemCode headerCode=$headerCode " + + "mainM18Id=$headerM18IdForRequest (override=$headerM18IdOverride, bom.m18Id=${bom.m18Id})", ) return M18BomForShopSaveRequest( @@ -115,6 +174,110 @@ open class M18BomForShopService( ) } + @Suppress("LongParameterList") + private fun resolveHeaderCodeAndM18Id( + bomId: Long, + itemCode: String, + lines: List, + udfUnit: Long, + udfHarvest: String, + udfHarvestUnit: String?, + udfEffectiveDate: Long?, + bomYield: BigDecimal?, + bomName: String?, + bomDescription: String?, + flowTypeId: Int, + headerM18IdOverride: Long?, + bomM18Id: Long?, + ): Triple { + 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. @@ -160,6 +323,23 @@ open class M18BomForShopService( 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 }, @@ -167,7 +347,8 @@ open class M18BomForShopService( udfProduct = proId, udfIngredients = mat.itemName ?: mat.item?.name, udfBaseUnit = udfBaseUnit, - udfSupplier = 0L, + udfSupplier = supplierM18Id, + udfpurchaseUnit = purchaseUnitM18Id, itemNo = String.format("%6d", lineNo), udfoptions = "", udfoption = 0.0, @@ -175,16 +356,6 @@ open class M18BomForShopService( ) } - private fun deriveUdfBomCode(fullCode: String): String { - val v = Regex("^(.*)V(\\d+)$").find(fullCode) - return if (v != null) v.groupValues[1] else fullCode - } - - private fun deriveRev(fullCode: String): String? { - val v = Regex("^.*V(\\d+)$").find(fullCode) ?: return null - return v.groupValues[1] - } - private fun resolveFlowTypeId(code: String): Int = when { code.startsWith("TOA") -> 1 code.startsWith("BOMPP") || code.startsWith("PP") -> 3 diff --git a/src/main/java/com/ffii/fpsms/modules/master/service/BomService.kt b/src/main/java/com/ffii/fpsms/modules/master/service/BomService.kt index efa8f93..b10e26f 100644 --- a/src/main/java/com/ffii/fpsms/modules/master/service/BomService.kt +++ b/src/main/java/com/ffii/fpsms/modules/master/service/BomService.kt @@ -495,6 +495,9 @@ open class BomService( 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 diff --git a/src/main/java/com/ffii/fpsms/modules/purchaseOrder/entity/PurchaseOrderLineRepository.kt b/src/main/java/com/ffii/fpsms/modules/purchaseOrder/entity/PurchaseOrderLineRepository.kt index abde40e..a5a1896 100644 --- a/src/main/java/com/ffii/fpsms/modules/purchaseOrder/entity/PurchaseOrderLineRepository.kt +++ b/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 { + @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 + + /** + * 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 + fun findByM18DataLogIdAndDeletedIsFalse(m18datalogId: Serializable): PurchaseOrderLine? fun findAllPurchaseOrderLineInfoByPurchaseOrderIdAndDeletedIsFalse(purchaseOrderId: Long): List fun findAllByPurchaseOrderIdAndDeletedIsFalse(purchaseOrderId: Long): List diff --git a/src/main/resources/db/changelog/changes/20260118_fai/01_insert_scheduler.sql b/src/main/resources/db/changelog/changes/20260118_fai/01_insert_scheduler.sql index ad4287b..0b3cee9 100644 --- a/src/main/resources/db/changelog/changes/20260118_fai/01_insert_scheduler.sql +++ b/src/main/resources/db/changelog/changes/20260118_fai/01_insert_scheduler.sql @@ -9,7 +9,7 @@ WHERE NOT EXISTS ( ); INSERT INTO `fpsmsdb`.`settings` (`name`, `value`, `category`, `type`) -SELECT 'SCHEDULE.m18.do2', '0 0 13 * * *', 'SCHEDULE', 'string' +SELECT 'SCHEDULE.m18.do2', '0 0 11 * * *', 'SCHEDULE', 'string' FROM DUAL WHERE NOT EXISTS ( SELECT 1 FROM `fpsmsdb`.`settings` WHERE `name` = 'SCHEDULE.m18.do2' diff --git a/src/main/resources/db/changelog/changes/20260515_fpsms/01_m18_bom_shop_sync_log_columns.sql b/src/main/resources/db/changelog/changes/20260515_fpsms/01_m18_bom_shop_sync_log_columns.sql new file mode 100644 index 0000000..0d1d86f --- /dev/null +++ b/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`; From 9dd08d6a7030211f558795b4c1b676ec0f7938f7 Mon Sep 17 00:00:00 2001 From: "CANCERYS\\kw093" Date: Wed, 13 May 2026 23:00:12 +0800 Subject: [PATCH 04/11] update Report and stock ledger search --- .../FGStockOutTraceabilityReportService.kt | 70 +++++++++++-------- .../modules/report/service/ReportService.kt | 65 +++++++++-------- .../stock/entity/StockLedgerRepository.kt | 25 +++---- .../stock/service/StockTakeRecordService.kt | 53 +++++--------- .../stock/web/StockTakeRecordController.kt | 15 +++- 5 files changed, 123 insertions(+), 105 deletions(-) diff --git a/src/main/java/com/ffii/fpsms/modules/report/service/FGStockOutTraceabilityReportService.kt b/src/main/java/com/ffii/fpsms/modules/report/service/FGStockOutTraceabilityReportService.kt index 7c0857e..10e625e 100644 --- a/src/main/java/com/ffii/fpsms/modules/report/service/FGStockOutTraceabilityReportService.kt +++ b/src/main/java/com/ffii/fpsms/modules/report/service/FGStockOutTraceabilityReportService.kt @@ -9,13 +9,23 @@ class FGStockOutTraceabilityReportService( ) { fun getDistinctHandlersForFGStockOutTraceability(): List { 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, diff --git a/src/main/java/com/ffii/fpsms/modules/report/service/ReportService.kt b/src/main/java/com/ffii/fpsms/modules/report/service/ReportService.kt index ffac33c..67fec79 100644 --- a/src/main/java/com/ffii/fpsms/modules/report/service/ReportService.kt +++ b/src/main/java/com/ffii/fpsms/modules/report/service/ReportService.kt @@ -101,7 +101,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 +109,25 @@ 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 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,7 +138,7 @@ 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 @@ -157,9 +157,9 @@ FORMAT(ROUND(IFNULL(IFNULL(sol.qty, dol.qty), 0), 0), 0) AS qty, 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' @@ -183,13 +183,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 +196,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 +218,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 +233,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 +257,23 @@ return result fun getDistinctHandlersForFGStockOutTraceability(): List { 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()).map { row -> (row["handler"]?.toString() ?: "").trim() }.filter { it.isNotBlank() } } diff --git a/src/main/java/com/ffii/fpsms/modules/stock/entity/StockLedgerRepository.kt b/src/main/java/com/ffii/fpsms/modules/stock/entity/StockLedgerRepository.kt index 9ac5eac..1a9858a 100644 --- a/src/main/java/com/ffii/fpsms/modules/stock/entity/StockLedgerRepository.kt +++ b/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 { @@ -19,17 +20,17 @@ interface StockLedgerRepository: AbstractRepository { 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 + @Param("startDateTime") startDateTime: LocalDateTime?, + @Param("endDateExclusive") endDateExclusive: LocalDateTime?, + pageable: Pageable + ): Page @Query(""" SELECT COUNT(sl) FROM StockLedger sl @@ -39,15 +40,15 @@ interface StockLedgerRepository: AbstractRepository { 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 diff --git a/src/main/java/com/ffii/fpsms/modules/stock/service/StockTakeRecordService.kt b/src/main/java/com/ffii/fpsms/modules/stock/service/StockTakeRecordService.kt index b15337b..812c325 100644 --- a/src/main/java/com/ffii/fpsms/modules/stock/service/StockTakeRecordService.kt +++ b/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( - { 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()) } } diff --git a/src/main/java/com/ffii/fpsms/modules/stock/web/StockTakeRecordController.kt b/src/main/java/com/ffii/fpsms/modules/stock/web/StockTakeRecordController.kt index 806d3e4..6931ea9 100644 --- a/src/main/java/com/ffii/fpsms/modules/stock/web/StockTakeRecordController.kt +++ b/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 { 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) From 870fbca20e62de811a560111a2b5ee4fd4d96e64 Mon Sep 17 00:00:00 2001 From: "CANCERYS\\kw093" Date: Thu, 14 May 2026 15:04:54 +0800 Subject: [PATCH 05/11] new supplier isEtra new do chart do saerch batch release button put down not lot requied qty show 0 fix --- .../m18/service/M18DeliveryOrderService.kt | 12 +- .../ffii/fpsms/m18/web/M18TestController.kt | 6 +- .../modules/chart/service/ChartService.kt | 46 ++-- .../modules/chart/web/ChartController.kt | 10 +- .../deliveryOrder/entity/DeliveryOrder.kt | 4 +- .../entity/DeliveryOrderRepository.kt | 8 +- .../entity/models/DeliveryOrderInfo.kt | 6 +- .../service/DeliveryOrderService.kt | 134 +++++------ .../service/DoFloorSupplierSettingsService.kt | 95 ++++++++ .../service/DoReleaseCoordinatorService.kt | 27 +-- .../DoWorkbenchDopoAssignmentService.kt | 11 + .../service/DoWorkbenchMainService.kt | 216 +++++++++++++++++- .../service/DoWorkbenchReleaseService.kt | 29 ++- .../web/DeliveryOrderController.kt | 6 +- .../web/DoWorkbenchController.kt | 19 +- .../web/models/DoDetailResponse.kt | 19 +- .../web/models/ReleaseDoRequest.kt | 9 +- .../web/models/SaveDeliveryOrderRequest.kt | 2 +- .../service/HierarchicalFgPayloadAssembler.kt | 3 + .../modules/report/service/ReportService.kt | 22 +- .../settings/web/SettingsController.java | 16 +- .../stock/service/SuggestedPickLotService.kt | 16 +- .../CreateStockTakeForSectionsRequest.kt | 8 + .../changes/20260514_Enson/01_setting.sql | 18 ++ .../changes/20260514_Enson/02_setting.sql | 6 + 25 files changed, 552 insertions(+), 196 deletions(-) create mode 100644 src/main/java/com/ffii/fpsms/modules/deliveryOrder/service/DoFloorSupplierSettingsService.kt create mode 100644 src/main/java/com/ffii/fpsms/modules/stock/web/model/CreateStockTakeForSectionsRequest.kt create mode 100644 src/main/resources/db/changelog/changes/20260514_Enson/01_setting.sql create mode 100644 src/main/resources/db/changelog/changes/20260514_Enson/02_setting.sql diff --git a/src/main/java/com/ffii/fpsms/m18/service/M18DeliveryOrderService.kt b/src/main/java/com/ffii/fpsms/m18/service/M18DeliveryOrderService.kt index 52c2576..08781e3 100644 --- a/src/main/java/com/ffii/fpsms/m18/service/M18DeliveryOrderService.kt +++ b/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 = diff --git a/src/main/java/com/ffii/fpsms/m18/web/M18TestController.kt b/src/main/java/com/ffii/fpsms/m18/web/M18TestController.kt index 4da2df9..2138251 100644 --- a/src/main/java/com/ffii/fpsms/m18/web/M18TestController.kt +++ b/src/main/java/com/ffii/fpsms/m18/web/M18TestController.kt @@ -82,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") diff --git a/src/main/java/com/ffii/fpsms/modules/chart/service/ChartService.kt b/src/main/java/com/ffii/fpsms/modules/chart/service/ChartService.kt index 73b3a2e..90b6f5d 100644 --- a/src/main/java/com/ffii/fpsms/modules/chart/service/ChartService.kt +++ b/src/main/java/com/ffii/fpsms/modules/chart/service/ChartService.kt @@ -721,23 +721,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? + staffNos: List?, + storeId: String?, + storeIdNull: Boolean?, ): List> { val args = mutableMapOf() 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 +750,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) diff --git a/src/main/java/com/ffii/fpsms/modules/chart/web/ChartController.kt b/src/main/java/com/ffii/fpsms/modules/chart/web/ChartController.kt index c83ef36..3de7d68 100644 --- a/src/main/java/com/ffii/fpsms/modules/chart/web/ChartController.kt +++ b/src/main/java/com/ffii/fpsms/modules/chart/web/ChartController.kt @@ -192,16 +192,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?, + @RequestParam(required = false) storeId: String?, + @RequestParam(required = false) storeIdNull: Boolean?, ): List> = - chartService.getStaffDeliveryPerformance(startDate, endDate, staffNo) + chartService.getStaffDeliveryPerformance(startDate, endDate, staffNo, storeId, storeIdNull) // ---------- Job order reports ---------- diff --git a/src/main/java/com/ffii/fpsms/modules/deliveryOrder/entity/DeliveryOrder.kt b/src/main/java/com/ffii/fpsms/modules/deliveryOrder/entity/DeliveryOrder.kt index d9dc6f2..1e61124 100644 --- a/src/main/java/com/ffii/fpsms/modules/deliveryOrder/entity/DeliveryOrder.kt +++ b/src/main/java/com/ffii/fpsms/modules/deliveryOrder/entity/DeliveryOrder.kt @@ -64,6 +64,6 @@ open class DeliveryOrder: BaseEntity() { 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 } \ No newline at end of file diff --git a/src/main/java/com/ffii/fpsms/modules/deliveryOrder/entity/DeliveryOrderRepository.kt b/src/main/java/com/ffii/fpsms/modules/deliveryOrder/entity/DeliveryOrderRepository.kt index 51c9260..65b48fd 100644 --- a/src/main/java/com/ffii/fpsms/modules/deliveryOrder/entity/DeliveryOrderRepository.kt +++ b/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 @@ -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, pageable: Pageable, ): Page diff --git a/src/main/java/com/ffii/fpsms/modules/deliveryOrder/entity/models/DeliveryOrderInfo.kt b/src/main/java/com/ffii/fpsms/modules/deliveryOrder/entity/models/DeliveryOrderInfo.kt index f806cb0..c27646e 100644 --- a/src/main/java/com/ffii/fpsms/modules/deliveryOrder/entity/models/DeliveryOrderInfo.kt +++ b/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, ) \ No newline at end of file diff --git a/src/main/java/com/ffii/fpsms/modules/deliveryOrder/service/DeliveryOrderService.kt b/src/main/java/com/ffii/fpsms/modules/deliveryOrder/service/DeliveryOrderService.kt index 3f7ef62..abfba71 100644 --- a/src/main/java/com/ffii/fpsms/modules/deliveryOrder/service/DeliveryOrderService.kt +++ b/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 { - 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 = + doFloorSupplierSettingsService.allowedSupplierCodesForFloor(floor) + + private fun loadDoFloorSupplierLists(): Pair, List> = + doFloorSupplierSettingsService.loadDoFloorSupplierLists() + private fun preferredStoreFloorForSupplier( + supplierCode: String?, + suppliers2F: List, + suppliers4F: List, + ): 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 { 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 { 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 { 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, lanePredicate: (String?) -> Boolean, ): List { @@ -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( diff --git a/src/main/java/com/ffii/fpsms/modules/deliveryOrder/service/DoFloorSupplierSettingsService.kt b/src/main/java/com/ffii/fpsms/modules/deliveryOrder/service/DoFloorSupplierSettingsService.kt new file mode 100644 index 0000000..a6bc9f0 --- /dev/null +++ b/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): List { + 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> { + 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 { + 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, + suppliers4F: List, + ): 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, + suppliers4F: List, + ): 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 = + codes.joinToString(", ") { "'" + it.replace("'", "''") + "'" } +} diff --git a/src/main/java/com/ffii/fpsms/modules/deliveryOrder/service/DoReleaseCoordinatorService.kt b/src/main/java/com/ffii/fpsms/modules/deliveryOrder/service/DoReleaseCoordinatorService.kt index e31662a..7b3ca0a 100644 --- a/src/main/java/com/ffii/fpsms/modules/deliveryOrder/service/DoReleaseCoordinatorService.kt +++ b/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(",")}) diff --git a/src/main/java/com/ffii/fpsms/modules/deliveryOrder/service/DoWorkbenchDopoAssignmentService.kt b/src/main/java/com/ffii/fpsms/modules/deliveryOrder/service/DoWorkbenchDopoAssignmentService.kt index 77eec8e..25ef935 100644 --- a/src/main/java/com/ffii/fpsms/modules/deliveryOrder/service/DoWorkbenchDopoAssignmentService.kt +++ b/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() diff --git a/src/main/java/com/ffii/fpsms/modules/deliveryOrder/service/DoWorkbenchMainService.kt b/src/main/java/com/ffii/fpsms/modules/deliveryOrder/service/DoWorkbenchMainService.kt index e0da6d2..f938588 100644 --- a/src/main/java/com/ffii/fpsms/modules/deliveryOrder/service/DoWorkbenchMainService.kt +++ b/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 { + 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> = try { + jdbcDao.queryForList(sql, mapOf("requiredDate" to targetDate)) + } catch (e: Exception) { + println("❌ getWorkbenchEtraLaneSummary: ${e.message}") + emptyList() + } + + fun cellStr(row: Map, 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, 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, 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 { 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 { it.shopName ?: it.shopCode ?: "" } + .thenBy { it.shopCode ?: "" } + ) + } + open fun findWorkbenchReleasedDeliveryOrderPickOrdersForSelection( shopName: String?, storeId: String?, truck: String?, + releaseTypeFilter: String? = null, ): List = - 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 = - 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,8 +1523,9 @@ return MessageResponse( dop.deliveryNoteCode = CodeGenerator.generateNo(prefix = prefix, midfix = midfix, latestCode = latestCode) } deliveryOrderPickOrderRepository.save(dop) + } - + markDeliveryOrdersCompletedForDeliveryOrderPickOrder(deliveryOrderPickOrderId) return MessageResponse( id = dop.id, code = "SUCCESS", @@ -1468,6 +1630,7 @@ return MessageResponse( truck: String?, beforeToday: Boolean, equalsDeliveryDate: LocalDate? = null, + releaseTypeFilter: String? = null, ): List { val today = LocalDate.now() val params = mutableMapOf() @@ -1518,6 +1681,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> = try { jdbcDao.queryForList(sqlBuilder.toString(), params) @@ -1912,6 +2079,7 @@ return MessageResponse( tryCompleteDeliveryOrderPickOrderTicketCompleted(poId) } } + private fun registerAfterCommit(action: () -> Unit) { if (!TransactionSynchronizationManager.isSynchronizationActive()) { action() @@ -2047,6 +2215,7 @@ return MessageResponse( ) } } + private fun postWorkbenchPickSideEffects(savedStockOutLine: StockOutLine, deltaQty: BigDecimal, createLedger: Boolean = true) { if (deltaQty <= BigDecimal.ZERO) return val wall0 = System.nanoTime() @@ -2229,9 +2398,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( @@ -2276,8 +2446,36 @@ return MessageResponse( """.trimIndent(), mapOf("dopId" to dopId, "deliveryNoteCode" to newDeliveryNoteCode), ) + markDeliveryOrdersCompletedForDeliveryOrderPickOrder(dopId) + } + /** + * When a workbench ticket ([delivery_order_pick_order]) is completed, align linked [delivery_order] headers. + */ + private fun markDeliveryOrdersCompletedForDeliveryOrderPickOrder(dopId: Long) { + if (dopId <= 0L) return + val rows = try { + jdbcDao.queryForList( + """ + SELECT DISTINCT po.doId AS doId + FROM fpsmsdb.pick_order po + WHERE po.deliveryOrderPickOrderId = :dopId + AND po.deleted = 0 + AND po.doId IS NOT NULL + """.trimIndent(), + mapOf("dopId" to dopId), + ) + } catch (_: Exception) { + emptyList() + } + for (row in rows) { + val doId = (row["doId"] as? Number)?.toLong() ?: continue + val deliveryOrder = deliveryOrderRepository.findByIdAndDeletedIsFalse(doId) ?: continue + if (deliveryOrder.status == DeliveryOrderStatus.COMPLETED) continue + deliveryOrder.status = DeliveryOrderStatus.COMPLETED + deliveryOrder.completeDate = LocalDateTime.now() + deliveryOrderRepository.save(deliveryOrder) + } } - private fun checkWorkbenchPickOrderLineCompleted(pickOrderLineId: Long, allStockOutLines: List) { val pol = pickOrderLineRepository.findById(pickOrderLineId).orElse(null) ?: return if (pol.status == PickOrderLineStatus.COMPLETED) return @@ -2715,11 +2913,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 { validateWorkbenchCartonReprintRange( fromCarton = request.fromCarton, diff --git a/src/main/java/com/ffii/fpsms/modules/deliveryOrder/service/DoWorkbenchReleaseService.kt b/src/main/java/com/ffii/fpsms/modules/deliveryOrder/service/DoWorkbenchReleaseService.kt index c138a85..5307654 100644 --- a/src/main/java/com/ffii/fpsms/modules/deliveryOrder/service/DoWorkbenchReleaseService.kt +++ b/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) diff --git a/src/main/java/com/ffii/fpsms/modules/deliveryOrder/web/DeliveryOrderController.kt b/src/main/java/com/ffii/fpsms/modules/deliveryOrder/web/DeliveryOrderController.kt index 8836198..f803111 100644 --- a/src/main/java/com/ffii/fpsms/modules/deliveryOrder/web/DeliveryOrderController.kt +++ b/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, ) } diff --git a/src/main/java/com/ffii/fpsms/modules/deliveryOrder/web/DoWorkbenchController.kt b/src/main/java/com/ffii/fpsms/modules/deliveryOrder/web/DoWorkbenchController.kt index ebd6dab..ffcdcae 100644 --- a/src/main/java/com/ffii/fpsms/modules/deliveryOrder/web/DoWorkbenchController.kt +++ b/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 = + 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 { - 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 { return doWorkbenchMainService.findWorkbenchReleasedDeliveryOrderPickOrdersForSelectionToday( shopName, storeId, truck, requiredDeliveryDate = requiredDate, + releaseTypeFilter = releaseType, ) } diff --git a/src/main/java/com/ffii/fpsms/modules/deliveryOrder/web/models/DoDetailResponse.kt b/src/main/java/com/ffii/fpsms/modules/deliveryOrder/web/models/DoDetailResponse.kt index 4643119..141665d 100644 --- a/src/main/java/com/ffii/fpsms/modules/deliveryOrder/web/models/DoDetailResponse.kt +++ b/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 ) @@ -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, ) 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?, diff --git a/src/main/java/com/ffii/fpsms/modules/deliveryOrder/web/models/ReleaseDoRequest.kt b/src/main/java/com/ffii/fpsms/modules/deliveryOrder/web/models/ReleaseDoRequest.kt index 8ecd928..5c827a8 100644 --- a/src/main/java/com/ffii/fpsms/modules/deliveryOrder/web/models/ReleaseDoRequest.kt +++ b/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, ) diff --git a/src/main/java/com/ffii/fpsms/modules/deliveryOrder/web/models/SaveDeliveryOrderRequest.kt b/src/main/java/com/ffii/fpsms/modules/deliveryOrder/web/models/SaveDeliveryOrderRequest.kt index bc89a77..6a1bcda 100644 --- a/src/main/java/com/ffii/fpsms/modules/deliveryOrder/web/models/SaveDeliveryOrderRequest.kt +++ b/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( diff --git a/src/main/java/com/ffii/fpsms/modules/pickOrder/service/HierarchicalFgPayloadAssembler.kt b/src/main/java/com/ffii/fpsms/modules/pickOrder/service/HierarchicalFgPayloadAssembler.kt index 5eb8f01..170fff1 100644 --- a/src/main/java/com/ffii/fpsms/modules/pickOrder/service/HierarchicalFgPayloadAssembler.kt +++ b/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"] ?: ""), diff --git a/src/main/java/com/ffii/fpsms/modules/report/service/ReportService.kt b/src/main/java/com/ffii/fpsms/modules/report/service/ReportService.kt index 67fec79..f6c5bf6 100644 --- a/src/main/java/com/ffii/fpsms/modules/report/service/ReportService.kt +++ b/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. @@ -118,6 +120,8 @@ open class ReportService( "AND DATE(IFNULL(dopo.requiredDeliveryDate, do.estimatedArrivalDate)) <= DATE(:lastOutDateEnd)" } else "" + val supplierFloorSqlCases = doFloorSupplierSettingsService.sqlPreferredFloorCases("supplier.code") + val sql = """ SELECT IFNULL(DATE_FORMAT( @@ -143,17 +147,9 @@ FORMAT(ROUND(IFNULL(IFNULL(sol.qty, dol.qty), 0), 0), 0) AS qty, 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 @@ -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), diff --git a/src/main/java/com/ffii/fpsms/modules/settings/web/SettingsController.java b/src/main/java/com/ffii/fpsms/modules/settings/web/SettingsController.java index 046037f..acf0d79 100644 --- a/src/main/java/com/ffii/fpsms/modules/settings/web/SettingsController.java +++ b/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); } diff --git a/src/main/java/com/ffii/fpsms/modules/stock/service/SuggestedPickLotService.kt b/src/main/java/com/ffii/fpsms/modules/stock/service/SuggestedPickLotService.kt index 6060b86..48692d9 100644 --- a/src/main/java/com/ffii/fpsms/modules/stock/service/SuggestedPickLotService.kt +++ b/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 } diff --git a/src/main/java/com/ffii/fpsms/modules/stock/web/model/CreateStockTakeForSectionsRequest.kt b/src/main/java/com/ffii/fpsms/modules/stock/web/model/CreateStockTakeForSectionsRequest.kt new file mode 100644 index 0000000..3ba0c02 --- /dev/null +++ b/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? = null, +) diff --git a/src/main/resources/db/changelog/changes/20260514_Enson/01_setting.sql b/src/main/resources/db/changelog/changes/20260514_Enson/01_setting.sql new file mode 100644 index 0000000..465fea8 --- /dev/null +++ b/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' +); diff --git a/src/main/resources/db/changelog/changes/20260514_Enson/02_setting.sql b/src/main/resources/db/changelog/changes/20260514_Enson/02_setting.sql new file mode 100644 index 0000000..e39fece --- /dev/null +++ b/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; + From 1d971256c48b876e27d3b381749fcc6564fd5bb2 Mon Sep 17 00:00:00 2001 From: "CANCERYS\\kw093" Date: Thu, 14 May 2026 21:31:42 +0800 Subject: [PATCH 06/11] fix bag lot line function slow query --- src/main/java/com/ffii/fpsms/modules/bag/service/bagService.kt | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/main/java/com/ffii/fpsms/modules/bag/service/bagService.kt b/src/main/java/com/ffii/fpsms/modules/bag/service/bagService.kt index af4b56d..dbe49c7 100644 --- a/src/main/java/com/ffii/fpsms/modules/bag/service/bagService.kt +++ b/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") From 7141c0f6b4ab32246840d5038f4ae984acd81ba3 Mon Sep 17 00:00:00 2001 From: "CANCERYS\\kw093" Date: Thu, 14 May 2026 22:46:44 +0800 Subject: [PATCH 07/11] chart sql improt --- .../modules/chart/service/ChartService.kt | 84 ++++++++++++------- .../modules/chart/web/ChartController.kt | 6 +- 2 files changed, 56 insertions(+), 34 deletions(-) diff --git a/src/main/java/com/ffii/fpsms/modules/chart/service/ChartService.kt b/src/main/java/com/ffii/fpsms/modules/chart/service/ChartService.kt index 90b6f5d..e46c5a4 100644 --- a/src/main/java/com/ffii/fpsms/modules/chart/service/ChartService.kt +++ b/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> { val args = mutableMapOf() 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> { val args = mutableMapOf() - 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> { val args = mutableMapOf() 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("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 diff --git a/src/main/java/com/ffii/fpsms/modules/chart/web/ChartController.kt b/src/main/java/com/ffii/fpsms/modules/chart/web/ChartController.kt index 3de7d68..7d568ec 100644 --- a/src/main/java/com/ffii/fpsms/modules/chart/web/ChartController.kt +++ b/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( From 57ab57dd65ea453dad31100248628cdc4c5ccec4 Mon Sep 17 00:00:00 2001 From: tommy Date: Mon, 18 May 2026 14:14:13 +0800 Subject: [PATCH 08/11] routeboard --- .../fpsms/modules/logistic/entity/Logistic.kt | 33 + .../logistic/entity/LogisticRepository.kt | 12 + .../logistic/service/LogisticService.kt | 82 + .../logistic/web/LogisticController.kt | 65 + .../web/models/DeleteLogisticRequest.kt | 9 + .../logistic/web/models/LogisticResponse.kt | 10 + .../web/models/SaveLogisticRequest.kt | 21 + .../web/models/SaveLogisticsBatchRequest.kt | 12 + .../modules/master/entity/ShopRepository.kt | 10 + .../fpsms/modules/pickOrder/entity/Truck.kt | 7 +- .../pickOrder/entity/TruckLaneVersion.kt | 19 + .../pickOrder/entity/TruckLaneVersionLine.kt | 55 + .../entity/TruckLaneVersionLineRepository.kt | 10 + .../entity/TruckLaneVersionRepository.kt | 12 + .../pickOrder/entity/TruckRepository.kt | 85 +- .../service/RouteLaneExcelSupport.kt | 202 +++ .../service/RouteReportExcelSupport.kt | 359 +++++ .../TruckLaneVersionReportExcelSupport.kt | 194 +++ ...TruckLaneVersionRouteReportExcelSupport.kt | 300 ++++ .../service/TruckLaneVersionService.kt | 306 ++++ .../modules/pickOrder/service/TruckService.kt | 1340 ++++++++++++++++- .../modules/pickOrder/web/TruckController.kt | 233 ++- .../web/TruckLaneVersionController.kt | 73 + .../web/models/ExportRouteLanesRequest.kt | 5 + .../web/models/ExportRouteReportRequest.kt | 11 + ...xportTruckLaneVersionReportExcelRequest.kt | 7 + .../web/models/ParseRouteLanesExcelModels.kt | 22 + .../pickOrder/web/models/SaveTruckRequest.kt | 16 +- .../models/TruckLaneCombinationResponse.kt | 34 + .../web/models/TruckLaneVersionModels.kt | 65 + .../UpdateTruckLaneVersionNoteRequest.kt | 8 + .../01_truck_lane_version_snapshot.sql | 130 ++ .../02_truck_lane_version_snapshot_patch.sql | 46 + .../01_truck_add_logistic_id.sql | 10 + .../01_truck_lane_version_drop_store_id.sql | 52 + ...ruck_lane_version_line_add_logistic_id.sql | 37 + 36 files changed, 3840 insertions(+), 52 deletions(-) create mode 100644 src/main/java/com/ffii/fpsms/modules/logistic/entity/Logistic.kt create mode 100644 src/main/java/com/ffii/fpsms/modules/logistic/entity/LogisticRepository.kt create mode 100644 src/main/java/com/ffii/fpsms/modules/logistic/service/LogisticService.kt create mode 100644 src/main/java/com/ffii/fpsms/modules/logistic/web/LogisticController.kt create mode 100644 src/main/java/com/ffii/fpsms/modules/logistic/web/models/DeleteLogisticRequest.kt create mode 100644 src/main/java/com/ffii/fpsms/modules/logistic/web/models/LogisticResponse.kt create mode 100644 src/main/java/com/ffii/fpsms/modules/logistic/web/models/SaveLogisticRequest.kt create mode 100644 src/main/java/com/ffii/fpsms/modules/logistic/web/models/SaveLogisticsBatchRequest.kt create mode 100644 src/main/java/com/ffii/fpsms/modules/pickOrder/entity/TruckLaneVersion.kt create mode 100644 src/main/java/com/ffii/fpsms/modules/pickOrder/entity/TruckLaneVersionLine.kt create mode 100644 src/main/java/com/ffii/fpsms/modules/pickOrder/entity/TruckLaneVersionLineRepository.kt create mode 100644 src/main/java/com/ffii/fpsms/modules/pickOrder/entity/TruckLaneVersionRepository.kt create mode 100644 src/main/java/com/ffii/fpsms/modules/pickOrder/service/RouteLaneExcelSupport.kt create mode 100644 src/main/java/com/ffii/fpsms/modules/pickOrder/service/RouteReportExcelSupport.kt create mode 100644 src/main/java/com/ffii/fpsms/modules/pickOrder/service/TruckLaneVersionReportExcelSupport.kt create mode 100644 src/main/java/com/ffii/fpsms/modules/pickOrder/service/TruckLaneVersionRouteReportExcelSupport.kt create mode 100644 src/main/java/com/ffii/fpsms/modules/pickOrder/service/TruckLaneVersionService.kt create mode 100644 src/main/java/com/ffii/fpsms/modules/pickOrder/web/TruckLaneVersionController.kt create mode 100644 src/main/java/com/ffii/fpsms/modules/pickOrder/web/models/ExportRouteLanesRequest.kt create mode 100644 src/main/java/com/ffii/fpsms/modules/pickOrder/web/models/ExportRouteReportRequest.kt create mode 100644 src/main/java/com/ffii/fpsms/modules/pickOrder/web/models/ExportTruckLaneVersionReportExcelRequest.kt create mode 100644 src/main/java/com/ffii/fpsms/modules/pickOrder/web/models/ParseRouteLanesExcelModels.kt create mode 100644 src/main/java/com/ffii/fpsms/modules/pickOrder/web/models/TruckLaneCombinationResponse.kt create mode 100644 src/main/java/com/ffii/fpsms/modules/pickOrder/web/models/TruckLaneVersionModels.kt create mode 100644 src/main/java/com/ffii/fpsms/modules/pickOrder/web/models/UpdateTruckLaneVersionNoteRequest.kt create mode 100644 src/main/resources/db/changelog/changes/20260430_02_2fi/01_truck_lane_version_snapshot.sql create mode 100644 src/main/resources/db/changelog/changes/20260430_02_2fi/02_truck_lane_version_snapshot_patch.sql create mode 100644 src/main/resources/db/changelog/changes/20260504_01_2fi/01_truck_add_logistic_id.sql create mode 100644 src/main/resources/db/changelog/changes/20260505_01_2fi/01_truck_lane_version_drop_store_id.sql create mode 100644 src/main/resources/db/changelog/changes/20260507_01_2fi/01_truck_lane_version_line_add_logistic_id.sql diff --git a/src/main/java/com/ffii/fpsms/modules/logistic/entity/Logistic.kt b/src/main/java/com/ffii/fpsms/modules/logistic/entity/Logistic.kt new file mode 100644 index 0000000..f96f0fc --- /dev/null +++ b/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() { + + @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 +} + diff --git a/src/main/java/com/ffii/fpsms/modules/logistic/entity/LogisticRepository.kt b/src/main/java/com/ffii/fpsms/modules/logistic/entity/LogisticRepository.kt new file mode 100644 index 0000000..4304fbb --- /dev/null +++ b/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 { + fun findAllByDeletedFalseOrderByIdAsc(): List + fun findByIdAndDeletedFalse(id: Long): Logistic? + fun findByCarPlateAndDeletedFalse(carPlate: String): Logistic? +} + diff --git a/src/main/java/com/ffii/fpsms/modules/logistic/service/LogisticService.kt b/src/main/java/com/ffii/fpsms/modules/logistic/service/LogisticService.kt new file mode 100644 index 0000000..807d4c5 --- /dev/null +++ b/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 { + 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): List { + 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" + } +} + diff --git a/src/main/java/com/ffii/fpsms/modules/logistic/web/LogisticController.kt b/src/main/java/com/ffii/fpsms/modules/logistic/web/LogisticController.kt new file mode 100644 index 0000000..2e65e06 --- /dev/null +++ b/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 { + 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 { + return logisticService.saveBatchCreate(body.items).map { it.toResponse() } + } + + @PostMapping("/delete") + fun delete(@Valid @RequestBody request: DeleteLogisticRequest): ResponseEntity { + 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, + ) + } +} + diff --git a/src/main/java/com/ffii/fpsms/modules/logistic/web/models/DeleteLogisticRequest.kt b/src/main/java/com/ffii/fpsms/modules/logistic/web/models/DeleteLogisticRequest.kt new file mode 100644 index 0000000..3b0eb42 --- /dev/null +++ b/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, +) + diff --git a/src/main/java/com/ffii/fpsms/modules/logistic/web/models/LogisticResponse.kt b/src/main/java/com/ffii/fpsms/modules/logistic/web/models/LogisticResponse.kt new file mode 100644 index 0000000..c991245 --- /dev/null +++ b/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, +) + diff --git a/src/main/java/com/ffii/fpsms/modules/logistic/web/models/SaveLogisticRequest.kt b/src/main/java/com/ffii/fpsms/modules/logistic/web/models/SaveLogisticRequest.kt new file mode 100644 index 0000000..e82763b --- /dev/null +++ b/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, +) + diff --git a/src/main/java/com/ffii/fpsms/modules/logistic/web/models/SaveLogisticsBatchRequest.kt b/src/main/java/com/ffii/fpsms/modules/logistic/web/models/SaveLogisticsBatchRequest.kt new file mode 100644 index 0000000..57445dd --- /dev/null +++ b/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, +) diff --git a/src/main/java/com/ffii/fpsms/modules/master/entity/ShopRepository.kt b/src/main/java/com/ffii/fpsms/modules/master/entity/ShopRepository.kt index 6986125..5fae1eb 100644 --- a/src/main/java/com/ffii/fpsms/modules/master/entity/ShopRepository.kt +++ b/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 { 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): List + @Query( nativeQuery = true, value = """ diff --git a/src/main/java/com/ffii/fpsms/modules/pickOrder/entity/Truck.kt b/src/main/java/com/ffii/fpsms/modules/pickOrder/entity/Truck.kt index 976ac97..14a5417 100644 --- a/src/main/java/com/ffii/fpsms/modules/pickOrder/entity/Truck.kt +++ b/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() { @Column(name = "remark") open var remark: String? = null -} \ No newline at end of file + @ManyToOne(fetch = FetchType.LAZY) + @JoinColumn(name = "logisticId") + open var logistic: Logistic? = null + +} diff --git a/src/main/java/com/ffii/fpsms/modules/pickOrder/entity/TruckLaneVersion.kt b/src/main/java/com/ffii/fpsms/modules/pickOrder/entity/TruckLaneVersion.kt new file mode 100644 index 0000000..bf3e5f6 --- /dev/null +++ b/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() { + + @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 +} + diff --git a/src/main/java/com/ffii/fpsms/modules/pickOrder/entity/TruckLaneVersionLine.kt b/src/main/java/com/ffii/fpsms/modules/pickOrder/entity/TruckLaneVersionLine.kt new file mode 100644 index 0000000..bf83c01 --- /dev/null +++ b/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() { + + @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 +} + diff --git a/src/main/java/com/ffii/fpsms/modules/pickOrder/entity/TruckLaneVersionLineRepository.kt b/src/main/java/com/ffii/fpsms/modules/pickOrder/entity/TruckLaneVersionLineRepository.kt new file mode 100644 index 0000000..7963ae8 --- /dev/null +++ b/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 { + fun findAllByTruckLaneVersion_IdAndDeletedFalseOrderByIdAsc(truckLaneVersionId: Long): List +} + diff --git a/src/main/java/com/ffii/fpsms/modules/pickOrder/entity/TruckLaneVersionRepository.kt b/src/main/java/com/ffii/fpsms/modules/pickOrder/entity/TruckLaneVersionRepository.kt new file mode 100644 index 0000000..f69ecc6 --- /dev/null +++ b/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 { + fun findAllByTruckLanceCodeAndDeletedFalseOrderByCreatedDesc(truckLanceCode: String): List + fun findAllByDeletedFalseOrderByCreatedDesc(): List + fun findByIdAndDeletedFalse(id: Long): TruckLaneVersion? +} + diff --git a/src/main/java/com/ffii/fpsms/modules/pickOrder/entity/TruckRepository.kt b/src/main/java/com/ffii/fpsms/modules/pickOrder/entity/TruckRepository.kt index c6d4277..116724e 100644 --- a/src/main/java/com/ffii/fpsms/modules/pickOrder/entity/TruckRepository.kt +++ b/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 { 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 + + /** + * 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 + + /** + * 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 + + /** + * 單一 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 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 diff --git a/src/main/java/com/ffii/fpsms/modules/pickOrder/service/RouteLaneExcelSupport.kt b/src/main/java/com/ffii/fpsms/modules/pickOrder/service/RouteLaneExcelSupport.kt new file mode 100644 index 0000000..2537a54 --- /dev/null +++ b/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? { + 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), + ) + } +} diff --git a/src/main/java/com/ffii/fpsms/modules/pickOrder/service/RouteReportExcelSupport.kt b/src/main/java/com/ffii/fpsms/modules/pickOrder/service/RouteReportExcelSupport.kt new file mode 100644 index 0000000..f22a40a --- /dev/null +++ b/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, + 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, + ) + + data class LaneGroup( + val laneCode: String, + val districts: List, + ) + + data class DistrictGroup( + val districtLabel: String, + val shops: List, + ) +} + diff --git a/src/main/java/com/ffii/fpsms/modules/pickOrder/service/TruckLaneVersionReportExcelSupport.kt b/src/main/java/com/ffii/fpsms/modules/pickOrder/service/TruckLaneVersionReportExcelSupport.kt new file mode 100644 index 0000000..6122846 --- /dev/null +++ b/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 = emptySet(), + ) + + fun writeSummarySheet(wb: XSSFWorkbook, meta: SummaryMeta, rows: List) { + 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) + } +} + diff --git a/src/main/java/com/ffii/fpsms/modules/pickOrder/service/TruckLaneVersionRouteReportExcelSupport.kt b/src/main/java/com/ffii/fpsms/modules/pickOrder/service/TruckLaneVersionRouteReportExcelSupport.kt new file mode 100644 index 0000000..ebd98b7 --- /dev/null +++ b/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, + ) + + data class LaneGroup( + val laneLabel: String, + val districts: List, + ) + + data class TimeGroup( + val timeLabel: String, + val lanes: List, + ) + + 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, + 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 + } +} + diff --git a/src/main/java/com/ffii/fpsms/modules/pickOrder/service/TruckLaneVersionService.kt b/src/main/java/com/ffii/fpsms/modules/pickOrder/service/TruckLaneVersionService.kt new file mode 100644 index 0000000..bd50d7f --- /dev/null +++ b/src/main/java/com/ffii/fpsms/modules/pickOrder/service/TruckLaneVersionService.kt @@ -0,0 +1,306 @@ +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, + ): 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 { + val lane = truckLanceCode.trim() + return truckLaneVersionRepository + .findAllByTruckLanceCodeAndDeletedFalseOrderByCreatedDesc(lane) + .map(::toResponse) + } + + open fun listAllVersions(): List { + return truckLaneVersionRepository + .findAllByDeletedFalseOrderByCreatedDesc() + .map(::toResponse) + } + + open fun getVersionLines(versionId: Long): List { + 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() + + fun s(v: Any?): String? = v?.toString() + + allKeys.forEach { key -> + val a = fromByRow[key] + val b = toByRow[key] + val changes = mutableListOf() + + 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, + ) + ) + } + } + + 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 { + 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() + 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" + } +} diff --git a/src/main/java/com/ffii/fpsms/modules/pickOrder/service/TruckService.kt b/src/main/java/com/ffii/fpsms/modules/pickOrder/service/TruckService.kt index a473ae3..0175ef4 100644 --- a/src/main/java/com/ffii/fpsms/modules/pickOrder/service/TruckService.kt +++ b/src/main/java/com/ffii/fpsms/modules/pickOrder/service/TruckService.kt @@ -5,11 +5,17 @@ import com.ffii.core.support.JdbcDao import com.ffii.core.utils.ExcelUtils import org.apache.poi.ss.usermodel.Sheet import org.apache.poi.ss.usermodel.Workbook +import org.apache.poi.xssf.usermodel.XSSFWorkbook import org.springframework.stereotype.Service +import com.ffii.fpsms.modules.logistic.entity.Logistic +import com.ffii.fpsms.modules.logistic.entity.LogisticRepository import com.ffii.fpsms.modules.pickOrder.entity.Truck +import com.ffii.fpsms.modules.pickOrder.entity.TruckLaneVersionLineRepository +import com.ffii.fpsms.modules.pickOrder.entity.TruckLaneVersionRepository import com.ffii.fpsms.modules.pickOrder.entity.TruckRepository import com.ffii.fpsms.modules.pickOrder.web.models.SaveTruckRequest import com.ffii.fpsms.modules.pickOrder.web.models.CreateTruckWithoutShopRequest +import com.ffii.fpsms.modules.pickOrder.web.models.UpdateLaneLogisticRequest import com.ffii.fpsms.modules.pickOrder.web.models.UpdateTruckShopDetailsRequest import java.time.LocalTime import java.time.format.DateTimeFormatter @@ -17,6 +23,14 @@ import com.ffii.fpsms.modules.master.entity.ShopRepository import com.ffii.fpsms.modules.master.entity.projections.ShopAndTruck import com.ffii.fpsms.modules.pickOrder.web.models.SaveTruckLane import jakarta.transaction.Transactional +import java.io.ByteArrayOutputStream +import java.text.Collator +import java.time.LocalDate +import java.util.Locale +import com.ffii.fpsms.modules.pickOrder.web.models.DiffFieldChange +import com.ffii.fpsms.modules.pickOrder.web.models.ParseRouteLanesExcelResponse +import com.ffii.fpsms.modules.pickOrder.web.models.RouteLaneImportPreviewRow +import com.ffii.fpsms.modules.pickOrder.web.models.TruckLaneVersionDiffLine @Service @@ -24,7 +38,17 @@ open class TruckService( private val jdbcDao: JdbcDao, private val truckRepository: TruckRepository, private val shopRepository: ShopRepository, + private val logisticRepository: LogisticRepository, + private val truckLaneVersionRepository: TruckLaneVersionRepository, + private val truckLaneVersionLineRepository: TruckLaneVersionLineRepository, ) : AbstractBaseEntityService(jdbcDao, truckRepository) { + + private fun logisticRefOrNull(id: Long?): Logistic? { + if (id == null) return null + return logisticRepository.findById(id).orElseThrow { + IllegalArgumentException("Logistic not found with id: $id") + } + } open fun saveTruck(request: SaveTruckRequest): Truck { val truck = request.id?.let { truckRepository.findById(it).orElse(null) @@ -39,41 +63,98 @@ open class TruckService( this.truckLanceCode = request.truckLanceCode this.departureTime = request.departureTime this.shop = shop - this.shopName = request.shopName + this.shopName = normalizeTruckShopDisplayName(request.shopName) this.shopCode = request.shopCode this.loadingSequence = request.loadingSequence this.remark = request.remark + this.districtReference = request.districtReference + this.logistic = logisticRefOrNull(request.logisticId) } return truckRepository.save(truck); } + /** + * 同 (truckLanceCode, remark) 桶內僅用來佔位的列:`shop` 為 null 且店名/代碼為空或舊版 Unassign。 + * 新增店鋪時應 **先 UPDATE 此列**,避免再 INSERT 一筆造成多條「空列」。 + */ + private fun isLanePlaceholderTruck(truck: Truck): Boolean { + if (truck.shop != null) return false + val nm = truck.shopName?.trim().orEmpty() + val cd = truck.shopCode?.trim().orEmpty() + if (nm.isEmpty() && cd.isEmpty()) return true + if (nm.equals("unassign", ignoreCase = true) || cd.equals("unassign", ignoreCase = true)) return true + if (nm.equals("unassigned", ignoreCase = true) || cd.equals("unassigned", ignoreCase = true)) return true + return false + } + + /** 與 [findAllByTruckLanceCodeAndRemarkAndDeletedFalse] 相同的 remark 桶規則 */ + private fun trucksInSameLaneBucket( + truckLanceCode: String, + storeId: String, + remark: String?, + ): List { + val bucketRemark = if (storeId == "4F") remark?.trim()?.takeIf { it.isNotEmpty() } else null + val trimmed = bucketRemark?.trim().orEmpty() + val blankRemark = trimmed.isEmpty() + return truckRepository.findAllByTruckLanceCodeAndRemarkAndDeletedFalse( + truckLanceCode.trim(), + blankRemark, + if (blankRemark) null else trimmed, + ) + } + + private fun softDeleteTruckRow(t: Truck) { + t.deleted = true + truckRepository.save(t) + } + private fun parseDepartureTime(timeStr: String?): LocalTime? { if (timeStr.isNullOrBlank()) return null return try { val cleaned = timeStr.trim().uppercase().replace(" ", "") - // 处理 3:00AM / 5:30PM 这类 12 小时制 + // 12 小時制:3:00AM、5:30:15PM(含秒) if (cleaned.contains("AM") || cleaned.contains("PM")) { val isPM = cleaned.contains("PM") val timePart = cleaned.replace("AM", "").replace("PM", "") val parts = timePart.split(":") - if (parts.size == 2) { - var hour = parts[0].toInt() - val minute = parts[1].toIntOrNull() ?: 0 - - if (isPM && hour != 12) hour += 12 - if (!isPM && hour == 12) hour = 0 - - LocalTime.of(hour, minute) - } else null - } else { - // 处理 17:30 / 3:00 这类 24 小时制 + when (parts.size) { + 2 -> { + var hour = parts[0].toInt() + val minute = parts[1].toIntOrNull() ?: 0 + if (isPM && hour != 12) hour += 12 + if (!isPM && hour == 12) hour = 0 + return LocalTime.of(hour, minute) + } + 3 -> { + var hour = parts[0].toInt() + val minute = parts[1].toIntOrNull() ?: 0 + val second = parts[2].toIntOrNull() ?: 0 + if (isPM && hour != 12) hour += 12 + if (!isPM && hour == 12) hour = 0 + return LocalTime.of(hour, minute, second) + } + } + } + // 24 小時制:須接受匯出欄位 formatDepartureForExcel 的 HH:mm:ss(如 17:30:00) + val t = timeStr.trim() + try { + LocalTime.parse(t) + } catch (_: Exception) { try { - LocalTime.parse(timeStr.trim(), DateTimeFormatter.ofPattern("H:mm")) + LocalTime.parse(t, DateTimeFormatter.ofPattern("H:mm:ss")) } catch (_: Exception) { - LocalTime.parse(timeStr.trim(), DateTimeFormatter.ofPattern("HH:mm")) + try { + LocalTime.parse(t, DateTimeFormatter.ofPattern("HH:mm:ss")) + } catch (_: Exception) { + try { + LocalTime.parse(t, DateTimeFormatter.ofPattern("H:mm")) + } catch (_: Exception) { + LocalTime.parse(t, DateTimeFormatter.ofPattern("HH:mm")) + } + } } } } catch (e: Exception) { @@ -99,6 +180,118 @@ open class TruckService( return letterPart + normalizedNumber } + /** MTMS 車線匯入:判斷 truck 列是否與 Excel 指向同一 shop(shopId 或 shopCode 多寫法)。 */ + private fun truckRowMatchesImportShop( + truck: Truck, + shopId: Long, + shopCodeRaw: String, + normalizedShopCode: String, + ): Boolean { + if (truck.shop?.id != null && truck.shop?.id == shopId) return true + val tc = truck.shopCode?.trim().orEmpty() + if (tc.isEmpty()) return false + val rawTrim = shopCodeRaw.trim() + return tc == rawTrim || + tc == normalizedShopCode || + normalizeShopCode(tc) == normalizedShopCode + } + + /** + * 同 (truckLanceCode, remark) 桶內同店至多一筆:保留 id 最小,其餘 soft delete。 + * 桶內無則 fallback 全域 findFirst(搬移他線列進當前桶)。 + */ + private fun resolveExistingTruckForRouteLaneImport( + truckLanceCode: String, + storeId: String, + laneRemark: String?, + shopId: Long, + shopCodeRaw: String, + normalizedShopCode: String, + ): Truck? { + val bucket = trucksInSameLaneBucket(truckLanceCode, storeId, laneRemark) + val matched = + bucket + .filter { truckRowMatchesImportShop(it, shopId, shopCodeRaw, normalizedShopCode) } + .sortedBy { it.id ?: Long.MAX_VALUE } + if (matched.isEmpty()) { + return truckRepository.findFirstByShopCodeAndStoreIdAndDeletedFalseOrderByIdAsc( + shopCodeRaw, + storeId, + ) + ?: truckRepository.findFirstByShopCodeAndStoreIdAndDeletedFalseOrderByIdAsc( + normalizedShopCode, + storeId, + ) + } + val primary = matched.first() + for (dup in matched.drop(1)) { + logger.warn( + "Route lane import: soft-delete duplicate truck id=${dup.id} shopCode=${dup.shopCode} " + + "storeId=$storeId lane=$truckLanceCode; keep id=${primary.id}", + ) + softDeleteTruckRow(dup) + } + val pid = primary.id ?: return primary + return truckRepository.findById(pid).orElse(primary) + } + + /** + * MTMS 車線匯入:Excel「板」欄 → `districtReference`。 + * 空白、`未分類`、舊版合成「板一…」皆視同未分類(存 null);其餘 trim 後原樣寫入。 + */ + private fun normalizeDistrictReferenceForRouteLaneImport(plateColumn: String): String? { + val t = plateColumn.trim() + if (t.isEmpty() || t == "未分類") return null + for (i in 0 until 64) { + if (t == RouteLaneExcelSupport.plateLabel(i)) return null + } + return t + } + + /** + * M18 / combo 店名可能是 `CF001 - 雞檔-健威坊店`;`truck.ShopName` 應存分店短名(如 `健威`)。 + * 規則:若為 SKU 前綴開頭或分段數≥3,取最後一段並去掉尾綴「坊店」。 + */ + private fun normalizeTruckShopDisplayName(raw: String?): String { + if (raw.isNullOrBlank()) return "" + val s = raw.trim() + val parts = s.split(Regex("\\s*-\\s*")).map { it.trim() }.filter { it.isNotEmpty() } + if (parts.size < 2) return s + val first = parts[0] + val codeSkuLike = first.matches(Regex("^[A-Za-z]{1,6}\\d+$")) + val takeLast = parts.size >= 3 || codeSkuLike + if (!takeLast) return s + var last = parts.last() + if (last.endsWith("坊店")) { + last = last.removeSuffix("坊店").trim() + } + return last.ifEmpty { s } + } + + /** + * MTMS「品牌」欄:M18 全名多為 `店鋪編 - 品牌 - 分店短名/…` 或 `編號 - 品牌-分店名`; + * 與 [normalizeTruckShopDisplayName] 用同一套 ` - ` 分段,取品牌段(非首段之 SKU 前綴、非最末段之顯示名)。 + */ + private fun deriveBrandFromShopFullName(raw: String?): String { + if (raw.isNullOrBlank()) return "" + val s = raw.trim() + val parts = s.split(Regex("\\s*-\\s*")).map { it.trim() }.filter { it.isNotEmpty() } + if (parts.size >= 3) { + return parts[1] + } + if (parts.size == 2) { + val first = parts[0] + val codeSkuLike = first.matches(Regex("^[A-Za-z]{1,6}\\d+$")) + if (codeSkuLike) { + val sub = parts[1].split('-').map { it.trim() }.filter { it.isNotEmpty() } + if (sub.size >= 2) { + return sub[0] + } + } + } + return "" + } + open fun importExcel(workbook: Workbook?): String { logger.info("--------- Start - Import Warehouse Excel -------"); @@ -121,7 +314,7 @@ open class TruckService( val START_ROW_INDEX = 3; logger.info("Total rows in sheet: ${sheet.lastRowNum + 1}, Processing from row ${START_ROW_INDEX + 1} to ${sheet.lastRowNum + 1}"); // Start Import - for (i in START_ROW_INDEX.. { @@ -269,6 +489,20 @@ open class TruckService( return truckRepository.findAllByTruckLanceCodeAndDeletedFalse(truckLanceCode) } + open fun findAllByTruckLanceCodeAndRemarkAndDeletedFalse(truckLanceCode: String, remark: String?): List { + val trimmed = remark?.trim() ?: "" + val blankRemark = trimmed.isEmpty() + return truckRepository.findAllByTruckLanceCodeAndRemarkAndDeletedFalse( + truckLanceCode, + blankRemark, + if (blankRemark) null else trimmed, + ) + } + + open fun findAllForRouteBoard(): List { + return truckRepository.findAllForRouteBoard() + } + open fun findAllUniqueShopNamesAndCodesFromTrucks(): List> { return truckRepository.findAllUniqueShopNamesAndCodesFromTrucks() } @@ -304,7 +538,7 @@ open class TruckService( // Always use shopName and shopCode from request (from truck table), not from shop entity // This allows truck table to have different shop names/codes than shop table if (request.shopName != null) { - truck.shopName = request.shopName + truck.shopName = normalizeTruckShopDisplayName(request.shopName) } if (request.shopCode != null) { @@ -325,25 +559,1069 @@ open class TruckService( return truckRepository.save(truck) } + @Transactional + open fun updateLogisticForEntireLane(request: UpdateLaneLogisticRequest): Int { + val trimmedCode = request.truckLanceCode.trim() + val trimmedRemark = request.remark?.trim() ?: "" + val blankRemark = trimmedRemark.isEmpty() + val logistic = logisticRefOrNull(request.logisticId) + val updated = truckRepository.bulkUpdateLogisticForLaneGroup( + logistic, + trimmedCode, + blankRemark, + if (blankRemark) null else trimmedRemark, + ) + if (updated == 0) { + throw IllegalArgumentException("No truck rows for lane: $trimmedCode") + } + return updated + } + @Transactional open fun createTruckWithoutShop(request: CreateTruckWithoutShopRequest): Truck { - // Create a new truck without a shop - val truck = Truck() - + val laneRows = trucksInSameLaneBucket( + request.truckLanceCode, + request.store_id, + request.remark, + ) + val placeholders = laneRows.filter { isLanePlaceholderTruck(it) } + .sortedBy { it.id ?: Long.MAX_VALUE } + val primary = placeholders.firstOrNull() + val truck = primary ?: Truck() + truck.apply { this.storeId = request.store_id this.truckLanceCode = request.truckLanceCode this.departureTime = request.departureTime this.shop = null - this.shopName = "Unassign" - this.shopCode = "Unassign" + /** 僅佔位列:不寫假店名(避免 DB / 報表出現 Unassign) */ + this.shopName = null + this.shopCode = null this.loadingSequence = request.loadingSequence this.districtReference = request.districtReference // Only set remark if store_id is "4F", otherwise set to null this.remark = if (request.store_id == "4F") request.remark else null + this.logistic = logisticRefOrNull(request.logisticId) } - - return truckRepository.save(truck) + + val saved = truckRepository.save(truck) + if (primary != null) { + placeholders.drop(1).forEach { softDeleteTruckRow(it) } + } + return saved + } + + private fun formatDepartureForExcel(t: LocalTime?): String { + if (t == null) return "" + return DateTimeFormatter.ofPattern("HH:mm:ss").format(t) + } + + /** 依 loading 順序切出連續同 district 的段(每段日後對應一個「板」標題列)。 */ + private fun splitDistrictSegments(trucks: List): List> { + if (trucks.isEmpty()) return emptyList() + val out = mutableListOf>() + var cur = mutableListOf() + var prev: String? = null + for (t in trucks) { + val d = t.districtReference?.trim().orEmpty() + if (prev != null && d != prev) { + out.add(cur) + cur = mutableListOf() + } + cur.add(t) + prev = d + } + out.add(cur) + return out + } + + /** + * 匯出用:每段給定「板」欄顯示文字後,依中文排序段順序;段內維持原 loading 順序。 + * 無區域代碼(未分類)的段固定輸出「未分類」(與前端 RouteBoard 一致);多段未分類時同字串需靠列順序區分。 + */ + private fun sortDistrictSegmentsForPlateColumnExport( + trucksInLoadingOrder: List, + ): List>> { + val segments = splitDistrictSegments(trucksInLoadingOrder) + val labeled = segments.map { seg -> + val d = seg.first().districtReference?.trim().orEmpty() + val label = if (d.isNotEmpty()) d else "未分類" + label to seg + } + val coll = Collator.getInstance(Locale.forLanguageTag("zh-HK")) + coll.strength = Collator.TERTIARY + return labeled.sortedWith { a, b -> coll.compare(a.first, b.first) } + } + + /** + * PDF 圖1:一個 workbook 內每個車線一個 sheet(MTMS_ROUTE_V1)。 + */ + open fun exportRouteLanesExcelBytes(laneIds: List): ByteArray { + val wb = XSSFWorkbook() + return ByteArrayOutputStream().use { bos -> + try { + val distinctIds = laneIds.distinct().filter { it.isNotBlank() } + if (distinctIds.isEmpty()) { + throw IllegalArgumentException("laneIds is empty") + } + for (laneId in distinctIds) { + val key = RouteLaneExcelSupport.decodeLaneId(laneId) + ?: throw IllegalArgumentException("Invalid lane id: $laneId") + val (code, remark) = key + val trucks = findAllByTruckLanceCodeAndRemarkAndDeletedFalse(code, remark) + .sortedWith(compareBy({ it.loadingSequence ?: 0 }, { it.id ?: 0L })) + if (trucks.isEmpty()) continue + + val sheet = wb.createSheet( + RouteLaneExcelSupport.uniqueSheetName(wb, code, remark), + ) + + var rr = sheet.createRow(RouteLaneExcelSupport.ROW_MARKER) + rr.createCell(RouteLaneExcelSupport.COL_META_A) + .setCellValue(RouteLaneExcelSupport.FORMAT_MARKER) + rr.createCell(RouteLaneExcelSupport.COL_META_B).setCellValue(code) + rr.createCell(RouteLaneExcelSupport.COL_META_C).setCellValue(remark ?: "") + + rr = sheet.createRow(RouteLaneExcelSupport.ROW_STORE) + rr.createCell(RouteLaneExcelSupport.COL_META_A).setCellValue("樓層") + rr.createCell(RouteLaneExcelSupport.COL_META_B) + .setCellValue(trucks.first().storeId ?: "") + + rr = sheet.createRow(RouteLaneExcelSupport.ROW_DEPARTURE_DEFAULT) + rr.createCell(RouteLaneExcelSupport.COL_META_A).setCellValue("出車時間") + rr.createCell(RouteLaneExcelSupport.COL_META_B).setCellValue( + formatDepartureForExcel(trucks.first().departureTime), + ) + + rr = sheet.createRow(RouteLaneExcelSupport.ROW_HEADER) + rr.createCell(RouteLaneExcelSupport.COL_AREA_PLATE).setCellValue("板") + rr.createCell(RouteLaneExcelSupport.COL_SHOP_NAME).setCellValue("店鋪名稱") + rr.createCell(RouteLaneExcelSupport.COL_BRAND).setCellValue("品牌") + rr.createCell(RouteLaneExcelSupport.COL_SHOP_CODE).setCellValue("店鋪編號") + rr.createCell(RouteLaneExcelSupport.COL_SCHEDULE).setCellValue("此店車期") + rr.createCell(RouteLaneExcelSupport.COL_DEPARTURE_ROW).setCellValue("出車時間") + + val segmentsForRows = sortDistrictSegmentsForPlateColumnExport(trucks) + var rowNum = RouteLaneExcelSupport.ROW_FIRST_DATA + for ((label, seg) in segmentsForRows) { + for ((idx, t) in seg.withIndex()) { + val colA = if (idx == 0) label else "" + val dataRow = sheet.createRow(rowNum++) + dataRow.createCell(RouteLaneExcelSupport.COL_AREA_PLATE).setCellValue(colA) + dataRow.createCell(RouteLaneExcelSupport.COL_SHOP_NAME).setCellValue(t.shopName ?: "") + dataRow.createCell(RouteLaneExcelSupport.COL_BRAND) + .setCellValue(deriveBrandFromShopFullName(t.shop?.name)) + dataRow.createCell(RouteLaneExcelSupport.COL_SHOP_CODE).setCellValue(t.shopCode ?: "") + dataRow.createCell(RouteLaneExcelSupport.COL_SCHEDULE).setCellValue(t.remark ?: "") + dataRow.createCell(RouteLaneExcelSupport.COL_DEPARTURE_ROW).setCellValue( + formatDepartureForExcel(t.departureTime), + ) + } + } + RouteLaneExcelSupport.applyRouteLaneExportFinishing( + sheet, + wb, + RouteLaneExcelSupport.ROW_FIRST_DATA, + rowNum - 1, + ) + } + if (wb.numberOfSheets == 0) { + throw IllegalArgumentException("No lane data to export (check lane ids / empty lanes)") + } + wb.write(bos) + bos.toByteArray() + } finally { + wb.close() + } + } + } + + fun buildRouteReportFilename(): String { + val d = LocalDate.now().format(DateTimeFormatter.ofPattern("yyyy-MM-dd")) + return "車線Report_${d}.xlsx" + } + + private fun normalizeRemarkForLaneGroup(raw: String?): String { + val s = raw?.trim().orEmpty() + return s + } + + private fun laneKey(truckLanceCode: String?, remark: String?): Pair { + val code = truckLanceCode?.trim().orEmpty() + val rem = normalizeRemarkForLaneGroup(remark) + return code to rem + } + + /** + * 圖2:車線 Report(單一 sheet,每間物流公司一個水平區塊)。 + * laneIds 若不空,會限制只匯出指定 lane group(與前端 encodeLaneId 對應)。 + */ + open fun exportRouteReportExcelBytes( + laneIds: List, + preparedBy: String = "—", + ): ByteArray { + val wb = XSSFWorkbook() + return ByteArrayOutputStream().use { bos -> + try { + val trucksAll = truckRepository.findAllForRouteBoard() + val distinctLaneIds = laneIds.distinct().filter { it.isNotBlank() } + val decoded = + distinctLaneIds.mapNotNull { RouteLaneExcelSupport.decodeLaneId(it) } + if (distinctLaneIds.isNotEmpty() && decoded.isEmpty()) { + throw IllegalArgumentException("Invalid laneIds") + } + val filterKeys: Set> = + decoded + .map { (code, remark) -> + code.trim() to normalizeRemarkForLaneGroup(remark) + } + .toSet() + + val trucks = + if (filterKeys.isEmpty()) { + trucksAll + } else { + trucksAll.filter { t -> + val (c, r) = laneKey(t.truckLanceCode, t.remark) + filterKeys.contains(c to r) + } + } + + val coll = Collator.getInstance(Locale.forLanguageTag("zh-HK")).apply { + strength = Collator.TERTIARY + } + data class CompanyKey(val id: Long?, val name: String) + + val byCompanyId = trucks.groupBy { t -> + val id = t.logistic?.id + val nameRaw = t.logistic?.logisticName?.trim().orEmpty() + val name = if (nameRaw.isNotEmpty()) nameRaw else "未命名物流" + CompanyKey(id, name) + } + + val companies = byCompanyId.entries + .filter { it.value.isNotEmpty() } + .sortedWith { a, b -> + val c = coll.compare(a.key.name, b.key.name) + if (c != 0) c else (a.key.id ?: Long.MIN_VALUE).compareTo(b.key.id ?: Long.MIN_VALUE) + } + if (companies.isEmpty()) { + throw IllegalArgumentException("No lane data to export") + } + + val sheet = wb.createSheet(RouteReportExcelSupport.SHEET_NAME) + val st = RouteReportExcelSupport.buildStyles(wb) + + val today = LocalDate.now().format(DateTimeFormatter.ofPattern("yyyy-MM-dd")) + RouteReportExcelSupport.writeTitle( + sheet, + st, + "新車綫($today)更改車線", + "製表: $preparedBy", + companies.size, + ) + + val timeFmt = DateTimeFormatter.ofPattern("HH:mm") + + var blockIndex = 0 + for ((ck, list) in companies) { + val sorted = list.sortedWith( + compareBy( + { it.truckLanceCode?.trim().orEmpty() }, + { it.districtReference?.trim().orEmpty() }, + { it.loadingSequence ?: 0 }, + { it.id ?: 0L }, + ), + ) + + val metaTruck = sorted.first() + val logistic = metaTruck.logistic + val plate = logistic?.carPlate?.trim().orEmpty() + val driverName = logistic?.driverName?.trim().orEmpty() + val driverNumber = logistic?.driverNumber?.toString().orEmpty() + + // 先以 lane group(truckLanceCode + normalized remark)聚合,避免同 lane 因為某些 row 的 departureTime 為 null/不一致而被拆散 + data class LaneKey(val code: String, val remark: String) + data class LaneBucket( + val key: LaneKey, + val time: LocalTime, + val trucks: List, + ) + + val laneBuckets = sorted + .groupBy { t -> + val (c, r) = laneKey(t.truckLanceCode, t.remark) + LaneKey(c, r) + } + .entries + .map { (k, laneTrucks) -> + val laneTime = laneTrucks.firstNotNullOfOrNull { it.departureTime } ?: LocalTime.MIDNIGHT + LaneBucket(k, laneTime, laneTrucks) + } + + val timeGroups = laneBuckets + .groupBy { it.time } + .toSortedMap() + .map { (time, bucketsAtTime) -> + val lanes = bucketsAtTime + .sortedWith { a, b -> + val c1 = coll.compare(a.key.code, b.key.code) + if (c1 != 0) c1 else coll.compare(a.key.remark, b.key.remark) + } + .map { bucket -> + val laneLabel = + if (bucket.key.remark.isNotEmpty()) { + "${bucket.key.code}-${bucket.key.remark}" + } else { + bucket.key.code + }.ifEmpty { "—" } + + val districts = bucket.trucks + .groupBy { it.districtReference?.trim().orEmpty() } + .mapKeys { (k) -> if (k.isNotEmpty()) k else "未分類" } + .toList() + .sortedWith { a, b -> + if (a.first == "未分類") -1 + else if (b.first == "未分類") 1 + else coll.compare(a.first, b.first) + } + .map { (district, dTrucks) -> + val shops = dTrucks + .sortedWith(compareBy({ it.loadingSequence ?: 0 }, { it.id ?: 0L })) + .map { t -> + val name = t.shopName?.trim().takeUnless { it.isNullOrBlank() } + ?: t.shopCode?.trim().takeUnless { it.isNullOrBlank() } + ?: "—" + val brand = deriveBrandFromShopFullName(t.shop?.name).trim() + val code = t.shopCode?.trim().orEmpty() + val lines = ArrayList(3) + lines.add(name) + if (brand.isNotEmpty()) lines.add(brand) + if (code.isNotEmpty() && code != name) lines.add(code) + lines.joinToString("\n") + } + RouteReportExcelSupport.DistrictGroup(district, shops) + } + RouteReportExcelSupport.LaneGroup(laneLabel, districts) + } + RouteReportExcelSupport.TimeGroup(time.format(timeFmt), lanes) + } + + val distinctShopCount = sorted + .map { + val code = it.shopCode?.trim().orEmpty() + if (code.isNotEmpty()) "code:$code" else "id:${it.id}" + } + .toSet() + .size + + RouteReportExcelSupport.writeCompanyBlock( + sheet, + st, + blockIndex, + 1, + RouteReportExcelSupport.BlockMeta( + companyName = ck.name, + plate = plate, + driverName = driverName, + driverNumber = driverNumber, + ), + timeGroups, + distinctShopCount, + ) + blockIndex++ + } + + wb.write(bos) + bos.toByteArray() + } finally { + wb.close() + } + } + } + + data class ExportTruckLaneVersionReportInput( + val fromVersionId: Long, + val toVersionId: Long, + val preparedBy: String, + ) + + /** + * 匯出「版本 Log 車線報告」: + * - Sheet1:版本異動報告(高亮 + 文字說明) + * - 其餘 sheets:每車線一個 worksheet(MTMS_ROUTE_V1)— 內容來自 toVersion 快照 lines + */ + open fun exportTruckLaneVersionReportExcelBytes(input: ExportTruckLaneVersionReportInput): ByteArray { + val wb = XSSFWorkbook() + return ByteArrayOutputStream().use { bos -> + try { + if (input.fromVersionId == input.toVersionId) { + throw IllegalArgumentException("fromVersionId and toVersionId must be different") + } + val fromV = truckLaneVersionRepository.findByIdAndDeletedFalse(input.fromVersionId) + ?: throw IllegalArgumentException("Version not found: ${input.fromVersionId}") + val toV = truckLaneVersionRepository.findByIdAndDeletedFalse(input.toVersionId) + ?: throw IllegalArgumentException("Version not found: ${input.toVersionId}") + + // 避免 from/to 顛倒導致新增/刪除/移動語意反轉 + if (fromV.created != null && toV.created != null && fromV.created!!.isAfter(toV.created)) { + throw IllegalArgumentException("fromVersionId must be older than toVersionId") + } + + val fromLines = truckLaneVersionLineRepository + .findAllByTruckLaneVersion_IdAndDeletedFalseOrderByIdAsc(input.fromVersionId) + val toLines = truckLaneVersionLineRepository + .findAllByTruckLaneVersion_IdAndDeletedFalseOrderByIdAsc(input.toVersionId) + + // 大檔保護:避免 OOM / timeout + val maxLines = 80_000 + val maxLaneSheets = 300 + if (fromLines.size + toLines.size > maxLines) { + throw IllegalArgumentException("Snapshot too large to export (lines>${maxLines})") + } + + val logisticIds = (fromLines.mapNotNull { it.logisticId } + toLines.mapNotNull { it.logisticId }).toSet() + val logisticNameById = + if (logisticIds.isEmpty()) { + emptyMap() + } else { + logisticRepository.findAllById(logisticIds).associate { (it.id ?: -1L) to (it.logisticName ?: "") } + } + + val diffLines = buildVersionDiffLines(fromLines, toLines) + val summaryRows = buildSummaryRows(fromLines, toLines, diffLines, logisticNameById) + + val createdDate = (toV.created?.toString() ?: "").take(10) + val title = "車線報告(${createdDate.ifBlank { "—" }})更改車線" + val editor = (toV.modifiedBy ?: input.preparedBy).trim().ifBlank { input.preparedBy } + val created = toV.created?.toString() ?: "—" + + val stats = summarizeSummaryRows(summaryRows) + val statsText = + "新增 ${stats.added} · 移動 ${stats.moved} · 刪除 ${stats.deleted} · 欄位變更 ${stats.fieldChanged}" + + TruckLaneVersionReportExcelSupport.writeSummarySheet( + wb, + TruckLaneVersionReportExcelSupport.SummaryMeta( + title = title, + editor = editor, + created = created, + fromVersionId = input.fromVersionId, + toVersionId = input.toVersionId, + note = toV.note, + statsText = statsText, + ), + summaryRows, + ) + + // Sheet2:同正常車線報告版面,但把異動 row 高亮(資料來自 toVersion 快照) + val logisticsByIdForVersionReport = if (logisticIds.isEmpty()) emptyMap() else + logisticRepository.findAllById(logisticIds).associateBy { it.id ?: -1L } + appendVersionRouteReportSheet( + wb, + createdDate.ifBlank { "—" }, + editor, + toLines, + diffLines, + logisticNameById, + logisticsByIdForVersionReport, + ) + + // lane sheets (MTMS_ROUTE_V1) based on toLines snapshot + appendLaneSheetsFromSnapshotLines(wb, toLines, maxLaneSheets) + + wb.write(bos) + bos.toByteArray() + } finally { + wb.close() + } + } + } + + private data class SummaryStats( + val added: Int, + val moved: Int, + val deleted: Int, + val fieldChanged: Int, + ) + + private fun summarizeSummaryRows(rows: List): SummaryStats { + var added = 0 + var moved = 0 + var deleted = 0 + var fieldChanged = 0 + for (r in rows) { + when (r.type) { + TruckLaneVersionReportExcelSupport.RowType.ADDED -> added++ + TruckLaneVersionReportExcelSupport.RowType.MOVED -> moved++ + TruckLaneVersionReportExcelSupport.RowType.DELETED -> deleted++ + TruckLaneVersionReportExcelSupport.RowType.EDITED -> {} + } + if (r.changedFields.isNotEmpty()) fieldChanged++ + } + return SummaryStats(added, moved, deleted, fieldChanged) + } + + private fun laneLabel(code: String?, remark: String?): String { + val c = code?.trim().orEmpty() + val r = remark?.trim().orEmpty() + if (c.isEmpty() && r.isEmpty()) return "—" + if (r.isEmpty()) return c + return "$c-$r" + } + + private fun buildVersionDiffLines( + fromLines: List, + toLines: List, + ): List { + 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 = ArrayList() + + fun s(v: Any?): String? = v?.toString() + + for (key in allKeys) { + val a = fromByRow[key] + val b = toByRow[key] + val changes = ArrayList() + + 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, + ), + ) + } + } + return changed + } + + private val FIELD_LABEL = mapOf( + "departureTime" to "發車時段", + "loadingSequence" to "裝載順序", + "branchName" to "分店名稱", + "districtReference" to "區域", + "shopCode" to "店鋪代碼", + "storeId" to "樓層/店別", + "remark" to "備註", + "truckLanceCode" to "車線代碼", + "logisticId" to "物流公司", + ) + + private fun buildSummaryRows( + fromLines: List, + toLines: List, + diffs: List, + logisticNameById: Map, + ): List { + val fromByRow = fromLines.associateBy { it.truckRowId ?: -1 } + val toByRow = toLines.associateBy { it.truckRowId ?: -1 } + + fun shopName(line: com.ffii.fpsms.modules.pickOrder.entity.TruckLaneVersionLine?): String { + val n = line?.branchName?.trim().orEmpty() + return if (n.isNotEmpty()) n else (line?.shopCode?.trim().orEmpty().ifEmpty { "—" }) + } + + val out = ArrayList() + for (d in diffs) { + val a = fromByRow[d.truckRowId] + val b = toByRow[d.truckRowId] + val fromLane = laneLabel(a?.truckLanceCode, a?.remark) + val toLane = laneLabel(b?.truckLanceCode, b?.remark) + val fromEmpty = fromLane == "—" + val toEmpty = toLane == "—" + val type = + if (fromEmpty && !toEmpty) TruckLaneVersionReportExcelSupport.RowType.ADDED + else if (!fromEmpty && toEmpty) TruckLaneVersionReportExcelSupport.RowType.DELETED + else if (fromLane != toLane) TruckLaneVersionReportExcelSupport.RowType.MOVED + else TruckLaneVersionReportExcelSupport.RowType.EDITED + + val changedFields = d.changes + .map { it.field } + .filterNot { it == "truckLanceCode" || it == "remark" } + .toSet() + val textBits = ArrayList() + if (type == TruckLaneVersionReportExcelSupport.RowType.ADDED) { + textBits.add("新增到 $toLane") + } else if (type == TruckLaneVersionReportExcelSupport.RowType.DELETED) { + textBits.add("自 $fromLane 移除") + } else if (type == TruckLaneVersionReportExcelSupport.RowType.MOVED) { + textBits.add("由 $fromLane → $toLane") + } + for (c in d.changes) { + if (c.field == "truckLanceCode" || c.field == "remark") continue + val label = FIELD_LABEL[c.field] ?: c.field + val from = + if (c.field == "logisticId") { + val id = c.from?.trim()?.toLongOrNull() + val name = id?.let { logisticNameById[it] }?.trim().orEmpty() + name.ifEmpty { c.from?.trim().takeUnless { it.isNullOrBlank() } ?: "—" } + } else { + c.from?.trim().takeUnless { it.isNullOrBlank() } ?: "—" + } + val to = + if (c.field == "logisticId") { + val id = c.to?.trim()?.toLongOrNull() + val name = id?.let { logisticNameById[it] }?.trim().orEmpty() + name.ifEmpty { c.to?.trim().takeUnless { it.isNullOrBlank() } ?: "—" } + } else { + c.to?.trim().takeUnless { it.isNullOrBlank() } ?: "—" + } + if (from != to) textBits.add("$label:$from → $to") + } + + out.add( + TruckLaneVersionReportExcelSupport.SummaryRow( + type = type, + shopName = shopName(b ?: a), + shopCode = (b?.shopCode ?: a?.shopCode ?: "").trim(), + fromLane = fromLane, + toLane = toLane, + changeText = textBits.joinToString(";").ifEmpty { "欄位變更" }, + changedFields = changedFields, + ), + ) + } + return out + } + + private fun appendLaneSheetsFromSnapshotLines( + wb: XSSFWorkbook, + toLines: List, + maxLaneSheets: Int, + ) { + data class LaneKey(val code: String, val remark: String) + fun keyOf(l: com.ffii.fpsms.modules.pickOrder.entity.TruckLaneVersionLine): LaneKey { + val c = l.truckLanceCode?.trim().orEmpty() + val r = normalizeRemarkForLaneGroup(l.remark) + return LaneKey(c, r) + } + + val codes = toLines.mapNotNull { it.shopCode?.trim()?.takeIf { s -> s.isNotEmpty() } }.toSet() + val shopNameByCode = if (codes.isEmpty()) emptyMap() else + shopRepository.findAllByCodeInAndDeletedIsFalse(codes).associateBy({ it.code ?: "" }, { it.name ?: "" }) + + fun deriveBrandByShopCode(code: String?): String { + val c = code?.trim().orEmpty() + if (c.isEmpty()) return "" + val full = shopNameByCode[c].orEmpty() + return deriveBrandFromShopFullName(full) + } + + fun parseLocalTimeOrNull(raw: String?): LocalTime? = parseDepartureTime(raw) + + // group by lane + val byLane = toLines + .filter { !it.truckLanceCode.isNullOrBlank() } + .groupBy(::keyOf) + .entries + .sortedWith(compareBy({ it.key.code }, { it.key.remark })) + + if (byLane.size > maxLaneSheets) { + throw IllegalArgumentException("Too many lane sheets to export (>${maxLaneSheets})") + } + + for ((k, linesRaw) in byLane) { + val lines = linesRaw.sortedWith(compareBy({ it.loadingSequence ?: 0 }, { it.truckRowId ?: 0L }, { it.id ?: 0L })) + if (lines.isEmpty()) continue + val sheetName = RouteLaneExcelSupport.uniqueSheetName(wb, k.code, k.remark.ifEmpty { null }) + val sheet = wb.createSheet(sheetName) + + // marker/meta + var rr = sheet.createRow(RouteLaneExcelSupport.ROW_MARKER) + rr.createCell(RouteLaneExcelSupport.COL_META_A).setCellValue(RouteLaneExcelSupport.FORMAT_MARKER) + rr.createCell(RouteLaneExcelSupport.COL_META_B).setCellValue(k.code) + rr.createCell(RouteLaneExcelSupport.COL_META_C).setCellValue(k.remark) + + rr = sheet.createRow(RouteLaneExcelSupport.ROW_STORE) + rr.createCell(RouteLaneExcelSupport.COL_META_A).setCellValue("樓層") + rr.createCell(RouteLaneExcelSupport.COL_META_B).setCellValue(lines.first().storeId ?: "") + + rr = sheet.createRow(RouteLaneExcelSupport.ROW_DEPARTURE_DEFAULT) + rr.createCell(RouteLaneExcelSupport.COL_META_A).setCellValue("出車時間") + val deptDefault = parseLocalTimeOrNull(lines.firstNotNullOfOrNull { it.departureTime }) + rr.createCell(RouteLaneExcelSupport.COL_META_B).setCellValue(formatDepartureForExcel(deptDefault)) + + rr = sheet.createRow(RouteLaneExcelSupport.ROW_HEADER) + rr.createCell(RouteLaneExcelSupport.COL_AREA_PLATE).setCellValue("板") + rr.createCell(RouteLaneExcelSupport.COL_SHOP_NAME).setCellValue("店鋪名稱") + rr.createCell(RouteLaneExcelSupport.COL_BRAND).setCellValue("品牌") + rr.createCell(RouteLaneExcelSupport.COL_SHOP_CODE).setCellValue("店鋪編號") + rr.createCell(RouteLaneExcelSupport.COL_SCHEDULE).setCellValue("此店車期") + rr.createCell(RouteLaneExcelSupport.COL_DEPARTURE_ROW).setCellValue("出車時間") + + // segments by district changes in loading order + val segments = ArrayList>() + var cur = ArrayList() + var prev = "" + for (l in lines) { + val d = l.districtReference?.trim().orEmpty() + if (cur.isNotEmpty() && d != prev) { + segments.add(cur) + cur = ArrayList() + } + cur.add(l) + prev = d + } + if (cur.isNotEmpty()) segments.add(cur) + + val coll = Collator.getInstance(Locale.forLanguageTag("zh-HK")).apply { strength = Collator.TERTIARY } + val labeled = segments.map { seg -> + val d = seg.first().districtReference?.trim().orEmpty() + val label = if (d.isNotEmpty()) d else "未分類" + label to seg + }.sortedWith { a, b -> coll.compare(a.first, b.first) } + + var rowNum = RouteLaneExcelSupport.ROW_FIRST_DATA + for ((label, seg) in labeled) { + for ((idx, l) in seg.withIndex()) { + val colA = if (idx == 0) label else "" + val dataRow = sheet.createRow(rowNum++) + dataRow.createCell(RouteLaneExcelSupport.COL_AREA_PLATE).setCellValue(colA) + dataRow.createCell(RouteLaneExcelSupport.COL_SHOP_NAME).setCellValue(l.branchName ?: "") + dataRow.createCell(RouteLaneExcelSupport.COL_BRAND).setCellValue(deriveBrandByShopCode(l.shopCode)) + dataRow.createCell(RouteLaneExcelSupport.COL_SHOP_CODE).setCellValue(l.shopCode ?: "") + dataRow.createCell(RouteLaneExcelSupport.COL_SCHEDULE).setCellValue(l.remark ?: "") + val dept = parseLocalTimeOrNull(l.departureTime) ?: deptDefault + dataRow.createCell(RouteLaneExcelSupport.COL_DEPARTURE_ROW).setCellValue(formatDepartureForExcel(dept)) + } + } + + RouteLaneExcelSupport.applyRouteLaneExportFinishing( + sheet, + wb, + RouteLaneExcelSupport.ROW_FIRST_DATA, + rowNum - 1, + ) + } + } + + private fun appendVersionRouteReportSheet( + wb: XSSFWorkbook, + createdDate: String, + editor: String, + toLines: List, + diffLines: List, + logisticNameById: Map, + logisticsById: Map, + ) { + val diffByRowId = diffLines.associateBy { it.truckRowId } + val logisticChangeByRowId = diffLines.mapNotNull { d -> + d.changes.firstOrNull { it.field == "logisticId" }?.let { d.truckRowId to it } + }.toMap() + + val changedFieldsByRowId: Map> = diffLines.associate { d -> + d.truckRowId to d.changes.map { it.field }.toSet() + } + val changedRowIds = changedFieldsByRowId.keys.toSet() + + val shopCodes = toLines.mapNotNull { it.shopCode?.trim()?.takeIf { s -> s.isNotEmpty() } }.toSet() + val shopNameByCode = if (shopCodes.isEmpty()) emptyMap() else + shopRepository.findAllByCodeInAndDeletedIsFalse(shopCodes).associateBy({ it.code ?: "" }, { it.name ?: "" }) + + fun brandByShopCode(code: String?): String { + val c = code?.trim().orEmpty() + if (c.isEmpty()) return "" + return deriveBrandFromShopFullName(shopNameByCode[c].orEmpty()).trim() + } + + fun laneLabel(code: String?, remark: String?): String { + val c = code?.trim().orEmpty() + val r = remark?.trim().orEmpty() + if (c.isEmpty() && r.isEmpty()) return "—" + return if (r.isEmpty()) c else "$c-$r" + } + + fun timeLabel(raw: String?): String { + val t = parseDepartureTime(raw) ?: return "00:00" + return DateTimeFormatter.ofPattern("HH:mm").format(t) + } + + // group by logisticId + val linesWithLane = toLines.filter { !it.truckLanceCode.isNullOrBlank() } + data class CompanyKey(val id: Long?, val name: String) + val byCompany = linesWithLane.groupBy { l -> + val id = l.logisticId + val logi = if (id != null) logisticsById[id] else null + val name = logi?.logisticName?.trim().orEmpty().ifEmpty { "未命名物流" } + CompanyKey(id, name) + }.entries.sortedBy { it.key.name } + + val st = TruckLaneVersionRouteReportExcelSupport.buildStyles(wb) + val sheet = wb.createSheet(TruckLaneVersionRouteReportExcelSupport.SHEET_NAME) + TruckLaneVersionRouteReportExcelSupport.writeTitle( + sheet, + st, + "車線報告($createdDate)更改車線", + "製表: $editor", + byCompany.size.coerceAtLeast(1), + ) + + var blockIndex = 0 + for ((ck, list) in byCompany) { + val metaLogi = ck.id?.let { logisticsById[it] } + val plate = metaLogi?.carPlate?.trim().orEmpty() + val driverName = metaLogi?.driverName?.trim().orEmpty() + val driverNumber = metaLogi?.driverNumber?.toString().orEmpty() + + // lane buckets by (code+remark) to avoid split by inconsistent times + data class LaneKey(val code: String, val remark: String) + data class LaneBucket(val key: LaneKey, val time: String, val lines: List) + val laneBuckets = list.groupBy { l -> + LaneKey(l.truckLanceCode?.trim().orEmpty(), normalizeRemarkForLaneGroup(l.remark)) + }.map { (k, ls) -> + val t = ls.firstNotNullOfOrNull { it.departureTime } ?: "" + LaneBucket(k, timeLabel(t), ls) + } + + val timeGroups = laneBuckets.groupBy { it.time }.toSortedMap().map { (time, bucketsAtTime) -> + val lanes = bucketsAtTime + .sortedWith(compareBy({ it.key.code }, { it.key.remark })) + .map { bucket -> + val lane = laneLabel(bucket.key.code, bucket.key.remark) + val districts = bucket.lines + .groupBy { it.districtReference?.trim().orEmpty().ifEmpty { "未分類" } } + .toSortedMap { a, b -> + if (a == "未分類") -1 + else if (b == "未分類") 1 + else a.compareTo(b) + } + .map { (district, dLines) -> + val shops = dLines + .sortedWith(compareBy({ it.loadingSequence ?: 0 }, { it.truckRowId ?: 0L }, { it.id ?: 0L })) + .map { l -> + val id = l.truckRowId ?: -1L + val changed = changedRowIds.contains(id) + val name = l.branchName?.trim().orEmpty().ifEmpty { l.shopCode?.trim().orEmpty().ifEmpty { "—" } } + val brand = brandByShopCode(l.shopCode) + val code = l.shopCode?.trim().orEmpty() + val extra = changedFieldsByRowId[id]?.let { fields -> + val shown = fields + .filterNot { it == "truckLanceCode" || it == "remark" || it == "logisticId" } + .map { FIELD_LABEL[it] ?: it } + if (shown.isNotEmpty()) "變更: ${shown.joinToString(",")}" else null + } + val logisticChange = logisticChangeByRowId[id]?.let { ch -> + val fromName = ch.from?.trim()?.toLongOrNull() + ?.let { logisticNameById[it] } + ?.trim() + .orEmpty() + .ifEmpty { ch.from?.trim().orEmpty() } + .ifEmpty { "—" } + val toName = ch.to?.trim()?.toLongOrNull() + ?.let { logisticNameById[it] } + ?.trim() + .orEmpty() + .ifEmpty { ch.to?.trim().orEmpty() } + .ifEmpty { "—" } + if (fromName != toName) "物流公司:$fromName → $toName" else null + } + val linesText = ArrayList(4) + linesText.add(name) + if (brand.isNotEmpty()) linesText.add(brand) + if (code.isNotEmpty() && code != name) linesText.add(code) + if (!extra.isNullOrBlank()) linesText.add(extra) + if (!logisticChange.isNullOrBlank()) linesText.add(logisticChange) + TruckLaneVersionRouteReportExcelSupport.ShopRow( + truckRowId = id, + text = linesText.joinToString("\n"), + changed = changed, + ) + } + TruckLaneVersionRouteReportExcelSupport.DistrictGroup(district, shops) + } + TruckLaneVersionRouteReportExcelSupport.LaneGroup(lane, districts) + } + TruckLaneVersionRouteReportExcelSupport.TimeGroup(time, lanes) + } + + val distinctShopCount = list.mapNotNull { it.shopCode?.trim()?.takeIf { s -> s.isNotEmpty() } }.toSet().size + + TruckLaneVersionRouteReportExcelSupport.writeCompanyBlock( + sheet, + st, + blockIndex, + 1, + TruckLaneVersionRouteReportExcelSupport.BlockMeta( + companyName = ck.name, + plate = plate, + driverName = driverName, + driverNumber = driverNumber, + ), + timeGroups, + distinctShopCount, + ) + blockIndex++ + } + } + + /** Parse MTMS_ROUTE_V1 workbook without writing to DB (for staged import preview). */ + open fun parseRouteLanesExcel(workbook: Workbook?): ParseRouteLanesExcelResponse { + if (workbook == null) { + return ParseRouteLanesExcelResponse(0, 0, emptyList()) + } + var sheetsProcessed = 0 + val previewRows = mutableListOf() + for (si in 0 until workbook.numberOfSheets) { + val sheet = workbook.getSheetAt(si) + val row0 = sheet.getRow(RouteLaneExcelSupport.ROW_MARKER) ?: continue + val marker = ExcelUtils.getStringValue(row0.getCell(RouteLaneExcelSupport.COL_META_A)).trim() + if (marker != RouteLaneExcelSupport.FORMAT_MARKER) { + logger.warn("Skip sheet ${sheet.sheetName}: not ${RouteLaneExcelSupport.FORMAT_MARKER}") + continue + } + val truckLanceCode = + ExcelUtils.getStringValue(row0.getCell(RouteLaneExcelSupport.COL_META_B)).trim() + if (truckLanceCode.isEmpty()) { + logger.warn("Skip sheet ${sheet.sheetName}: empty truckLanceCode") + continue + } + val remarkCell = + ExcelUtils.getStringValue(row0.getCell(RouteLaneExcelSupport.COL_META_C)).trim() + val laneRemark = remarkCell.ifEmpty { null } + + val rowStore = sheet.getRow(RouteLaneExcelSupport.ROW_STORE) + val storeId = + ExcelUtils.getStringValue(rowStore?.getCell(RouteLaneExcelSupport.COL_META_B)).trim() + if (storeId.isEmpty()) { + logger.warn("Skip sheet ${sheet.sheetName}: empty store id") + continue + } + + val rowDeptRow = sheet.getRow(RouteLaneExcelSupport.ROW_DEPARTURE_DEFAULT) + val defaultDeptStr = + ExcelUtils.getStringValue(rowDeptRow?.getCell(RouteLaneExcelSupport.COL_META_B)).trim() + val defaultDept = parseDepartureTime(defaultDeptStr) + + var currentDistrict = "" + var seq = 1 + for (i in RouteLaneExcelSupport.ROW_FIRST_DATA..sheet.lastRowNum) { + val row = sheet.getRow(i) ?: continue + val c0 = + ExcelUtils.getStringValue(row.getCell(RouteLaneExcelSupport.COL_AREA_PLATE)).trim() + if (c0.isNotEmpty()) { + currentDistrict = c0 + } + val shopName = + ExcelUtils.getStringValue(row.getCell(RouteLaneExcelSupport.COL_SHOP_NAME)).trim() + val shopCodeRaw = + ExcelUtils.getStringValue(row.getCell(RouteLaneExcelSupport.COL_SHOP_CODE)).trim() + if (shopCodeRaw.isEmpty()) { + continue + } + + val scheduleRemark = + ExcelUtils.getStringValue(row.getCell(RouteLaneExcelSupport.COL_SCHEDULE)).trim() + val rowDeptStr = + ExcelUtils.getStringValue(row.getCell(RouteLaneExcelSupport.COL_DEPARTURE_ROW)).trim() + val departure = parseDepartureTime(rowDeptStr) ?: defaultDept + if (departure == null) { + logger.warn("Sheet ${sheet.sheetName} row ${i + 1}: skipped — invalid departure") + continue + } + + val normalizedShopCode = normalizeShopCode(shopCodeRaw) + val allShops = shopRepository.findAllByDeletedIsFalse() + val shop = allShops.firstOrNull { it.code == shopCodeRaw } + ?: allShops.firstOrNull { it.code == normalizedShopCode } + if (shop == null) { + logger.warn( + "Sheet ${sheet.sheetName} row ${i + 1}: no shop for code '$shopCodeRaw' (normalized '$normalizedShopCode')", + ) + continue + } + + val effectiveRemark = + if (storeId == "4F") { + when { + scheduleRemark.isNotEmpty() -> scheduleRemark + laneRemark != null && laneRemark.isNotEmpty() -> laneRemark + else -> null + } + } else { + null + } + + val existingTruck = + resolveExistingTruckForRouteLaneImport( + truckLanceCode.trim(), + storeId, + laneRemark, + shop.id!!, + shopCodeRaw, + normalizedShopCode, + ) + val logisticId = existingTruck?.logistic?.id + + previewRows.add( + RouteLaneImportPreviewRow( + truckRowId = existingTruck?.id, + truckLanceCode = truckLanceCode, + remark = effectiveRemark, + storeId = storeId, + departureTime = departure.toString(), + shopId = shop.id!!, + shopName = shopName.ifEmpty { shop.name ?: "" }, + shopCode = normalizedShopCode, + loadingSequence = seq, + districtReference = normalizeDistrictReferenceForRouteLaneImport(currentDistrict), + logisticId = logisticId, + ), + ) + seq++ + } + sheetsProcessed++ + } + return ParseRouteLanesExcelResponse( + sheetCount = sheetsProcessed, + rowCount = previewRows.size, + rows = previewRows, + ) + } + + private fun saveTruckFromImportPreview(row: RouteLaneImportPreviewRow) { + val departure = parseDepartureTime(row.departureTime) + ?: throw IllegalArgumentException("Invalid departureTime: ${row.departureTime}") + saveTruck( + SaveTruckRequest( + id = row.truckRowId, + store_id = row.storeId, + truckLanceCode = row.truckLanceCode, + departureTime = departure, + shopId = row.shopId, + shopName = row.shopName, + shopCode = row.shopCode, + loadingSequence = row.loadingSequence, + remark = row.remark, + districtReference = row.districtReference, + logisticId = row.logisticId, + ), + ) + } + + /** 不做單一大 transaction:逐列 [saveTruck] 各自提交,部分列失敗時前面仍保留 */ + open fun importRouteLanesExcel(workbook: Workbook?): String { + if (workbook == null) { + return "Import Excel failure" + } + val parsed = parseRouteLanesExcel(workbook) + for (row in parsed.rows) { + saveTruckFromImportPreview(row) + } + return "Import Excel success: ${parsed.sheetCount} sheet(s), ${parsed.rowCount} row(s)" } } \ No newline at end of file diff --git a/src/main/java/com/ffii/fpsms/modules/pickOrder/web/TruckController.kt b/src/main/java/com/ffii/fpsms/modules/pickOrder/web/TruckController.kt index 65e0694..9df44fa 100644 --- a/src/main/java/com/ffii/fpsms/modules/pickOrder/web/TruckController.kt +++ b/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 { + 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 { + 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 { + 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 { + 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 { - return truckService.findAllUniqueTruckLanceCodeAndRemarkCombinations() + fun findAllUniqueTruckLanceCodeAndRemarkCombinations(): List { + 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 { + 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 { + return truckService.findAllForRouteBoard() + } + @GetMapping("/findAllUniqueShopNamesAndCodesFromTrucks") fun findAllUniqueShopNamesAndCodesFromTrucks(): List> { return truckService.findAllUniqueShopNamesAndCodesFromTrucks() diff --git a/src/main/java/com/ffii/fpsms/modules/pickOrder/web/TruckLaneVersionController.kt b/src/main/java/com/ffii/fpsms/modules/pickOrder/web/TruckLaneVersionController.kt new file mode 100644 index 0000000..90eba64 --- /dev/null +++ b/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 { + 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 { + 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 { + val msg = truckLaneVersionService.restore(versionId) + return ResponseEntity.ok( + MessageResponse( + id = null, + name = null, + code = null, + type = "OK", + message = msg, + errorPosition = null, + entity = null, + ) + ) + } +} + diff --git a/src/main/java/com/ffii/fpsms/modules/pickOrder/web/models/ExportRouteLanesRequest.kt b/src/main/java/com/ffii/fpsms/modules/pickOrder/web/models/ExportRouteLanesRequest.kt new file mode 100644 index 0000000..9ce0111 --- /dev/null +++ b/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, +) diff --git a/src/main/java/com/ffii/fpsms/modules/pickOrder/web/models/ExportRouteReportRequest.kt b/src/main/java/com/ffii/fpsms/modules/pickOrder/web/models/ExportRouteReportRequest.kt new file mode 100644 index 0000000..cb70349 --- /dev/null +++ b/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 = emptyList(), +) + diff --git a/src/main/java/com/ffii/fpsms/modules/pickOrder/web/models/ExportTruckLaneVersionReportExcelRequest.kt b/src/main/java/com/ffii/fpsms/modules/pickOrder/web/models/ExportTruckLaneVersionReportExcelRequest.kt new file mode 100644 index 0000000..40949d1 --- /dev/null +++ b/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, +) + diff --git a/src/main/java/com/ffii/fpsms/modules/pickOrder/web/models/ParseRouteLanesExcelModels.kt b/src/main/java/com/ffii/fpsms/modules/pickOrder/web/models/ParseRouteLanesExcelModels.kt new file mode 100644 index 0000000..e830810 --- /dev/null +++ b/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, +) diff --git a/src/main/java/com/ffii/fpsms/modules/pickOrder/web/models/SaveTruckRequest.kt b/src/main/java/com/ffii/fpsms/modules/pickOrder/web/models/SaveTruckRequest.kt index b96e33c..7e59339 100644 --- a/src/main/java/com/ffii/fpsms/modules/pickOrder/web/models/SaveTruckRequest.kt +++ b/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, ) diff --git a/src/main/java/com/ffii/fpsms/modules/pickOrder/web/models/TruckLaneCombinationResponse.kt b/src/main/java/com/ffii/fpsms/modules/pickOrder/web/models/TruckLaneCombinationResponse.kt new file mode 100644 index 0000000..35e4e09 --- /dev/null +++ b/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, + ) +} diff --git a/src/main/java/com/ffii/fpsms/modules/pickOrder/web/models/TruckLaneVersionModels.kt b/src/main/java/com/ffii/fpsms/modules/pickOrder/web/models/TruckLaneVersionModels.kt new file mode 100644 index 0000000..7dfe507 --- /dev/null +++ b/src/main/java/com/ffii/fpsms/modules/pickOrder/web/models/TruckLaneVersionModels.kt @@ -0,0 +1,65 @@ +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, +) + +/** 物流主檔異動(版本區間內新增/修改;不依賴 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, + val logisticMasterChanges: List = emptyList(), +) + diff --git a/src/main/java/com/ffii/fpsms/modules/pickOrder/web/models/UpdateTruckLaneVersionNoteRequest.kt b/src/main/java/com/ffii/fpsms/modules/pickOrder/web/models/UpdateTruckLaneVersionNoteRequest.kt new file mode 100644 index 0000000..219c085 --- /dev/null +++ b/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, +) diff --git a/src/main/resources/db/changelog/changes/20260430_02_2fi/01_truck_lane_version_snapshot.sql b/src/main/resources/db/changelog/changes/20260430_02_2fi/01_truck_lane_version_snapshot.sql new file mode 100644 index 0000000..4571ba9 --- /dev/null +++ b/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; diff --git a/src/main/resources/db/changelog/changes/20260430_02_2fi/02_truck_lane_version_snapshot_patch.sql b/src/main/resources/db/changelog/changes/20260430_02_2fi/02_truck_lane_version_snapshot_patch.sql new file mode 100644 index 0000000..b79fb6c --- /dev/null +++ b/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; diff --git a/src/main/resources/db/changelog/changes/20260504_01_2fi/01_truck_add_logistic_id.sql b/src/main/resources/db/changelog/changes/20260504_01_2fi/01_truck_add_logistic_id.sql new file mode 100644 index 0000000..5b5e68f --- /dev/null +++ b/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`); diff --git a/src/main/resources/db/changelog/changes/20260505_01_2fi/01_truck_lane_version_drop_store_id.sql b/src/main/resources/db/changelog/changes/20260505_01_2fi/01_truck_lane_version_drop_store_id.sql new file mode 100644 index 0000000..aa20c43 --- /dev/null +++ b/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; diff --git a/src/main/resources/db/changelog/changes/20260507_01_2fi/01_truck_lane_version_line_add_logistic_id.sql b/src/main/resources/db/changelog/changes/20260507_01_2fi/01_truck_lane_version_line_add_logistic_id.sql new file mode 100644 index 0000000..072423e --- /dev/null +++ b/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; + From 1a6cb368978aee7cd029fd4a1f5a678c2306befb Mon Sep 17 00:00:00 2001 From: "CANCERYS\\kw093" Date: Mon, 18 May 2026 15:00:48 +0800 Subject: [PATCH 09/11] inventory search fix --- .../service/ProductProcessService.kt | 24 +++++++++---------- .../stock/entity/InventoryRepository.kt | 21 ++++++++++++---- 2 files changed, 28 insertions(+), 17 deletions(-) diff --git a/src/main/java/com/ffii/fpsms/modules/productProcess/service/ProductProcessService.kt b/src/main/java/com/ffii/fpsms/modules/productProcess/service/ProductProcessService.kt index c0724f5..49b5e69 100644 --- a/src/main/java/com/ffii/fpsms/modules/productProcess/service/ProductProcessService.kt +++ b/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 diff --git a/src/main/java/com/ffii/fpsms/modules/stock/entity/InventoryRepository.kt b/src/main/java/com/ffii/fpsms/modules/stock/entity/InventoryRepository.kt index e75cb39..a439b31 100644 --- a/src/main/java/com/ffii/fpsms/modules/stock/entity/InventoryRepository.kt +++ b/src/main/java/com/ffii/fpsms/modules/stock/entity/InventoryRepository.kt @@ -14,11 +14,22 @@ import java.util.Optional interface InventoryRepository: AbstractRepository { fun findInventoryInfoByDeletedIsFalse(): List - @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 @Query("SELECT i FROM Inventory i " + From e78971d7b27ef07e25655b71f7b02c7217d29fd9 Mon Sep 17 00:00:00 2001 From: "CANCERYS\\kw093" Date: Mon, 18 May 2026 18:11:44 +0800 Subject: [PATCH 10/11] job order auto cancel and delay --- .../fpsms/modules/common/SettingNames.java | 5 + .../scheduler/service/SchedulerService.kt | 44 ++++ .../scheduler/web/SchedulerController.kt | 5 + .../service/JobOrderPlanStartAutoService.kt | 243 ++++++++++++++++++ src/main/resources/application.yml | 4 + 5 files changed, 301 insertions(+) create mode 100644 src/main/java/com/ffii/fpsms/modules/jobOrder/service/JobOrderPlanStartAutoService.kt diff --git a/src/main/java/com/ffii/fpsms/modules/common/SettingNames.java b/src/main/java/com/ffii/fpsms/modules/common/SettingNames.java index e0fa020..e74855c 100644 --- a/src/main/java/com/ffii/fpsms/modules/common/SettingNames.java +++ b/src/main/java/com/ffii/fpsms/modules/common/SettingNames.java @@ -57,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 */ diff --git a/src/main/java/com/ffii/fpsms/modules/common/scheduler/service/SchedulerService.kt b/src/main/java/com/ffii/fpsms/modules/common/scheduler/service/SchedulerService.kt index beaabe3..55aab0d 100644 --- a/src/main/java/com/ffii/fpsms/modules/common/scheduler/service/SchedulerService.kt +++ b/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,11 +58,14 @@ 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) @@ -86,6 +91,8 @@ open class SchedulerService( var scheduledGrnCodeSync: ScheduledFuture<*>? = null var scheduledInventoryLotExpiry: ScheduledFuture<*>? = null + var scheduledJobOrderPlanStart: ScheduledFuture<*>? = null + //@Volatile //var scheduledRoughProd: ScheduledFuture<*>? = null @@ -175,6 +182,7 @@ open class SchedulerService( schedulePostCompletedDnGrn(); scheduleGrnCodeSync(); scheduleInventoryLotExpiry(); + scheduleJobOrderPlanStartAuto(); //scheduleRoughProd(); //scheduleDetailedProd(); } @@ -292,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) { diff --git a/src/main/java/com/ffii/fpsms/modules/common/scheduler/web/SchedulerController.kt b/src/main/java/com/ffii/fpsms/modules/common/scheduler/web/SchedulerController.kt index 5c5d49a..3b01c91 100644 --- a/src/main/java/com/ffii/fpsms/modules/common/scheduler/web/SchedulerController.kt +++ b/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" + } } \ No newline at end of file diff --git a/src/main/java/com/ffii/fpsms/modules/jobOrder/service/JobOrderPlanStartAutoService.kt b/src/main/java/com/ffii/fpsms/modules/jobOrder/service/JobOrderPlanStartAutoService.kt new file mode 100644 index 0000000..b1872c1 --- /dev/null +++ b/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, + 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): Map> { + if (jobOrderIds.isEmpty()) return emptyMap() + return pickOrderRepository + .findAllByJobOrder_IdInAndDeletedFalseOrderByJobOrder_IdAscCreatedDesc(jobOrderIds) + .groupBy { it.jobOrder?.id ?: -1L } + .filterKeys { it > 0L } + } + + private fun loadProductProcessesByJobOrderId(jobOrderIds: List): Map { + 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" + } +} diff --git a/src/main/resources/application.yml b/src/main/resources/application.yml index 9d4cb97..e8cd673 100644 --- a/src/main/resources/application.yml +++ b/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: From e9f1f48edb57d3696af3ffb23bc40d9644c8c44f Mon Sep 17 00:00:00 2001 From: tommy Date: Mon, 18 May 2026 18:53:58 +0800 Subject: [PATCH 11/11] routeboard --- .../fpsms/modules/pickOrder/service/TruckLaneVersionService.kt | 2 ++ .../com/ffii/fpsms/modules/pickOrder/service/TruckService.kt | 2 ++ .../modules/pickOrder/web/models/TruckLaneVersionModels.kt | 3 +++ 3 files changed, 7 insertions(+) diff --git a/src/main/java/com/ffii/fpsms/modules/pickOrder/service/TruckLaneVersionService.kt b/src/main/java/com/ffii/fpsms/modules/pickOrder/service/TruckLaneVersionService.kt index bd50d7f..41fc24b 100644 --- a/src/main/java/com/ffii/fpsms/modules/pickOrder/service/TruckLaneVersionService.kt +++ b/src/main/java/com/ffii/fpsms/modules/pickOrder/service/TruckLaneVersionService.kt @@ -145,6 +145,8 @@ open class TruckLaneVersionService( truckRowId = key, shopCode = b?.shopCode ?: a?.shopCode, changes = changes, + truckLanceCode = b?.truckLanceCode ?: a?.truckLanceCode, + remark = b?.remark ?: a?.remark, ) ) } diff --git a/src/main/java/com/ffii/fpsms/modules/pickOrder/service/TruckService.kt b/src/main/java/com/ffii/fpsms/modules/pickOrder/service/TruckService.kt index 0175ef4..5d60425 100644 --- a/src/main/java/com/ffii/fpsms/modules/pickOrder/service/TruckService.kt +++ b/src/main/java/com/ffii/fpsms/modules/pickOrder/service/TruckService.kt @@ -1102,6 +1102,8 @@ open class TruckService( truckRowId = key, shopCode = b?.shopCode ?: a?.shopCode, changes = changes, + truckLanceCode = b?.truckLanceCode ?: a?.truckLanceCode, + remark = b?.remark ?: a?.remark, ), ) } diff --git a/src/main/java/com/ffii/fpsms/modules/pickOrder/web/models/TruckLaneVersionModels.kt b/src/main/java/com/ffii/fpsms/modules/pickOrder/web/models/TruckLaneVersionModels.kt index 7dfe507..8f59864 100644 --- a/src/main/java/com/ffii/fpsms/modules/pickOrder/web/models/TruckLaneVersionModels.kt +++ b/src/main/java/com/ffii/fpsms/modules/pickOrder/web/models/TruckLaneVersionModels.kt @@ -45,6 +45,9 @@ data class TruckLaneVersionDiffLine( val truckRowId: Long, val shopCode: String?, val changes: List, + /** 快照中的車線代碼(優先 to 版,刪除列時 fallback from)— 供僅欄位異動時顯示車線 */ + val truckLanceCode: String? = null, + val remark: String? = null, ) /** 物流主檔異動(版本區間內新增/修改;不依賴 truck 列是否已指派) */