# Conflicts: # src/main/java/com/ffii/fpsms/modules/deliveryOrder/service/DoWorkbenchMainService.ktproduction
| @@ -37,4 +37,5 @@ out/ | |||||
| .vscode/ | .vscode/ | ||||
| package-lock.json | package-lock.json | ||||
| python/Bag3.spec | python/Bag3.spec | ||||
| python/dist/Bag3.exe | |||||
| python/dist | |||||
| @@ -16,6 +16,7 @@ Bag2 is kept as a separate legacy v2.x line; do not assume Bag2 matches Bag3. | |||||
| Run: python Bag3.py | Run: python Bag3.py | ||||
| """ | """ | ||||
| import errno | |||||
| import json | import json | ||||
| import os | import os | ||||
| import select | import select | ||||
| @@ -344,6 +345,25 @@ DATAFLEX_UI_PROGRESS_EVERY = max( | |||||
| DATAFLEX_SINGLE_TCP_JOB = _dataflex_bool_env( | DATAFLEX_SINGLE_TCP_JOB = _dataflex_bool_env( | ||||
| "FPSMS_DATAFLEX_SINGLE_TCP_JOB", False | "FPSMS_DATAFLEX_SINGLE_TCP_JOB", False | ||||
| ) | ) | ||||
| # Link-OS SGD: raw-ZPL job shows this host id instead of a generic name (e.g. "ZPL. EMULATION"). | |||||
| # Same id when the same job order is printed again. Disable: FPSMS_DATAFLEX_HOST_IDENTIFICATION_SGD=0 | |||||
| DATAFLEX_HOST_IDENTIFICATION_SGD = _dataflex_bool_env( | |||||
| "FPSMS_DATAFLEX_HOST_IDENTIFICATION_SGD", True | |||||
| ) | |||||
| # Bag ZPL size (dots). ^PW700 matched little content (mostly vertical ^A@R), so previews showed a wide strip with empty right margin. | |||||
| DATAFLEX_LABEL_PW = max( | |||||
| 280, | |||||
| _dataflex_int_env("FPSMS_DATAFLEX_LABEL_PW", 400), | |||||
| ) | |||||
| DATAFLEX_LABEL_LL = max( | |||||
| 200, | |||||
| _dataflex_int_env("FPSMS_DATAFLEX_LABEL_LL", 500), | |||||
| ) | |||||
| # Some Zebra/DataFlex units RST the socket on host half-close; Windows surfaces WinError 10054. | |||||
| # Set FPSMS_DATAFLEX_SKIP_SHUTDOWN_WR=1 to omit shutdown(SHUT_WR) and only close() (often avoids RST). | |||||
| DATAFLEX_SKIP_SHUTDOWN_WR = _dataflex_bool_env( | |||||
| "FPSMS_DATAFLEX_SKIP_SHUTDOWN_WR", False | |||||
| ) | |||||
| # Full recovery (~JR soft reset) — used by「打袋重設」only; longer delay for firmware | # Full recovery (~JR soft reset) — used by「打袋重設」only; longer delay for firmware | ||||
| DATAFLEX_POST_FULL_RECOVERY_DELAY_SEC = 1.2 | DATAFLEX_POST_FULL_RECOVERY_DELAY_SEC = 1.2 | ||||
| # Zebra ~RO only (used when FPSMS_DATAFLEX_NO_JR is set for full recovery) | # Zebra ~RO only (used when FPSMS_DATAFLEX_NO_JR is set for full recovery) | ||||
| @@ -364,12 +384,56 @@ def _zpl_escape(s: str) -> str: | |||||
| return s.replace("\\", "\\\\").replace("^", "\\^") | return s.replace("\\", "\\\\").replace("^", "\\^") | ||||
| def _dataflex_host_identification_sgd_prefix(job_order_id: Optional[int]) -> str: | |||||
| """ | |||||
| Optional ASCII prefix before ^XA: set zpl.host_identification so the printer lists the job | |||||
| under the job order id instead of a generic raw-ZPL label. | |||||
| """ | |||||
| if not DATAFLEX_HOST_IDENTIFICATION_SGD or job_order_id is None: | |||||
| return "" | |||||
| try: | |||||
| jid = str(int(job_order_id)) | |||||
| except (TypeError, ValueError): | |||||
| return "" | |||||
| if not jid.isdigit(): | |||||
| return "" | |||||
| return f'! U1 setvar "zpl.host_identification" "{jid}"\r\n' | |||||
| def _dataflex_zpl_bytes(zpl: str) -> bytes: | def _dataflex_zpl_bytes(zpl: str) -> bytes: | ||||
| """UTF-8 ZPL with one trailing CRLF so the printer sees a clear job boundary.""" | """UTF-8 ZPL with one trailing CRLF so the printer sees a clear job boundary.""" | ||||
| s = (zpl or "").rstrip("\r\n") | s = (zpl or "").rstrip("\r\n") | ||||
| return (s + "\r\n").encode("utf-8") | return (s + "\r\n").encode("utf-8") | ||||
| def _dataflex_is_benign_tcp_reset(err: BaseException) -> bool: | |||||
| """True when peer closed with RST/FIN in a way that is normal for raw printer TCP (Windows 10054).""" | |||||
| if isinstance(err, (BrokenPipeError, ConnectionResetError, ConnectionAbortedError)): | |||||
| return True | |||||
| if isinstance(err, OSError): | |||||
| if getattr(err, "winerror", None) == 10054: # WSAECONNRESET | |||||
| return True | |||||
| if err.errno in ( | |||||
| errno.ECONNRESET, | |||||
| errno.EPIPE, | |||||
| errno.ECONNABORTED, | |||||
| ): | |||||
| return True | |||||
| return False | |||||
| def _dataflex_shutdown_write_maybe(sock: socket.socket) -> None: | |||||
| """Half-close write side; ignore printer RST (common after ZPL on port 9100-style links).""" | |||||
| if DATAFLEX_SKIP_SHUTDOWN_WR: | |||||
| return | |||||
| try: | |||||
| sock.shutdown(socket.SHUT_WR) | |||||
| except OSError as e: | |||||
| if _dataflex_is_benign_tcp_reset(e): | |||||
| return | |||||
| raise | |||||
| def generate_zpl_dataflex( | def generate_zpl_dataflex( | ||||
| batch_no: str, | batch_no: str, | ||||
| item_code: str, | item_code: str, | ||||
| @@ -377,6 +441,7 @@ def generate_zpl_dataflex( | |||||
| item_id: Optional[int] = None, | item_id: Optional[int] = None, | ||||
| stock_in_line_id: Optional[int] = None, | stock_in_line_id: Optional[int] = None, | ||||
| lot_no: Optional[str] = None, | lot_no: Optional[str] = None, | ||||
| job_order_id: Optional[int] = None, | |||||
| font_regular: str = "E:STXihei.ttf", | font_regular: str = "E:STXihei.ttf", | ||||
| font_bold: str = "E:STXihei.ttf", | font_bold: str = "E:STXihei.ttf", | ||||
| ) -> str: | ) -> str: | ||||
| @@ -398,11 +463,12 @@ def generate_zpl_dataflex( | |||||
| qr_value = _zpl_escape(qr_payload) | qr_value = _zpl_escape(qr_payload) | ||||
| # Explicit ^PQ1: each ^XA…^XZ is exactly one bag. Avoids E1005 "over quantity" on some Zebra/DataFlex | # Explicit ^PQ1: each ^XA…^XZ is exactly one bag. Avoids E1005 "over quantity" on some Zebra/DataFlex | ||||
| # firmware when many labels are sent on one TCP session without a per-job quantity. | # firmware when many labels are sent on one TCP session without a per-job quantity. | ||||
| return f"""^XA | |||||
| host_id = _dataflex_host_identification_sgd_prefix(job_order_id) | |||||
| return host_id + f"""^XA | |||||
| ^PQ1,0,1,N | ^PQ1,0,1,N | ||||
| ^CI28 | ^CI28 | ||||
| ^PW700 | |||||
| ^LL500 | |||||
| ^PW{DATAFLEX_LABEL_PW} | |||||
| ^LL{DATAFLEX_LABEL_LL} | |||||
| ^PO N | ^PO N | ||||
| ^FO10,20 | ^FO10,20 | ||||
| ^BQN,2,4^FDQA,{qr_value}^FS | ^BQN,2,4^FDQA,{qr_value}^FS | ||||
| @@ -447,10 +513,7 @@ def send_dataflex_preprint_reset(ip: str, port: int, *, force: bool = False) -> | |||||
| sock.connect((ip, port)) | sock.connect((ip, port)) | ||||
| sock.sendall(DATAFLEX_PREPRINT_BYTES) | sock.sendall(DATAFLEX_PREPRINT_BYTES) | ||||
| time.sleep(DATAFLEX_POST_PREPRINT_DELAY_SEC) | time.sleep(DATAFLEX_POST_PREPRINT_DELAY_SEC) | ||||
| try: | |||||
| sock.shutdown(socket.SHUT_WR) | |||||
| except OSError: | |||||
| pass | |||||
| _dataflex_shutdown_write_maybe(sock) | |||||
| finally: | finally: | ||||
| sock.close() | sock.close() | ||||
| @@ -472,10 +535,7 @@ def send_dataflex_job_counter_reset(ip: str, port: int, *, force: bool = False) | |||||
| sock.connect((ip, port)) | sock.connect((ip, port)) | ||||
| sock.sendall(_dataflex_full_recovery_payload()) | sock.sendall(_dataflex_full_recovery_payload()) | ||||
| time.sleep(DATAFLEX_POST_FULL_RECOVERY_DELAY_SEC) | time.sleep(DATAFLEX_POST_FULL_RECOVERY_DELAY_SEC) | ||||
| try: | |||||
| sock.shutdown(socket.SHUT_WR) | |||||
| except OSError: | |||||
| pass | |||||
| _dataflex_shutdown_write_maybe(sock) | |||||
| finally: | finally: | ||||
| sock.close() | sock.close() | ||||
| @@ -527,10 +587,7 @@ def send_dataflex_reset_and_labels( | |||||
| time.sleep(DATAFLEX_POST_LABEL_SETTLE_SEC) | time.sleep(DATAFLEX_POST_LABEL_SETTLE_SEC) | ||||
| if i < copies - 1: | if i < copies - 1: | ||||
| time.sleep(delay_sec) | time.sleep(delay_sec) | ||||
| try: | |||||
| sock.shutdown(socket.SHUT_WR) | |||||
| except OSError: | |||||
| pass | |||||
| _dataflex_shutdown_write_maybe(sock) | |||||
| finally: | finally: | ||||
| sock.close() | sock.close() | ||||
| @@ -879,10 +936,7 @@ def send_zpl_to_dataflex(ip: str, port: int, zpl: str) -> None: | |||||
| sock.connect((ip, port)) | sock.connect((ip, port)) | ||||
| sock.sendall(_dataflex_zpl_bytes(zpl)) | sock.sendall(_dataflex_zpl_bytes(zpl)) | ||||
| time.sleep(DATAFLEX_POST_LABEL_SETTLE_SEC) | time.sleep(DATAFLEX_POST_LABEL_SETTLE_SEC) | ||||
| try: | |||||
| sock.shutdown(socket.SHUT_WR) | |||||
| except OSError: | |||||
| pass | |||||
| _dataflex_shutdown_write_maybe(sock) | |||||
| finally: | finally: | ||||
| sock.close() | sock.close() | ||||
| @@ -907,6 +961,10 @@ def query_dataflex_host_status(ip: str, port: int) -> str: | |||||
| data = sock.recv(4096) | data = sock.recv(4096) | ||||
| except socket.timeout: | except socket.timeout: | ||||
| break | break | ||||
| except OSError as ex: | |||||
| if _dataflex_is_benign_tcp_reset(ex): | |||||
| break | |||||
| raise | |||||
| if not data: | if not data: | ||||
| break | break | ||||
| chunks.append(data) | chunks.append(data) | ||||
| @@ -2451,6 +2509,7 @@ def main() -> None: | |||||
| item_id=item_id, | item_id=item_id, | ||||
| stock_in_line_id=stock_in_line_id, | stock_in_line_id=stock_in_line_id, | ||||
| lot_no=lot_no, | lot_no=lot_no, | ||||
| job_order_id=j.get("id"), | |||||
| ) | ) | ||||
| label_text = (lot_no or b).strip() | label_text = (lot_no or b).strip() | ||||
| if continuous: | if continuous: | ||||
| @@ -0,0 +1,48 @@ | |||||
| package com.ffii.fpsms.m18.entity | |||||
| import com.ffii.core.entity.BaseEntity | |||||
| import jakarta.persistence.Column | |||||
| import jakarta.persistence.Entity | |||||
| import jakarta.persistence.Table | |||||
| import jakarta.validation.constraints.NotNull | |||||
| /** | |||||
| * Audit log for FPSMS → M18 udfBomForShop sync (request / response bodies). | |||||
| */ | |||||
| @Entity | |||||
| @Table(name = "m18_bom_shop_sync_log") | |||||
| open class M18BomShopSyncLog : BaseEntity<Long>() { | |||||
| @NotNull | |||||
| @Column(name = "bom_id", nullable = false) | |||||
| open var bomId: Long? = null | |||||
| @Column(name = "finished_item_code", length = 100) | |||||
| open var finishedItemCode: String? = null | |||||
| @Column(name = "m18_header_code", length = 200) | |||||
| open var m18HeaderCode: String? = null | |||||
| @Column(name = "request_fingerprint", length = 64) | |||||
| open var requestFingerprint: String? = null | |||||
| @Column(name = "m18_record_id") | |||||
| open var m18RecordId: Long? = null | |||||
| @NotNull | |||||
| @Column(name = "m18_api_status", nullable = false) | |||||
| open var m18ApiStatus: Boolean = false | |||||
| @NotNull | |||||
| @Column(name = "synced", nullable = false) | |||||
| open var synced: Boolean = false | |||||
| @Column(name = "message", length = 4000) | |||||
| open var message: String? = null | |||||
| @Column(name = "request_json", columnDefinition = "LONGTEXT") | |||||
| open var requestJson: String? = null | |||||
| @Column(name = "response_json", columnDefinition = "LONGTEXT") | |||||
| open var responseJson: String? = null | |||||
| } | |||||
| @@ -0,0 +1,12 @@ | |||||
| package com.ffii.fpsms.m18.entity | |||||
| import com.ffii.core.support.AbstractRepository | |||||
| interface M18BomShopSyncLogRepository : AbstractRepository<M18BomShopSyncLog, Long> { | |||||
| fun findFirstByBomIdOrderByIdDesc(bomId: Long): M18BomShopSyncLog? | |||||
| fun findTop100ByBomIdOrderByIdDesc(bomId: Long): List<M18BomShopSyncLog> | |||||
| /** Successful M18 udfBomForShop saves only — used for `BOM{item}Vnnn` version allocation. */ | |||||
| fun findTop100ByBomIdAndSyncedIsTrueOrderByIdDesc(bomId: Long): List<M18BomShopSyncLog> | |||||
| } | |||||
| @@ -0,0 +1,96 @@ | |||||
| package com.ffii.fpsms.m18.model | |||||
| import com.fasterxml.jackson.annotation.JsonInclude | |||||
| import com.fasterxml.jackson.annotation.JsonProperty | |||||
| /** | |||||
| * M18 save payload for Shop BOM (udfBomForShop). | |||||
| * PUT /root/api/save/udfbomforshop?menuCode=udfbomforshop | |||||
| * | |||||
| * Same idea as GRN (`mainan` + `ant`): header and lines each wrapped as `{ "values": [ ... ] }`. | |||||
| * Root keys: **`udfbomforshop`** and **`udfproduct`** (same as M18 read [M18BomData]). | |||||
| * (Spelling is **udf**, not "uni".) | |||||
| */ | |||||
| @JsonInclude(JsonInclude.Include.NON_NULL) | |||||
| data class M18BomForShopSaveRequest( | |||||
| @JsonProperty("udfbomforshop") | |||||
| val udfbomforshop: M18MainUdfBomForShopWrapper, | |||||
| @JsonProperty("udfproduct") | |||||
| val udfproduct: M18UdfProductWrapper, | |||||
| ) | |||||
| @JsonInclude(JsonInclude.Include.NON_NULL) | |||||
| data class M18MainUdfBomForShopWrapper( | |||||
| val values: List<M18MainUdfBomForShopValue>, | |||||
| ) | |||||
| /** | |||||
| * Header row for udfBomForShop. Field names match M18 read/sample JSON. | |||||
| */ | |||||
| @JsonInclude(JsonInclude.Include.NON_NULL) | |||||
| data class M18MainUdfBomForShopValue( | |||||
| /** | |||||
| * Existing M18 udfBomForShop header id for **update** (same as FPSMS [Bom.m18Id] after first sync). | |||||
| * Omit or null for **create**. Sent as JSON string for M18 compatibility (like GRN mainan `id`). | |||||
| */ | |||||
| val id: String? = null, | |||||
| val code: String? = null, | |||||
| val beId: Int? = null, | |||||
| val desc: String? = null, | |||||
| @JsonProperty("desc_en") | |||||
| val descEn: String? = null, | |||||
| @JsonProperty("udfBOMCode") | |||||
| val udfBomCode: String? = null, | |||||
| val rev: String? = null, | |||||
| val udfUnit: Long? = null, | |||||
| /** Harvest qty: [Bom.outputQty] × pack multiple from header item stock UOM code (e.g. PACK2LB → ×2), else plain output qty. */ | |||||
| val udfHarvest: String? = null, | |||||
| /** Trailing unit letters from that code (e.g. LB); null if code not parsed. */ | |||||
| val udfHarvestUnit: String? = null, | |||||
| /** Epoch milliseconds (M18-style; same as read `lastModifyDate`). From FPSMS [com.ffii.core.entity.BaseEntity.created] in Asia/Hong_Kong. */ | |||||
| @JsonProperty("udfeffectivedate") | |||||
| val udfEffectiveDate: Long? = null, | |||||
| @JsonProperty("udfYieldratePP") | |||||
| val udfYieldratePP: Number? = null, | |||||
| val udftypeoffood: String? = null, | |||||
| @JsonProperty("udfconfirmed") | |||||
| val udfconfirmed: Boolean? = null, | |||||
| val staffId: Int? = null, | |||||
| val flowTypeId: Int? = null, | |||||
| val virDeptId: Int? = null, | |||||
| val status: String? = null, | |||||
| ) | |||||
| @JsonInclude(JsonInclude.Include.NON_NULL) | |||||
| data class M18UdfProductWrapper( | |||||
| val values: List<M18UdfProductSaveValue>, | |||||
| ) | |||||
| /** | |||||
| * Line payload for `udfproduct.values[]`. **`udfBaseUnit`** is the FPSMS UOM **code** for the line. | |||||
| * **`udfPackingUnit`** / **`udfPackingQty`** / **`udfproremark`** are not sent. | |||||
| */ | |||||
| @JsonInclude(JsonInclude.Include.NON_NULL) | |||||
| data class M18UdfProductSaveValue( | |||||
| /** Line id in M18 when updating */ | |||||
| val id: Long? = null, | |||||
| val udfqty: Number? = null, | |||||
| val udfProduct: Long? = null, | |||||
| val udfIngredients: String? = null, | |||||
| /** Line UOM: [com.ffii.fpsms.modules.master.entity.UomConversion.code] (same unit as [udfqty]). */ | |||||
| val udfBaseUnit: String? = null, | |||||
| /** PO supplier [com.ffii.fpsms.modules.master.entity.Shop.m18Id] (via `purchase_order.supplierId`) for latest PO line matching material [com.ffii.fpsms.modules.master.entity.Items.code]. */ | |||||
| val udfSupplier: Long? = null, | |||||
| /** | |||||
| * M18 UOM id for price/purchase unit on the **M18-linked** PO line (`m18DataLog` present): | |||||
| * [com.ffii.fpsms.modules.purchaseOrder.entity.PurchaseOrderLine.uomM18] (M18 `unitId`) then | |||||
| * [com.ffii.fpsms.modules.purchaseOrder.entity.PurchaseOrderLine.uom] → [com.ffii.fpsms.modules.master.entity.UomConversion.m18Id]. | |||||
| */ | |||||
| @JsonProperty("udfpurchaseUnit") | |||||
| val udfpurchaseUnit: Long? = null, | |||||
| /** Line sequence, e.g. " 1" */ | |||||
| val itemNo: String? = null, | |||||
| val udfoptions: String? = null, | |||||
| val udfoption: Number? = null, | |||||
| val udfYieldRate: Number? = null, | |||||
| ) | |||||
| @@ -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, | |||||
| ) | |||||
| @@ -0,0 +1,394 @@ | |||||
| package com.ffii.fpsms.m18.service | |||||
| import com.fasterxml.jackson.core.JsonGenerator | |||||
| import com.fasterxml.jackson.databind.ObjectMapper | |||||
| import com.fasterxml.jackson.module.kotlin.jacksonObjectMapper | |||||
| import com.ffii.fpsms.api.service.ApiCallerService | |||||
| import com.ffii.fpsms.m18.M18Config | |||||
| import com.ffii.fpsms.m18.entity.M18BomShopSyncLog | |||||
| import com.ffii.fpsms.m18.entity.M18BomShopSyncLogRepository | |||||
| import com.ffii.fpsms.m18.model.GoodsReceiptNoteResponse | |||||
| import com.ffii.fpsms.m18.model.M18BomForShopSaveRequest | |||||
| import com.ffii.fpsms.m18.model.M18MainUdfBomForShopValue | |||||
| import com.ffii.fpsms.m18.model.M18MainUdfBomForShopWrapper | |||||
| import com.ffii.fpsms.m18.model.M18UdfProductSaveValue | |||||
| import com.ffii.fpsms.m18.model.M18UdfProductWrapper | |||||
| import com.ffii.fpsms.modules.master.entity.Bom | |||||
| import com.ffii.fpsms.modules.master.entity.BomMaterial | |||||
| import com.ffii.fpsms.modules.master.service.ItemUomService | |||||
| import com.ffii.fpsms.modules.purchaseOrder.entity.PurchaseOrderLineRepository | |||||
| import org.slf4j.Logger | |||||
| import org.slf4j.LoggerFactory | |||||
| import org.springframework.data.domain.PageRequest | |||||
| import org.springframework.stereotype.Service | |||||
| import org.springframework.util.LinkedMultiValueMap | |||||
| import reactor.core.publisher.Mono | |||||
| import java.math.BigDecimal | |||||
| import java.math.RoundingMode | |||||
| import java.nio.charset.StandardCharsets | |||||
| import java.security.MessageDigest | |||||
| import java.time.ZoneId | |||||
| /** | |||||
| * Push FPSMS BOM + materials to M18 udfBomForShop (similar to GRN save/an). | |||||
| * PUT /root/api/save/udfbomforshop?menuCode=udfbomforshop | |||||
| */ | |||||
| @Service | |||||
| open class M18BomForShopService( | |||||
| private val m18Config: M18Config, | |||||
| private val apiCallerService: ApiCallerService, | |||||
| private val itemUomService: ItemUomService, | |||||
| private val purchaseOrderLineRepository: PurchaseOrderLineRepository, | |||||
| private val m18BomShopSyncLogRepository: M18BomShopSyncLogRepository, | |||||
| ) { | |||||
| private val logger: Logger = LoggerFactory.getLogger(M18BomForShopService::class.java) | |||||
| private val savePath = "/root/api/save/udfbomforshop" | |||||
| private val menuCode = "udfbomforshop" | |||||
| /** M18 business entity id for udfBomForShop header (`udfbomforshop.values[0].beId`). */ | |||||
| private val bomShopMainBeId: Int = 29 | |||||
| /** | |||||
| * Stock UOM `code` on the **BOM header item** (e.g. PACK2LB = prefix + pack multiple + unit suffix). | |||||
| * [udfHarvest] = [Bom.outputQty] × middle number; [udfHarvestUnit] = trailing unit (e.g. LB). | |||||
| */ | |||||
| private val bomItemStockUomPackCodeRegex = Regex("^([A-Za-z]+)(\\d+)([A-Za-z]+)$") | |||||
| companion object { | |||||
| private const val HARVEST_CALC_SCALE = 10 | |||||
| private val m18Tz: ZoneId = ZoneId.of("Asia/Hong_Kong") | |||||
| private fun formatBomShopHeaderCode(itemCode: String, version: Int): String = | |||||
| "BOM${itemCode}V${version.toString().padStart(3, '0')}" | |||||
| } | |||||
| @Suppress("DEPRECATION") | |||||
| private val objectMapper: ObjectMapper = jacksonObjectMapper().apply { | |||||
| disable(JsonGenerator.Feature.ESCAPE_NON_ASCII) | |||||
| } | |||||
| /** | |||||
| * Stable hash of payload **excluding** M18 header `id`, `code`, and `rev` (so version bumps do not affect equality). | |||||
| * Used with [M18BomShopSyncLog] to decide V000 vs V001+. | |||||
| */ | |||||
| open fun contentFingerprint(request: M18BomForShopSaveRequest): String { | |||||
| val json = objectMapper.writeValueAsString(normalizedForFingerprint(request)) | |||||
| return sha256Hex(json) | |||||
| } | |||||
| private fun normalizedForFingerprint(request: M18BomForShopSaveRequest): M18BomForShopSaveRequest { | |||||
| val v = request.udfbomforshop.values.firstOrNull() | |||||
| ?: return request | |||||
| val headerNorm = v.copy(id = null, code = null, rev = null) | |||||
| val linesSorted = request.udfproduct.values.sortedWith( | |||||
| compareBy({ it.itemNo }, { it.udfProduct }, { it.udfIngredients }), | |||||
| ) | |||||
| return M18BomForShopSaveRequest( | |||||
| udfbomforshop = M18MainUdfBomForShopWrapper(values = listOf(headerNorm)), | |||||
| udfproduct = M18UdfProductWrapper(values = linesSorted), | |||||
| ) | |||||
| } | |||||
| private fun sha256Hex(text: String): String { | |||||
| val md = MessageDigest.getInstance("SHA-256") | |||||
| val bytes = md.digest(text.toByteArray(StandardCharsets.UTF_8)) | |||||
| return bytes.joinToString("") { "%02x".format(it) } | |||||
| } | |||||
| /** | |||||
| * Builds M18 save body from a persisted BOM (materials loaded). | |||||
| * [headerM18IdOverride] optional M18 header record id when forcing update; skips version/fingerprint logic for **id** only, | |||||
| * reuses latest logged [M18BomShopSyncLog.m18HeaderCode] when possible. | |||||
| * Otherwise uses [Bom.m18Id] when the normalized payload matches the latest log; on content change, bumps `BOM{item}Vnnn`. | |||||
| */ | |||||
| open fun buildSaveRequest(bom: Bom, headerM18IdOverride: Long? = null): M18BomForShopSaveRequest? { | |||||
| val bomId = bom.id ?: return null | |||||
| val routingCode = bom.code ?: return null | |||||
| val itemCode = bom.item?.code?.trim().orEmpty().ifEmpty { | |||||
| logger.warn("[M18 BOM] bom.item.code missing; cannot build M18 BOM shop payload. bomId=$bomId") | |||||
| return null | |||||
| } | |||||
| val flowTypeId = resolveFlowTypeId(routingCode) | |||||
| val udfUnit = bom.uom?.m18Id?.takeIf { it > 0 } ?: return null | |||||
| val outputQty = bom.outputQty ?: BigDecimal.ZERO | |||||
| val (udfHarvest, udfHarvestUnit) = resolveUdfHarvestFields(bom, outputQty) | |||||
| val udfEffectiveDate = bom.created?.atZone(m18Tz)?.toInstant()?.toEpochMilli() | |||||
| val lines = bom.bomMaterials | |||||
| .filter { it.deleted != true } | |||||
| .sortedBy { it.id ?: 0L } | |||||
| .mapIndexedNotNull { idx, mat -> toProductLine(mat, idx + 1) } | |||||
| if (lines.isEmpty()) { | |||||
| logger.warn("[M18 BOM] BOM id=$bomId code=$routingCode has no materials; skipping M18 save") | |||||
| return null | |||||
| } | |||||
| val (headerCode, rev, headerM18IdForRequest) = resolveHeaderCodeAndM18Id( | |||||
| bomId = bomId, | |||||
| itemCode = itemCode, | |||||
| lines = lines, | |||||
| udfUnit = udfUnit, | |||||
| udfHarvest = udfHarvest, | |||||
| udfHarvestUnit = udfHarvestUnit, | |||||
| udfEffectiveDate = udfEffectiveDate, | |||||
| bomYield = bom.yield, | |||||
| bomName = bom.name, | |||||
| bomDescription = bom.description, | |||||
| flowTypeId = flowTypeId, | |||||
| headerM18IdOverride = headerM18IdOverride, | |||||
| bomM18Id = bom.m18Id?.takeIf { it > 0 }, | |||||
| ) | |||||
| val header = M18MainUdfBomForShopValue( | |||||
| id = headerM18IdForRequest?.toString(), | |||||
| code = headerCode, | |||||
| beId = bomShopMainBeId, | |||||
| desc = bom.name ?: bom.description, | |||||
| descEn = bom.name ?: bom.description, | |||||
| udfBomCode = itemCode, | |||||
| rev = rev, | |||||
| udfUnit = udfUnit, | |||||
| udfHarvest = udfHarvest, | |||||
| udfHarvestUnit = udfHarvestUnit, | |||||
| udfEffectiveDate = udfEffectiveDate, | |||||
| udfYieldratePP = bom.yield, | |||||
| udftypeoffood = "半成品", | |||||
| udfconfirmed = true, | |||||
| staffId = 232, | |||||
| flowTypeId = flowTypeId, | |||||
| virDeptId = 117, | |||||
| status = "Y", | |||||
| ) | |||||
| logger.info( | |||||
| "[M18 BOM] buildSaveRequest fpsmsBomId=$bomId routingCode=$routingCode itemCode=$itemCode headerCode=$headerCode " + | |||||
| "mainM18Id=$headerM18IdForRequest (override=$headerM18IdOverride, bom.m18Id=${bom.m18Id})", | |||||
| ) | |||||
| return M18BomForShopSaveRequest( | |||||
| udfbomforshop = M18MainUdfBomForShopWrapper(values = listOf(header)), | |||||
| udfproduct = M18UdfProductWrapper(values = lines), | |||||
| ) | |||||
| } | |||||
| @Suppress("LongParameterList") | |||||
| private fun resolveHeaderCodeAndM18Id( | |||||
| bomId: Long, | |||||
| itemCode: String, | |||||
| lines: List<M18UdfProductSaveValue>, | |||||
| udfUnit: Long, | |||||
| udfHarvest: String, | |||||
| udfHarvestUnit: String?, | |||||
| udfEffectiveDate: Long?, | |||||
| bomYield: BigDecimal?, | |||||
| bomName: String?, | |||||
| bomDescription: String?, | |||||
| flowTypeId: Int, | |||||
| headerM18IdOverride: Long?, | |||||
| bomM18Id: Long?, | |||||
| ): Triple<String, String?, Long?> { | |||||
| val draftHeader = M18MainUdfBomForShopValue( | |||||
| id = null, | |||||
| code = null, | |||||
| beId = bomShopMainBeId, | |||||
| desc = bomName ?: bomDescription, | |||||
| descEn = bomName ?: bomDescription, | |||||
| udfBomCode = itemCode, | |||||
| rev = null, | |||||
| udfUnit = udfUnit, | |||||
| udfHarvest = udfHarvest, | |||||
| udfHarvestUnit = udfHarvestUnit, | |||||
| udfEffectiveDate = udfEffectiveDate, | |||||
| udfYieldratePP = bomYield, | |||||
| udftypeoffood = "半成品", | |||||
| udfconfirmed = true, | |||||
| staffId = 232, | |||||
| flowTypeId = flowTypeId, | |||||
| virDeptId = 117, | |||||
| status = "Y", | |||||
| ) | |||||
| val draftRequest = M18BomForShopSaveRequest( | |||||
| udfbomforshop = M18MainUdfBomForShopWrapper(values = listOf(draftHeader)), | |||||
| udfproduct = M18UdfProductWrapper(values = lines), | |||||
| ) | |||||
| val fp = contentFingerprint(draftRequest) | |||||
| val forcedId = headerM18IdOverride?.takeIf { it > 0 } | |||||
| if (forcedId != null) { | |||||
| val latest = m18BomShopSyncLogRepository.findFirstByBomIdOrderByIdDesc(bomId) | |||||
| val codeForUpdate = | |||||
| latest?.m18HeaderCode?.takeIf { it.isNotBlank() } | |||||
| ?: formatBomShopHeaderCode(itemCode, 0) | |||||
| val forcedRev = parseTrailingVersion(codeForUpdate) ?: "000" | |||||
| return Triple(codeForUpdate, forcedRev, forcedId) | |||||
| } | |||||
| val latestLog = m18BomShopSyncLogRepository.findFirstByBomIdOrderByIdDesc(bomId) | |||||
| val prevFp = resolveLogFingerprint(latestLog) | |||||
| val prevCodeTrimmed = latestLog?.m18HeaderCode?.trim().orEmpty() | |||||
| val samePayload = latestLog != null && prevFp != null && prevFp == fp && prevCodeTrimmed.isNotEmpty() | |||||
| if (samePayload) { | |||||
| val revReuse = parseTrailingVersion(prevCodeTrimmed) ?: "000" | |||||
| return Triple(prevCodeTrimmed, revReuse, bomM18Id) | |||||
| } | |||||
| val maxV = maxVersionFromLogs(bomId, itemCode) | |||||
| val nextV = maxV + 1 | |||||
| val newCode = formatBomShopHeaderCode(itemCode, nextV) | |||||
| val rev = nextV.toString().padStart(3, '0') | |||||
| return Triple(newCode, rev, null) | |||||
| } | |||||
| private fun resolveLogFingerprint(log: M18BomShopSyncLog?): String? { | |||||
| if (log == null) return null | |||||
| log.requestFingerprint?.takeIf { it.isNotBlank() }?.let { return it } | |||||
| val json = log.requestJson ?: return null | |||||
| return runCatching { | |||||
| val prev = objectMapper.readValue(json, M18BomForShopSaveRequest::class.java) | |||||
| contentFingerprint(prev) | |||||
| }.getOrNull() | |||||
| } | |||||
| private fun maxVersionFromLogs(bomId: Long, itemCode: String): Int { | |||||
| val versionPat = Regex("^BOM${Regex.escape(itemCode)}V(\\d+)$") | |||||
| // Only successful syncs advance the numeric tail; failed attempts log a code but must not consume Vnnn. | |||||
| return m18BomShopSyncLogRepository.findTop100ByBomIdAndSyncedIsTrueOrderByIdDesc(bomId) | |||||
| .mapNotNull { row -> | |||||
| val c = row.m18HeaderCode?.trim().orEmpty().ifEmpty { | |||||
| extractHeaderCodeFromJson(row.requestJson).orEmpty() | |||||
| } | |||||
| versionPat.find(c)?.groupValues?.get(1)?.toIntOrNull() | |||||
| } | |||||
| .maxOrNull() ?: -1 | |||||
| } | |||||
| private fun extractHeaderCodeFromJson(json: String?): String? { | |||||
| if (json.isNullOrBlank()) return null | |||||
| return runCatching { | |||||
| val node = objectMapper.readTree(json) | |||||
| val text = node.path("udfbomforshop").path("values").path(0).path("code").asText() | |||||
| text.trim().takeIf { it.isNotEmpty() } | |||||
| }.getOrNull() | |||||
| } | |||||
| private fun parseTrailingVersion(headerCode: String): String? = | |||||
| Regex("V(\\d+)$").find(headerCode.trim())?.groupValues?.get(1)?.padStart(3, '0') | |||||
| /** | |||||
| * From the **finished-good** [Bom.item] stock unit [com.ffii.fpsms.modules.master.entity.UomConversion.code] | |||||
| * (pattern `LETTER_PREFIX` + `DIGITS` + `UNIT_SUFFIX`, e.g. PACK2LB): harvest qty = outputQty × digits, unit = suffix. | |||||
| * Falls back to plain [outputQty] and null unit when item/stock UOM/code is missing or does not match. | |||||
| */ | |||||
| private fun resolveUdfHarvestFields(bom: Bom, outputQty: BigDecimal): Pair<String, String?> { | |||||
| val itemId = bom.item?.id | |||||
| if (itemId == null) { | |||||
| logger.warn("[M18 BOM] bom.item id missing; udfHarvest=outputQty only. bomId=${bom.id}") | |||||
| return outputQty.stripTrailingZeros().toPlainString() to null | |||||
| } | |||||
| val stockCode = itemUomService.findStockUnitByItemId(itemId)?.uom?.code?.trim().orEmpty() | |||||
| if (stockCode.isEmpty()) { | |||||
| logger.warn("[M18 BOM] stock UOM code missing for bom itemId=$itemId; udfHarvest=outputQty only. bomId=${bom.id}") | |||||
| return outputQty.stripTrailingZeros().toPlainString() to null | |||||
| } | |||||
| val match = bomItemStockUomPackCodeRegex.matchEntire(stockCode) | |||||
| if (match == null) { | |||||
| logger.warn( | |||||
| "[M18 BOM] stock UOM code '$stockCode' does not match PREFIX+NUMBER+SUFFIX; " + | |||||
| "udfHarvest=outputQty only. bomId=${bom.id} itemId=$itemId", | |||||
| ) | |||||
| return outputQty.stripTrailingZeros().toPlainString() to null | |||||
| } | |||||
| val mult = match.groupValues[2].toBigDecimalOrNull() | |||||
| if (mult == null || mult.compareTo(BigDecimal.ZERO) <= 0) { | |||||
| logger.warn( | |||||
| "[M18 BOM] invalid pack multiple in stock UOM code '$stockCode'; udfHarvest=outputQty only. bomId=${bom.id}", | |||||
| ) | |||||
| return outputQty.stripTrailingZeros().toPlainString() to null | |||||
| } | |||||
| val unitSuffix = match.groupValues[3] | |||||
| val harvestQty = outputQty.multiply(mult).setScale(HARVEST_CALC_SCALE, RoundingMode.HALF_UP).stripTrailingZeros() | |||||
| return harvestQty.toPlainString() to unitSuffix | |||||
| } | |||||
| private fun toProductLine(mat: BomMaterial, lineNo: Int): M18UdfProductSaveValue? { | |||||
| val proId = mat.item?.m18Id?.takeIf { it > 0 } ?: run { | |||||
| logger.warn("[M18 BOM] material item m18Id missing bomMaterialId=${mat.id} itemId=${mat.item?.id}") | |||||
| return null | |||||
| } | |||||
| val udfBaseUnit = mat.uom?.code?.trim()?.takeIf { it.isNotEmpty() } ?: run { | |||||
| logger.warn("[M18 BOM] material UOM code missing bomMaterialId=${mat.id}") | |||||
| return null | |||||
| } | |||||
| val itemId = mat.item?.id | |||||
| val latestPoLine = itemId?.let { id -> | |||||
| purchaseOrderLineRepository.findLatestLinesForBomM18ByItemId(id, PageRequest.of(0, 1)).firstOrNull() | |||||
| } | |||||
| val itemCode = mat.item?.code?.trim()?.takeIf { it.isNotEmpty() } | |||||
| val supplierM18Id = itemCode?.let { code -> | |||||
| purchaseOrderLineRepository.findLatestPoSupplierM18IdByItemCodeNative(code) | |||||
| .firstOrNull() | |||||
| ?.takeIf { it > 0L } | |||||
| } | |||||
| /** | |||||
| * M18 line price unit id ([M18PurchaseOrderPot.unitId]): prefer [PurchaseOrderLine.uomM18] from M18 PO sync, | |||||
| * else [PurchaseOrderLine.uom] when uomM18 is missing. | |||||
| */ | |||||
| val purchaseUnitM18Id = | |||||
| latestPoLine?.uomM18?.m18Id?.takeIf { it > 0L } | |||||
| ?: latestPoLine?.uom?.m18Id?.takeIf { it > 0L } | |||||
| val udfqty = (mat.qty ?: BigDecimal.ZERO).setScale(8, RoundingMode.HALF_UP).toDouble() | |||||
| return M18UdfProductSaveValue( | |||||
| id = mat.m18Id?.takeIf { it > 0 }, | |||||
| udfqty = udfqty, | |||||
| udfProduct = proId, | |||||
| udfIngredients = mat.itemName ?: mat.item?.name, | |||||
| udfBaseUnit = udfBaseUnit, | |||||
| udfSupplier = supplierM18Id, | |||||
| udfpurchaseUnit = purchaseUnitM18Id, | |||||
| itemNo = String.format("%6d", lineNo), | |||||
| udfoptions = "", | |||||
| udfoption = 0.0, | |||||
| udfYieldRate = 0.0, | |||||
| ) | |||||
| } | |||||
| private fun resolveFlowTypeId(code: String): Int = when { | |||||
| code.startsWith("TOA") -> 1 | |||||
| code.startsWith("BOMPP") || code.startsWith("PP") -> 3 | |||||
| code.startsWith("BOMPF") || code.startsWith("PF") || code.startsWith("PFP") -> 2 | |||||
| else -> 1 | |||||
| } | |||||
| open fun toJson(request: M18BomForShopSaveRequest): String = | |||||
| objectMapper.writeValueAsString(request) | |||||
| open fun toJson(response: GoodsReceiptNoteResponse): String = | |||||
| objectMapper.writeValueAsString(response) | |||||
| open fun saveBomForShop(request: M18BomForShopSaveRequest): GoodsReceiptNoteResponse? = | |||||
| saveBomForShopMono(request).block() | |||||
| open fun saveBomForShopMono(request: M18BomForShopSaveRequest): Mono<GoodsReceiptNoteResponse> { | |||||
| val queryParams = LinkedMultiValueMap<String, String>().apply { | |||||
| add("menuCode", menuCode) | |||||
| } | |||||
| val qs = queryParams.entries.flatMap { (k, v) -> v.map { "$k=$it" } }.joinToString("&") | |||||
| val fullUrl = "${m18Config.BASE_URL}$savePath?$qs" | |||||
| val bodyJson = objectMapper.writeValueAsString(request) | |||||
| logger.info("[M18 BOM udfBomForShop] PUT url=$fullUrl bodyUtf8Bytes=${bodyJson.toByteArray(StandardCharsets.UTF_8).size}") | |||||
| logger.debug("[M18 BOM udfBomForShop] PUT body=$bodyJson") | |||||
| return apiCallerService.putWithJsonString<GoodsReceiptNoteResponse>( | |||||
| urlPath = savePath, | |||||
| queryParams = queryParams, | |||||
| bodyJson = bodyJson, | |||||
| ).doOnSuccess { r -> | |||||
| logger.info("[M18 BOM udfBomForShop] response status=${r.status} recordId=${r.recordId} messages=${r.messages}") | |||||
| }.doOnError { e -> | |||||
| logger.error("[M18 BOM udfBomForShop] failed: ${e.message}", e) | |||||
| } | |||||
| } | |||||
| } | |||||
| @@ -154,20 +154,20 @@ open class M18DeliveryOrderService( | |||||
| open fun saveDeliveryOrders(request: M18CommonRequest): SyncResult { | open fun saveDeliveryOrders(request: M18CommonRequest): SyncResult { | ||||
| val deliveryOrdersWithType = getDeliveryOrdersWithType(request) | val deliveryOrdersWithType = getDeliveryOrdersWithType(request) | ||||
| return saveDeliveryOrdersWithPreparedList(deliveryOrdersWithType, syncIsEtra = false) | |||||
| return saveDeliveryOrdersWithPreparedList(deliveryOrdersWithType, syncisExtra = false) | |||||
| } | } | ||||
| /** | /** | ||||
| * Sync a single M18 shop PO / delivery order by document [code], same search pattern as | * Sync a single M18 shop PO / delivery order by document [code], same search pattern as | ||||
| * [com.ffii.fpsms.m18.service.M18PurchaseOrderService.savePurchaseOrderByCode]. | * [com.ffii.fpsms.m18.service.M18PurchaseOrderService.savePurchaseOrderByCode]. | ||||
| * | * | ||||
| * @param isEtraSync when true, persist local `delivery_order.isEtra=true` (manual DO(加單) sync). | |||||
| * @param isExtraSync when true, persist local `delivery_order.isExtra=true` (manual DO(加單) sync). | |||||
| * No M18-side "加單" filtering is used. | * No M18-side "加單" filtering is used. | ||||
| * @param newOnly when true, skip if a non-deleted local DO already exists with the same `code`. | * @param newOnly when true, skip if a non-deleted local DO already exists with the same `code`. | ||||
| */ | */ | ||||
| open fun saveDeliveryOrderByCode( | open fun saveDeliveryOrderByCode( | ||||
| code: String, | code: String, | ||||
| isEtraSync: Boolean = false, | |||||
| isExtraSync: Boolean = false, | |||||
| newOnly: Boolean = false, | newOnly: Boolean = false, | ||||
| ): SyncResult { | ): SyncResult { | ||||
| if (newOnly && deliveryOrderRepository.existsByCodeAndDeletedIsFalse(code)) { | if (newOnly && deliveryOrderRepository.existsByCodeAndDeletedIsFalse(code)) { | ||||
| @@ -210,12 +210,12 @@ open class M18DeliveryOrderService( | |||||
| query = conds | query = conds | ||||
| ) | ) | ||||
| return saveDeliveryOrdersWithPreparedList(prepared, syncIsEtra = isEtraSync) | |||||
| return saveDeliveryOrdersWithPreparedList(prepared, syncisExtra = isExtraSync) | |||||
| } | } | ||||
| private fun saveDeliveryOrdersWithPreparedList( | private fun saveDeliveryOrdersWithPreparedList( | ||||
| deliveryOrdersWithType: M18PurchaseOrderListResponseWithType?, | deliveryOrdersWithType: M18PurchaseOrderListResponseWithType?, | ||||
| syncIsEtra: Boolean = false, | |||||
| syncisExtra: Boolean = false, | |||||
| ): SyncResult { | ): SyncResult { | ||||
| logger.info("--------------------------------------------Start - Saving M18 Delivery Order--------------------------------------------") | logger.info("--------------------------------------------Start - Saving M18 Delivery Order--------------------------------------------") | ||||
| @@ -303,7 +303,7 @@ open class M18DeliveryOrderService( | |||||
| handlerId = null, | handlerId = null, | ||||
| m18BeId = mainpo.beId, | m18BeId = mainpo.beId, | ||||
| deleted = mainpo.udfIsVoid == true, | deleted = mainpo.udfIsVoid == true, | ||||
| isEtra = syncIsEtra, | |||||
| isExtra = syncisExtra, | |||||
| ) | ) | ||||
| val saveDeliveryOrderResponse = | val saveDeliveryOrderResponse = | ||||
| @@ -4,8 +4,9 @@ import com.ffii.core.utils.JwtTokenUtil | |||||
| import com.ffii.fpsms.m18.M18Config | import com.ffii.fpsms.m18.M18Config | ||||
| import com.ffii.fpsms.m18.model.SyncResult | import com.ffii.fpsms.m18.model.SyncResult | ||||
| import com.ffii.fpsms.m18.service.* | import com.ffii.fpsms.m18.service.* | ||||
| import com.ffii.fpsms.m18.model.M18BomShopSyncTriggerResult | |||||
| import com.ffii.fpsms.m18.web.models.M18CommonRequest | import com.ffii.fpsms.m18.web.models.M18CommonRequest | ||||
| import com.ffii.fpsms.modules.common.SettingNames | |||||
| import com.ffii.fpsms.modules.master.service.BomService | |||||
| import com.ffii.fpsms.modules.common.scheduler.service.SchedulerService | import com.ffii.fpsms.modules.common.scheduler.service.SchedulerService | ||||
| import com.ffii.fpsms.modules.master.entity.ItemUom | import com.ffii.fpsms.modules.master.entity.ItemUom | ||||
| import com.ffii.fpsms.modules.master.entity.Items | import com.ffii.fpsms.modules.master.entity.Items | ||||
| @@ -35,6 +36,7 @@ class M18TestController ( | |||||
| private val m18DeliveryOrderService: M18DeliveryOrderService, | private val m18DeliveryOrderService: M18DeliveryOrderService, | ||||
| val schedulerService: SchedulerService, | val schedulerService: SchedulerService, | ||||
| private val settingsService: SettingsService, | private val settingsService: SettingsService, | ||||
| private val bomService: BomService, | |||||
| ) { | ) { | ||||
| var logger: Logger = LoggerFactory.getLogger(JwtTokenUtil::class.java) | var logger: Logger = LoggerFactory.getLogger(JwtTokenUtil::class.java) | ||||
| @@ -65,6 +67,14 @@ class M18TestController ( | |||||
| return schedulerService.getM18Pos(); | return schedulerService.getM18Pos(); | ||||
| } | } | ||||
| @PostMapping("/test/bom-shop-sync/{bomId}") | |||||
| fun testBomShopSync( | |||||
| @PathVariable bomId: Long, | |||||
| @RequestParam(required = false) m18HeaderId: Long?, | |||||
| ): M18BomShopSyncTriggerResult { | |||||
| return bomService.pushBomToM18ShopIfAllowed(bomId, m18HeaderId) | |||||
| } | |||||
| @GetMapping("/test/po-by-code") | @GetMapping("/test/po-by-code") | ||||
| fun testSyncPoByCode(@RequestParam code: String): SyncResult { | fun testSyncPoByCode(@RequestParam code: String): SyncResult { | ||||
| return m18PurchaseOrderService.savePurchaseOrderByCode(code) | return m18PurchaseOrderService.savePurchaseOrderByCode(code) | ||||
| @@ -72,14 +82,14 @@ class M18TestController ( | |||||
| @GetMapping("/test/do-by-code") | @GetMapping("/test/do-by-code") | ||||
| fun testSyncDoByCode(@RequestParam code: String): SyncResult { | fun testSyncDoByCode(@RequestParam code: String): SyncResult { | ||||
| return m18DeliveryOrderService.saveDeliveryOrderByCode(code, isEtraSync = false) | |||||
| return m18DeliveryOrderService.saveDeliveryOrderByCode(code, isExtraSync = false) | |||||
| } | } | ||||
| /** DO(加單):手動按 code 同步,並寫入本地 [DeliveryOrder.isEtra]=true(不做 M18 端加單條件過濾) */ | |||||
| /** DO(加單):手動按 code 同步,並寫入本地 [DeliveryOrder.isExtra]=true(不做 M18 端加單條件過濾) */ | |||||
| @GetMapping("/test/do-by-code-extra") | @GetMapping("/test/do-by-code-extra") | ||||
| fun testSyncDoByCodeExtra(@RequestParam code: String): SyncResult { | fun testSyncDoByCodeExtra(@RequestParam code: String): SyncResult { | ||||
| // 加單 tab: only sync when it's a NEW order (not existing in local system) | // 加單 tab: only sync when it's a NEW order (not existing in local system) | ||||
| return m18DeliveryOrderService.saveDeliveryOrderByCode(code, isEtraSync = true, newOnly = true) | |||||
| return m18DeliveryOrderService.saveDeliveryOrderByCode(code, isExtraSync = true, newOnly = true) | |||||
| } | } | ||||
| @GetMapping("/test/product-by-code") | @GetMapping("/test/product-by-code") | ||||
| @@ -29,7 +29,7 @@ open class BagService( | |||||
| ) { | ) { | ||||
| open fun createBagLotLinesByBagId(request: CreateBagLotLineRequest): MessageResponse { | open fun createBagLotLinesByBagId(request: CreateBagLotLineRequest): MessageResponse { | ||||
| val bag = bagRepository.findById(request.bagId).orElse(null) | val bag = bagRepository.findById(request.bagId).orElse(null) | ||||
| val lot = inventoryLotRepository.findByLotNoAndItemId(request.lotNo, request.itemId) | |||||
| val lot = inventoryLotRepository.findByIdAndDeletedFalse(request.lotId) | |||||
| val BaseUnitOfMeasure= itemUomRepository.findByItemIdAndStockUnitIsTrueAndDeletedIsFalse(request.itemId) | val BaseUnitOfMeasure= itemUomRepository.findByItemIdAndStockUnitIsTrueAndDeletedIsFalse(request.itemId) | ||||
| val baseRatioN = BaseUnitOfMeasure?.ratioN ?: BigDecimal.ONE | val baseRatioN = BaseUnitOfMeasure?.ratioN ?: BigDecimal.ONE | ||||
| println("baseRatioN: $baseRatioN") | println("baseRatioN: $baseRatioN") | ||||
| @@ -40,27 +40,28 @@ open class ChartService( | |||||
| /** | /** | ||||
| * Delivery orders: order count and total line qty by date. | * Delivery orders: order count and total line qty by date. | ||||
| * Uses delivery_order.completeDate or estimatedArrivalDate for date. | |||||
| * X-axis date: [delivery_order.estimatedArrivalDate] only (no completeDate/orderDate fallback). | |||||
| * Rows without estimatedArrivalDate are excluded. | |||||
| */ | */ | ||||
| fun getDeliveryOrderByDate(startDate: LocalDate?, endDate: LocalDate?): List<Map<String, Any>> { | fun getDeliveryOrderByDate(startDate: LocalDate?, endDate: LocalDate?): List<Map<String, Any>> { | ||||
| val args = mutableMapOf<String, Any>() | val args = mutableMapOf<String, Any>() | ||||
| val startSql = if (startDate != null) { | val startSql = if (startDate != null) { | ||||
| args["startDate"] = startDate.toString() | args["startDate"] = startDate.toString() | ||||
| "AND DATE(COALESCE(do.completeDate, do.estimatedArrivalDate, do.orderDate)) >= :startDate" | |||||
| "AND DATE(do.estimatedArrivalDate) >= :startDate" | |||||
| } else "" | } else "" | ||||
| val endSql = if (endDate != null) { | val endSql = if (endDate != null) { | ||||
| args["endDate"] = endDate.toString() | args["endDate"] = endDate.toString() | ||||
| "AND DATE(COALESCE(do.completeDate, do.estimatedArrivalDate, do.orderDate)) <= :endDate" | |||||
| "AND DATE(do.estimatedArrivalDate) <= :endDate" | |||||
| } else "" | } else "" | ||||
| val sql = """ | val sql = """ | ||||
| SELECT | SELECT | ||||
| DATE_FORMAT(COALESCE(do.completeDate, do.estimatedArrivalDate, do.orderDate), '%Y-%m-%d') AS date, | |||||
| DATE_FORMAT(do.estimatedArrivalDate, '%Y-%m-%d') AS date, | |||||
| COUNT(DISTINCT do.id) AS orderCount, | COUNT(DISTINCT do.id) AS orderCount, | ||||
| COALESCE(SUM(dol.qty), 0) AS totalQty | COALESCE(SUM(dol.qty), 0) AS totalQty | ||||
| FROM delivery_order do | FROM delivery_order do | ||||
| LEFT JOIN delivery_order_line dol ON dol.deliveryOrderId = do.id AND dol.deleted = 0 | LEFT JOIN delivery_order_line dol ON dol.deliveryOrderId = do.id AND dol.deleted = 0 | ||||
| WHERE do.deleted = 0 $startSql $endSql | |||||
| GROUP BY DATE(COALESCE(do.completeDate, do.estimatedArrivalDate, do.orderDate)) | |||||
| WHERE do.deleted = 0 AND do.estimatedArrivalDate IS NOT NULL $startSql $endSql | |||||
| GROUP BY DATE(do.estimatedArrivalDate) | |||||
| ORDER BY date | ORDER BY date | ||||
| """.trimIndent() | """.trimIndent() | ||||
| return jdbcDao.queryForList(sql, args) | return jdbcDao.queryForList(sql, args) | ||||
| @@ -529,17 +530,32 @@ open class ChartService( | |||||
| * Stock in vs stock out by date. | * Stock in vs stock out by date. | ||||
| * Stock in: stock_in_line.acceptedQty, date from stock_in.completeDate or receiptDate/created. | * Stock in: stock_in_line.acceptedQty, date from stock_in.completeDate or receiptDate/created. | ||||
| * Stock out: stock_out_line.qty, date from stock_out.completeDate or created. | * Stock out: stock_out_line.qty, date from stock_out.completeDate or created. | ||||
| * | |||||
| * Date range is applied inside each UNION branch (predicate pushdown) so we do not aggregate | |||||
| * all history before filtering. Reads filtered headers first via STRAIGHT_JOIN (si/so then lines). | |||||
| */ | */ | ||||
| fun getStockInOutByDate(startDate: LocalDate?, endDate: LocalDate?): List<Map<String, Any>> { | fun getStockInOutByDate(startDate: LocalDate?, endDate: LocalDate?): List<Map<String, Any>> { | ||||
| val args = mutableMapOf<String, Any>() | val args = mutableMapOf<String, Any>() | ||||
| val startSql = if (startDate != null) { | |||||
| args["startDate"] = startDate.toString() | |||||
| "AND u.dt >= :startDate" | |||||
| } else "" | |||||
| val endSql = if (endDate != null) { | |||||
| args["endDate"] = endDate.toString() | |||||
| "AND u.dt <= :endDate" | |||||
| } else "" | |||||
| if (startDate != null) args["startDate"] = startDate.toString() | |||||
| if (endDate != null) args["endDate"] = endDate.toString() | |||||
| val inDateFilter = buildString { | |||||
| if (startDate != null) { | |||||
| append(" AND DATE(COALESCE(si.completeDate, sil.receiptDate, si.created)) >= :startDate") | |||||
| } | |||||
| if (endDate != null) { | |||||
| append(" AND DATE(COALESCE(si.completeDate, sil.receiptDate, si.created)) <= :endDate") | |||||
| } | |||||
| } | |||||
| val outDateFilter = buildString { | |||||
| if (startDate != null) { | |||||
| append(" AND DATE(COALESCE(so.completeDate, so.created)) >= :startDate") | |||||
| } | |||||
| if (endDate != null) { | |||||
| append(" AND DATE(COALESCE(so.completeDate, so.created)) <= :endDate") | |||||
| } | |||||
| } | |||||
| val startSql = if (startDate != null) "AND u.dt >= :startDate" else "" | |||||
| val endSql = if (endDate != null) "AND u.dt <= :endDate" else "" | |||||
| val sql = """ | val sql = """ | ||||
| SELECT DATE_FORMAT(u.dt, '%Y-%m-%d') AS date, | SELECT DATE_FORMAT(u.dt, '%Y-%m-%d') AS date, | ||||
| COALESCE(SUM(u.inQty), 0) AS inQty, | COALESCE(SUM(u.inQty), 0) AS inQty, | ||||
| @@ -547,16 +563,16 @@ open class ChartService( | |||||
| FROM ( | FROM ( | ||||
| SELECT DATE(COALESCE(si.completeDate, sil.receiptDate, si.created)) AS dt, | SELECT DATE(COALESCE(si.completeDate, sil.receiptDate, si.created)) AS dt, | ||||
| SUM(COALESCE(sil.acceptedQty, 0)) AS inQty, 0 AS outQty | SUM(COALESCE(sil.acceptedQty, 0)) AS inQty, 0 AS outQty | ||||
| FROM stock_in_line sil | |||||
| INNER JOIN stock_in si ON sil.stockInId = si.id AND si.deleted = 0 | |||||
| WHERE sil.deleted = 0 | |||||
| FROM stock_in si | |||||
| STRAIGHT_JOIN stock_in_line sil ON sil.stockInId = si.id AND sil.deleted = 0 | |||||
| WHERE si.deleted = 0$inDateFilter | |||||
| GROUP BY DATE(COALESCE(si.completeDate, sil.receiptDate, si.created)) | GROUP BY DATE(COALESCE(si.completeDate, sil.receiptDate, si.created)) | ||||
| UNION ALL | UNION ALL | ||||
| SELECT DATE(COALESCE(so.completeDate, so.created)) AS dt, | SELECT DATE(COALESCE(so.completeDate, so.created)) AS dt, | ||||
| 0 AS inQty, SUM(COALESCE(sol.qty, 0)) AS outQty | 0 AS inQty, SUM(COALESCE(sol.qty, 0)) AS outQty | ||||
| FROM stock_out_line sol | |||||
| INNER JOIN stock_out so ON sol.stockOutId = so.id AND so.deleted = 0 | |||||
| WHERE sol.deleted = 0 | |||||
| FROM stock_out so | |||||
| STRAIGHT_JOIN stock_out_line sol ON sol.stockOutId = so.id AND sol.deleted = 0 | |||||
| WHERE so.deleted = 0$outDateFilter | |||||
| GROUP BY DATE(COALESCE(so.completeDate, so.created)) | GROUP BY DATE(COALESCE(so.completeDate, so.created)) | ||||
| ) u | ) u | ||||
| WHERE 1=1 $startSql $endSql | WHERE 1=1 $startSql $endSql | ||||
| @@ -568,23 +584,25 @@ open class ChartService( | |||||
| /** | /** | ||||
| * Distinct items that appear in delivery_order_line in the period (for multi-select options). | * Distinct items that appear in delivery_order_line in the period (for multi-select options). | ||||
| * Period filter: [delivery_order.estimatedArrivalDate] only; null ETA excluded. | |||||
| * Uses STRAIGHT_JOIN so MySQL reads filtered `delivery_order` first (avoids full scan on `delivery_order_line`). | |||||
| */ | */ | ||||
| fun getTopDeliveryItemsItemOptions(startDate: LocalDate?, endDate: LocalDate?): List<Map<String, Any>> { | fun getTopDeliveryItemsItemOptions(startDate: LocalDate?, endDate: LocalDate?): List<Map<String, Any>> { | ||||
| val args = mutableMapOf<String, Any>() | val args = mutableMapOf<String, Any>() | ||||
| val startSql = if (startDate != null) { | val startSql = if (startDate != null) { | ||||
| args["startDate"] = startDate.toString() | args["startDate"] = startDate.toString() | ||||
| "AND DATE(COALESCE(do.completeDate, do.estimatedArrivalDate, do.orderDate)) >= :startDate" | |||||
| "AND DATE(do.estimatedArrivalDate) >= :startDate" | |||||
| } else "" | } else "" | ||||
| val endSql = if (endDate != null) { | val endSql = if (endDate != null) { | ||||
| args["endDate"] = endDate.toString() | args["endDate"] = endDate.toString() | ||||
| "AND DATE(COALESCE(do.completeDate, do.estimatedArrivalDate, do.orderDate)) <= :endDate" | |||||
| "AND DATE(do.estimatedArrivalDate) <= :endDate" | |||||
| } else "" | } else "" | ||||
| val sql = """ | val sql = """ | ||||
| SELECT DISTINCT it.code AS itemCode, COALESCE(it.name, '') AS itemName | SELECT DISTINCT it.code AS itemCode, COALESCE(it.name, '') AS itemName | ||||
| FROM delivery_order_line dol | |||||
| INNER JOIN delivery_order do ON dol.deliveryOrderId = do.id AND do.deleted = 0 | |||||
| INNER JOIN items it ON dol.itemId = it.id AND it.deleted = 0 | |||||
| WHERE dol.deleted = 0 $startSql $endSql | |||||
| FROM delivery_order do | |||||
| STRAIGHT_JOIN delivery_order_line dol ON dol.deliveryOrderId = do.id AND dol.deleted = 0 | |||||
| STRAIGHT_JOIN items it ON it.id = dol.itemId AND it.deleted = 0 | |||||
| WHERE do.deleted = 0 AND do.estimatedArrivalDate IS NOT NULL $startSql $endSql | |||||
| ORDER BY it.code | ORDER BY it.code | ||||
| """.trimIndent() | """.trimIndent() | ||||
| return jdbcDao.queryForList(sql, args) | return jdbcDao.queryForList(sql, args) | ||||
| @@ -592,6 +610,8 @@ open class ChartService( | |||||
| /** | /** | ||||
| * Top delivery items by total qty in the period. When itemCodes is non-empty, only those items (still ordered by totalQty, limit applied). | * Top delivery items by total qty in the period. When itemCodes is non-empty, only those items (still ordered by totalQty, limit applied). | ||||
| * Period filter: [delivery_order.estimatedArrivalDate] only; null ETA excluded. | |||||
| * Uses STRAIGHT_JOIN so MySQL reads filtered `delivery_order` first (avoids full scan on `delivery_order_line`). | |||||
| */ | */ | ||||
| fun getTopDeliveryItems( | fun getTopDeliveryItems( | ||||
| startDate: LocalDate?, | startDate: LocalDate?, | ||||
| @@ -602,11 +622,11 @@ open class ChartService( | |||||
| val args = mutableMapOf<String, Any>("limit" to limit) | val args = mutableMapOf<String, Any>("limit" to limit) | ||||
| val startSql = if (startDate != null) { | val startSql = if (startDate != null) { | ||||
| args["startDate"] = startDate.toString() | args["startDate"] = startDate.toString() | ||||
| "AND DATE(COALESCE(do.completeDate, do.estimatedArrivalDate, do.orderDate)) >= :startDate" | |||||
| "AND DATE(do.estimatedArrivalDate) >= :startDate" | |||||
| } else "" | } else "" | ||||
| val endSql = if (endDate != null) { | val endSql = if (endDate != null) { | ||||
| args["endDate"] = endDate.toString() | args["endDate"] = endDate.toString() | ||||
| "AND DATE(COALESCE(do.completeDate, do.estimatedArrivalDate, do.orderDate)) <= :endDate" | |||||
| "AND DATE(do.estimatedArrivalDate) <= :endDate" | |||||
| } else "" | } else "" | ||||
| val itemSql = if (!itemCodes.isNullOrEmpty()) { | val itemSql = if (!itemCodes.isNullOrEmpty()) { | ||||
| val codes = itemCodes.map { it.trim() }.filter { it.isNotBlank() } | val codes = itemCodes.map { it.trim() }.filter { it.isNotBlank() } | ||||
| @@ -620,10 +640,10 @@ open class ChartService( | |||||
| it.code AS itemCode, | it.code AS itemCode, | ||||
| it.name AS itemName, | it.name AS itemName, | ||||
| SUM(COALESCE(dol.qty, 0)) AS totalQty | SUM(COALESCE(dol.qty, 0)) AS totalQty | ||||
| FROM delivery_order_line dol | |||||
| INNER JOIN delivery_order do ON dol.deliveryOrderId = do.id AND do.deleted = 0 | |||||
| INNER JOIN items it ON dol.itemId = it.id AND it.deleted = 0 | |||||
| WHERE dol.deleted = 0 $startSql $endSql $itemSql | |||||
| FROM delivery_order do | |||||
| STRAIGHT_JOIN delivery_order_line dol ON dol.deliveryOrderId = do.id AND dol.deleted = 0 | |||||
| STRAIGHT_JOIN items it ON it.id = dol.itemId AND it.deleted = 0 | |||||
| WHERE do.deleted = 0 AND do.estimatedArrivalDate IS NOT NULL $startSql $endSql $itemSql | |||||
| GROUP BY dol.itemId, it.code, it.name | GROUP BY dol.itemId, it.code, it.name | ||||
| ORDER BY totalQty DESC | ORDER BY totalQty DESC | ||||
| LIMIT :limit | LIMIT :limit | ||||
| @@ -721,23 +741,27 @@ open class ChartService( | |||||
| /** | /** | ||||
| * Staff delivery performance: daily pick ticket count and total time per staff. | * Staff delivery performance: daily pick ticket count and total time per staff. | ||||
| * Uses do_pick_order_record (handler = handledBy); time = sum of (ticketCompleteDateTime - ticketReleaseTime) per record. | |||||
| * Optionally use do_pick_order_line_record for line count; here orderCount = number of completed pick tickets. | |||||
| * Uses delivery_order_pick_order (handler = handledBy); time = sum of | |||||
| * (ticketCompleteDateTime - ticketReleaseTime) per completed ticket. | |||||
| * staffNos: when non-empty, filter to these staff by user.staffNo (multi-select). | * staffNos: when non-empty, filter to these staff by user.staffNo (multi-select). | ||||
| * storeIdNull: when true, only rows with dop.storeId IS NULL (takes precedence over storeId). | |||||
| * storeId: when non-blank and storeIdNull is not true, filter dop.storeId equality (trimmed). | |||||
| */ | */ | ||||
| fun getStaffDeliveryPerformance( | fun getStaffDeliveryPerformance( | ||||
| startDate: LocalDate?, | startDate: LocalDate?, | ||||
| endDate: LocalDate?, | endDate: LocalDate?, | ||||
| staffNos: List<String>? | |||||
| staffNos: List<String>?, | |||||
| storeId: String?, | |||||
| storeIdNull: Boolean?, | |||||
| ): List<Map<String, Any>> { | ): List<Map<String, Any>> { | ||||
| val args = mutableMapOf<String, Any>() | val args = mutableMapOf<String, Any>() | ||||
| val startSql = if (startDate != null) { | val startSql = if (startDate != null) { | ||||
| args["startDate"] = startDate.toString() | args["startDate"] = startDate.toString() | ||||
| "AND DATE(dpor.ticketCompleteDateTime) >= :startDate" | |||||
| "AND DATE(dop.ticketCompleteDateTime) >= :startDate" | |||||
| } else "" | } else "" | ||||
| val endSql = if (endDate != null) { | val endSql = if (endDate != null) { | ||||
| args["endDate"] = endDate.toString() | args["endDate"] = endDate.toString() | ||||
| "AND DATE(dpor.ticketCompleteDateTime) <= :endDate" | |||||
| "AND DATE(dop.ticketCompleteDateTime) <= :endDate" | |||||
| } else "" | } else "" | ||||
| val staffSql = if (!staffNos.isNullOrEmpty()) { | val staffSql = if (!staffNos.isNullOrEmpty()) { | ||||
| val nos = staffNos.map { it.trim() }.filter { it.isNotBlank() } | val nos = staffNos.map { it.trim() }.filter { it.isNotBlank() } | ||||
| @@ -746,25 +770,33 @@ open class ChartService( | |||||
| "AND u.staffNo IN (:staffNos)" | "AND u.staffNo IN (:staffNos)" | ||||
| } | } | ||||
| } else "" | } else "" | ||||
| val storeSql = when { | |||||
| storeIdNull == true -> "AND dop.storeId IS NULL" | |||||
| !storeId.isNullOrBlank() -> { | |||||
| args["filterStoreId"] = storeId.trim() | |||||
| "AND dop.storeId = :filterStoreId" | |||||
| } | |||||
| else -> "" | |||||
| } | |||||
| val sql = """ | val sql = """ | ||||
| SELECT | SELECT | ||||
| DATE_FORMAT(dpor.ticketCompleteDateTime, '%Y-%m-%d') AS date, | |||||
| COALESCE(u.name, dpor.handler_name, 'Unknown') AS staffName, | |||||
| COUNT(dpor.id) AS orderCount, | |||||
| DATE_FORMAT(dop.ticketCompleteDateTime, '%Y-%m-%d') AS date, | |||||
| COALESCE(NULLIF(TRIM(COALESCE(u.name, '')), ''), dop.handlerName, 'Unknown') AS staffName, | |||||
| COUNT(dop.id) AS orderCount, | |||||
| COALESCE(SUM( | COALESCE(SUM( | ||||
| CASE | CASE | ||||
| WHEN dpor.ticket_release_time IS NOT NULL AND dpor.ticketCompleteDateTime IS NOT NULL | |||||
| THEN GREATEST(0, TIMESTAMPDIFF(MINUTE, dpor.ticket_release_time, dpor.ticketCompleteDateTime)) | |||||
| WHEN dop.ticketReleaseTime IS NOT NULL AND dop.ticketCompleteDateTime IS NOT NULL | |||||
| THEN GREATEST(0, TIMESTAMPDIFF(MINUTE, dop.ticketReleaseTime, dop.ticketCompleteDateTime)) | |||||
| ELSE 0 | ELSE 0 | ||||
| END | END | ||||
| ), 0) AS totalMinutes | ), 0) AS totalMinutes | ||||
| FROM do_pick_order_record dpor | |||||
| LEFT JOIN user u ON dpor.handled_by = u.id AND u.deleted = 0 | |||||
| WHERE dpor.deleted = 0 | |||||
| AND dpor.ticket_status = 'completed' | |||||
| AND dpor.ticketCompleteDateTime IS NOT NULL | |||||
| $startSql $endSql $staffSql | |||||
| GROUP BY DATE(dpor.ticketCompleteDateTime), dpor.handled_by, u.name, dpor.handler_name | |||||
| FROM delivery_order_pick_order dop | |||||
| LEFT JOIN user u ON dop.handledBy = u.id AND u.deleted = 0 | |||||
| WHERE dop.deleted = 0 | |||||
| AND LOWER(COALESCE(dop.ticketStatus, '')) = 'completed' | |||||
| AND dop.ticketCompleteDateTime IS NOT NULL | |||||
| $startSql $endSql $staffSql $storeSql | |||||
| GROUP BY DATE(dop.ticketCompleteDateTime), dop.handledBy, u.name, dop.handlerName | |||||
| ORDER BY date, orderCount DESC | ORDER BY date, orderCount DESC | ||||
| """.trimIndent() | """.trimIndent() | ||||
| return jdbcDao.queryForList(sql, args) | return jdbcDao.queryForList(sql, args) | ||||
| @@ -26,7 +26,7 @@ class ChartController( | |||||
| /** | /** | ||||
| * GET /chart/delivery-order-by-date?startDate=&endDate= | * GET /chart/delivery-order-by-date?startDate=&endDate= | ||||
| * Returns [{ date, orderCount, totalQty }] | |||||
| * Returns [{ date, orderCount, totalQty }]. Date axis: delivery_order.estimatedArrivalDate only (null ETA excluded). | |||||
| */ | */ | ||||
| @GetMapping("/delivery-order-by-date") | @GetMapping("/delivery-order-by-date") | ||||
| fun getDeliveryOrderByDate( | fun getDeliveryOrderByDate( | ||||
| @@ -129,7 +129,7 @@ class ChartController( | |||||
| /** | /** | ||||
| * GET /chart/stock-in-out-by-date?startDate=&endDate= | * GET /chart/stock-in-out-by-date?startDate=&endDate= | ||||
| * Returns [{ date, inQty, outQty }] | |||||
| * Returns [{ date, inQty, outQty }]. Date range pushed into each UNION branch; si/so read before lines. | |||||
| */ | */ | ||||
| @GetMapping("/stock-in-out-by-date") | @GetMapping("/stock-in-out-by-date") | ||||
| fun getStockInOutByDate( | fun getStockInOutByDate( | ||||
| @@ -140,6 +140,7 @@ class ChartController( | |||||
| /** | /** | ||||
| * GET /chart/top-delivery-items-item-options?startDate=&endDate= | * GET /chart/top-delivery-items-item-options?startDate=&endDate= | ||||
| * Returns [{ itemCode, itemName }] — distinct items in delivery lines in the period (for multi-select). | * Returns [{ itemCode, itemName }] — distinct items in delivery lines in the period (for multi-select). | ||||
| * Period: delivery_order.estimatedArrivalDate only (null ETA excluded). | |||||
| */ | */ | ||||
| @GetMapping("/top-delivery-items-item-options") | @GetMapping("/top-delivery-items-item-options") | ||||
| fun getTopDeliveryItemsItemOptions( | fun getTopDeliveryItemsItemOptions( | ||||
| @@ -150,6 +151,7 @@ class ChartController( | |||||
| /** | /** | ||||
| * GET /chart/top-delivery-items?startDate=&endDate=&limit=20&itemCode=A&itemCode=B | * GET /chart/top-delivery-items?startDate=&endDate=&limit=20&itemCode=A&itemCode=B | ||||
| * Returns [{ itemCode, itemName, totalQty }]. When itemCode present, only those items (still by totalQty, limit). | * Returns [{ itemCode, itemName, totalQty }]. When itemCode present, only those items (still by totalQty, limit). | ||||
| * Period: delivery_order.estimatedArrivalDate only (null ETA excluded). | |||||
| */ | */ | ||||
| @GetMapping("/top-delivery-items") | @GetMapping("/top-delivery-items") | ||||
| fun getTopDeliveryItems( | fun getTopDeliveryItems( | ||||
| @@ -192,16 +194,20 @@ class ChartController( | |||||
| chartService.getStaffDeliveryPerformanceHandlers() | chartService.getStaffDeliveryPerformanceHandlers() | ||||
| /** | /** | ||||
| * GET /chart/staff-delivery-performance?startDate=&endDate=&staffNo=A001&staffNo=A002 | |||||
| * Returns [{ date, staffName, orderCount, totalMinutes }]. Data from do_pick_order_record (handled_by), orderCount = completed pick tickets, totalMinutes = sum(ticketCompleteDateTime - ticketReleaseTime). | |||||
| * GET /chart/staff-delivery-performance?startDate=&endDate=&staffNo=A001&staffNo=A002&storeId=2/F&storeIdNull=true | |||||
| * Returns [{ date, staffName, orderCount, totalMinutes }]. Data from delivery_order_pick_order | |||||
| * (handledBy), orderCount = completed pick tickets, totalMinutes = sum(ticketCompleteDateTime - ticketReleaseTime). | |||||
| * Optional storeId filters delivery_order_pick_order.storeId; storeIdNull=true means IS NULL (overrides storeId). | |||||
| */ | */ | ||||
| @GetMapping("/staff-delivery-performance") | @GetMapping("/staff-delivery-performance") | ||||
| fun getStaffDeliveryPerformance( | fun getStaffDeliveryPerformance( | ||||
| @RequestParam(required = false) @DateTimeFormat(iso = DateTimeFormat.ISO.DATE) startDate: LocalDate?, | @RequestParam(required = false) @DateTimeFormat(iso = DateTimeFormat.ISO.DATE) startDate: LocalDate?, | ||||
| @RequestParam(required = false) @DateTimeFormat(iso = DateTimeFormat.ISO.DATE) endDate: LocalDate?, | @RequestParam(required = false) @DateTimeFormat(iso = DateTimeFormat.ISO.DATE) endDate: LocalDate?, | ||||
| @RequestParam(required = false) staffNo: List<String>?, | @RequestParam(required = false) staffNo: List<String>?, | ||||
| @RequestParam(required = false) storeId: String?, | |||||
| @RequestParam(required = false) storeIdNull: Boolean?, | |||||
| ): List<Map<String, Any>> = | ): List<Map<String, Any>> = | ||||
| chartService.getStaffDeliveryPerformance(startDate, endDate, staffNo) | |||||
| chartService.getStaffDeliveryPerformance(startDate, endDate, staffNo, storeId, storeIdNull) | |||||
| // ---------- Job order reports ---------- | // ---------- Job order reports ---------- | ||||
| @@ -41,6 +41,11 @@ public abstract class SettingNames { | |||||
| */ | */ | ||||
| public static final String M18_UNITS_SYNC_INITIAL_FULL_SYNC_DONE = "M18.units.sync.initialFullSyncDone"; | public static final String M18_UNITS_SYNC_INITIAL_FULL_SYNC_DONE = "M18.units.sync.initialFullSyncDone"; | ||||
| /** | |||||
| * When "true", FPSMS may push BOM header + materials to M18 udfBomForShop. | |||||
| */ | |||||
| public static final String M18_BOM_SHOP_SYNC_ENABLED = "M18.bom.shop.sync.enabled"; | |||||
| /** Post completed DN and process M18 GRN (cron, e.g. "0 40 23 * * *" for 23:40 daily) */ | /** Post completed DN and process M18 GRN (cron, e.g. "0 40 23 * * *" for 23:40 daily) */ | ||||
| public static final String SCHEDULE_POST_COMPLETED_DN_GRN = "SCHEDULE.postCompletedDn.grn"; | public static final String SCHEDULE_POST_COMPLETED_DN_GRN = "SCHEDULE.postCompletedDn.grn"; | ||||
| @@ -52,6 +57,11 @@ public abstract class SettingNames { | |||||
| public static final String SCHEDULE_PROD_ROUGH = "SCHEDULE.prod.rough"; | public static final String SCHEDULE_PROD_ROUGH = "SCHEDULE.prod.rough"; | ||||
| public static final String SCHEDULE_PROD_DETAILED = "SCHEDULE.prod.detailed"; | public static final String SCHEDULE_PROD_DETAILED = "SCHEDULE.prod.detailed"; | ||||
| /** | |||||
| * Job order plan-start overdue batch (default 00:00:15 daily): hide or reschedule JOs whose plan day was yesterday. | |||||
| */ | |||||
| public static final String SCHEDULE_JO_PLAN_START = "SCHEDULE.jo.planStart"; | |||||
| /* | /* | ||||
| * Mail settings | * Mail settings | ||||
| */ | */ | ||||
| @@ -10,6 +10,7 @@ import com.ffii.fpsms.m18.entity.SchedulerSyncLog | |||||
| import com.ffii.fpsms.m18.entity.SchedulerSyncLogRepository | import com.ffii.fpsms.m18.entity.SchedulerSyncLogRepository | ||||
| import com.ffii.fpsms.m18.model.SyncResult | import com.ffii.fpsms.m18.model.SyncResult | ||||
| import com.ffii.fpsms.modules.common.SettingNames | import com.ffii.fpsms.modules.common.SettingNames | ||||
| import com.ffii.fpsms.modules.jobOrder.service.JobOrderPlanStartAutoService | |||||
| import com.ffii.fpsms.modules.master.service.ProductionScheduleService | import com.ffii.fpsms.modules.master.service.ProductionScheduleService | ||||
| import com.ffii.fpsms.modules.stock.service.SearchCompletedDnService | import com.ffii.fpsms.modules.stock.service.SearchCompletedDnService | ||||
| import com.ffii.fpsms.modules.stock.service.InventoryLotLineService | import com.ffii.fpsms.modules.stock.service.InventoryLotLineService | ||||
| @@ -42,6 +43,7 @@ open class SchedulerService( | |||||
| @Value("\${scheduler.inventoryLotExpiry.enabled:true}") val inventoryLotExpiryEnabled: Boolean, | @Value("\${scheduler.inventoryLotExpiry.enabled:true}") val inventoryLotExpiryEnabled: Boolean, | ||||
| /** When false (default), M18 PO / DO1 / DO2 / master-data cron jobs are not registered — use true in production only. */ | /** When false (default), M18 PO / DO1 / DO2 / master-data cron jobs are not registered — use true in production only. */ | ||||
| @Value("\${scheduler.m18Sync.enabled:false}") val m18SyncEnabled: Boolean, | @Value("\${scheduler.m18Sync.enabled:false}") val m18SyncEnabled: Boolean, | ||||
| @Value("\${scheduler.jo.planStart.enabled:true}") val jobOrderPlanStartAutoEnabled: Boolean, | |||||
| val settingsService: SettingsService, | val settingsService: SettingsService, | ||||
| /** | /** | ||||
| * Lookback window for GRN code sync: rows with `created` from **start of (today − N days)** through **now**, | * Lookback window for GRN code sync: rows with `created` from **start of (today − N days)** through **now**, | ||||
| @@ -56,7 +58,16 @@ open class SchedulerService( | |||||
| val searchCompletedDnService: SearchCompletedDnService, | val searchCompletedDnService: SearchCompletedDnService, | ||||
| val m18GrnCodeSyncService: M18GrnCodeSyncService, | val m18GrnCodeSyncService: M18GrnCodeSyncService, | ||||
| val inventoryLotLineService: InventoryLotLineService, | val inventoryLotLineService: InventoryLotLineService, | ||||
| val jobOrderPlanStartAutoService: JobOrderPlanStartAutoService, | |||||
| ) { | ) { | ||||
| companion object { | |||||
| /** DO2: Spring 6-field cron default and M18 `lastModifyDate` upper bound hour (1pm local). */ | |||||
| const val DO2_MODIFIED_TO_HOUR: Int = 13 | |||||
| const val DO2_DEFAULT_CRON: String = "0 0 13 * * *" | |||||
| /** Daily 00:00:15 — process job orders whose planStart was yesterday. */ | |||||
| const val JO_PLAN_START_DEFAULT_CRON: String = "15 0 0 * * *" | |||||
| } | |||||
| var logger: Logger = LoggerFactory.getLogger(JwtTokenUtil::class.java) | var logger: Logger = LoggerFactory.getLogger(JwtTokenUtil::class.java) | ||||
| val dataStringFormat = DateTimeFormatter.ofPattern("yyyy-MM-dd") | val dataStringFormat = DateTimeFormatter.ofPattern("yyyy-MM-dd") | ||||
| val dateTimeStringFormat = DateTimeFormatter.ofPattern("yyyy-MM-dd HH:mm:ss") | val dateTimeStringFormat = DateTimeFormatter.ofPattern("yyyy-MM-dd HH:mm:ss") | ||||
| @@ -80,6 +91,8 @@ open class SchedulerService( | |||||
| var scheduledGrnCodeSync: ScheduledFuture<*>? = null | var scheduledGrnCodeSync: ScheduledFuture<*>? = null | ||||
| var scheduledInventoryLotExpiry: ScheduledFuture<*>? = null | var scheduledInventoryLotExpiry: ScheduledFuture<*>? = null | ||||
| var scheduledJobOrderPlanStart: ScheduledFuture<*>? = null | |||||
| //@Volatile | //@Volatile | ||||
| //var scheduledRoughProd: ScheduledFuture<*>? = null | //var scheduledRoughProd: ScheduledFuture<*>? = null | ||||
| @@ -169,6 +182,7 @@ open class SchedulerService( | |||||
| schedulePostCompletedDnGrn(); | schedulePostCompletedDnGrn(); | ||||
| scheduleGrnCodeSync(); | scheduleGrnCodeSync(); | ||||
| scheduleInventoryLotExpiry(); | scheduleInventoryLotExpiry(); | ||||
| scheduleJobOrderPlanStartAuto(); | |||||
| //scheduleRoughProd(); | //scheduleRoughProd(); | ||||
| //scheduleDetailedProd(); | //scheduleDetailedProd(); | ||||
| } | } | ||||
| @@ -206,7 +220,7 @@ open class SchedulerService( | |||||
| logger.info("M18 DO2 scheduler disabled (scheduler.m18Sync.enabled=false)") | logger.info("M18 DO2 scheduler disabled (scheduler.m18Sync.enabled=false)") | ||||
| return | return | ||||
| } | } | ||||
| scheduledM18Do2 = commonSchedule(scheduledM18Do2, SettingNames.SCHEDULE_M18_DO2, ::getM18Dos2) | |||||
| scheduledM18Do2 = commonSchedule(scheduledM18Do2, SettingNames.SCHEDULE_M18_DO2, DO2_DEFAULT_CRON, ::getM18Dos2) | |||||
| } | } | ||||
| fun scheduleM18MasterData() { | fun scheduleM18MasterData() { | ||||
| @@ -286,6 +300,42 @@ open class SchedulerService( | |||||
| ) | ) | ||||
| } | } | ||||
| /** | |||||
| * Job order plan-start batch at 00:00:15 daily (yesterday plan day). | |||||
| * Set scheduler.jo.planStart.enabled=false to disable. | |||||
| */ | |||||
| fun scheduleJobOrderPlanStartAuto() { | |||||
| if (!jobOrderPlanStartAutoEnabled) { | |||||
| scheduledJobOrderPlanStart?.cancel(false) | |||||
| scheduledJobOrderPlanStart = null | |||||
| logger.info("Job order plan-start auto scheduler disabled (scheduler.jo.planStart.enabled=false)") | |||||
| return | |||||
| } | |||||
| scheduledJobOrderPlanStart = commonSchedule( | |||||
| scheduledJobOrderPlanStart, | |||||
| SettingNames.SCHEDULE_JO_PLAN_START, | |||||
| JO_PLAN_START_DEFAULT_CRON, | |||||
| ::runJobOrderPlanStartAuto, | |||||
| ) | |||||
| logger.info("Scheduled job order plan-start auto (default cron={})", JO_PLAN_START_DEFAULT_CRON) | |||||
| } | |||||
| open fun runJobOrderPlanStartAuto() { | |||||
| try { | |||||
| val report = jobOrderPlanStartAutoService.runAutoProcess(LocalDateTime.now()) | |||||
| logger.info( | |||||
| "Scheduler - Job order plan-start auto: candidates={}, hidden={}, rescheduled={}, skipped={}, errors={}", | |||||
| report.candidates, | |||||
| report.hidden, | |||||
| report.rescheduled, | |||||
| report.skipped, | |||||
| report.errors, | |||||
| ) | |||||
| } catch (e: Exception) { | |||||
| logger.error("Scheduler - Job order plan-start auto failed: ${e.message}", e) | |||||
| } | |||||
| } | |||||
| /** Mark expired inventory lot lines as unavailable daily. Set scheduler.inventoryLotExpiry.enabled=false to disable. */ | /** Mark expired inventory lot lines as unavailable daily. Set scheduler.inventoryLotExpiry.enabled=false to disable. */ | ||||
| fun scheduleInventoryLotExpiry() { | fun scheduleInventoryLotExpiry() { | ||||
| if (!inventoryLotExpiryEnabled) { | if (!inventoryLotExpiryEnabled) { | ||||
| @@ -455,7 +505,7 @@ open class SchedulerService( | |||||
| val ysd = today.minusDays(1L) | val ysd = today.minusDays(1L) | ||||
| val tmr = today.plusDays(1L) | val tmr = today.plusDays(1L) | ||||
| // Default: lastModified from yesterday 19:00 (aligns with nightly DO2 expectation). | |||||
| // Default: lastModified from yesterday 19:00 through today's DO2 run hour (1pm; aligns with SCHEDULE.m18.do2). | |||||
| // On Sunday, yesterday is Saturday: use 03:00 instead so we include DO changed after Sat 03:10 DO1 | // On Sunday, yesterday is Saturday: use 03:00 instead so we include DO changed after Sat 03:10 DO1 | ||||
| // (otherwise Sat 03:00–18:59 would be skipped until a much later sync). | // (otherwise Sat 03:00–18:59 would be skipped until a much later sync). | ||||
| val isSundayDo2 = runDate.dayOfWeek == DayOfWeek.SUNDAY | val isSundayDo2 = runDate.dayOfWeek == DayOfWeek.SUNDAY | ||||
| @@ -465,21 +515,21 @@ open class SchedulerService( | |||||
| ysd.withHour(19).withMinute(0).withSecond(0) | ysd.withHour(19).withMinute(0).withSecond(0) | ||||
| } | } | ||||
| // Set to 11:00:00 of today | |||||
| val todayEleven = today.withHour(11).withMinute(0).withSecond(0) | |||||
| val modifiedDateToEnd = | |||||
| today.withHour(DO2_MODIFIED_TO_HOUR).withMinute(0).withSecond(0) | |||||
| logger.info( | logger.info( | ||||
| "DO2 modifiedDateFrom={} ({}), modifiedDateTo={}", | "DO2 modifiedDateFrom={} ({}), modifiedDateTo={}", | ||||
| modifiedFromStart.format(dateTimeStringFormat), | modifiedFromStart.format(dateTimeStringFormat), | ||||
| if (isSundayDo2) "Sunday window from Sat 03:00" else "from yesterday 19:00", | if (isSundayDo2) "Sunday window from Sat 03:00" else "from yesterday 19:00", | ||||
| todayEleven.format(dateTimeStringFormat), | |||||
| modifiedDateToEnd.format(dateTimeStringFormat), | |||||
| ) | ) | ||||
| val requestDO = M18CommonRequest( | val requestDO = M18CommonRequest( | ||||
| // These will now produce "yyyy-MM-dd HH:mm:ss" | // These will now produce "yyyy-MM-dd HH:mm:ss" | ||||
| dDateTo = tmr.format(dateTimeStringFormat), // e.g. 2026-01-19 00:00:00 | dDateTo = tmr.format(dateTimeStringFormat), // e.g. 2026-01-19 00:00:00 | ||||
| dDateFrom = tmr.format(dateTimeStringFormat), // e.g. 2026-01-19 00:00:00 | dDateFrom = tmr.format(dateTimeStringFormat), // e.g. 2026-01-19 00:00:00 | ||||
| modifiedDateTo = todayEleven.format(dateTimeStringFormat), // 2026-01-18 11:00:00 | |||||
| modifiedDateTo = modifiedDateToEnd.format(dateTimeStringFormat), | |||||
| modifiedDateFrom = modifiedFromStart.format(dateTimeStringFormat), | modifiedDateFrom = modifiedFromStart.format(dateTimeStringFormat), | ||||
| ) | ) | ||||
| @@ -491,30 +541,6 @@ open class SchedulerService( | |||||
| result = result, | result = result, | ||||
| start = currentTime | start = currentTime | ||||
| ) | ) | ||||
| // Extra DO sync window: after DO2, also sync ETA = today or tomorrow (normal sync; does NOT set isEtra). | |||||
| try { | |||||
| val extraStart = LocalDateTime.now() | |||||
| val requestExtra = M18CommonRequest( | |||||
| dDateFrom = today.format(dateTimeStringFormat), | |||||
| dDateTo = tmr.format(dateTimeStringFormat), | |||||
| ) | |||||
| val extraResult = m18DeliveryOrderService.saveDeliveryOrders(requestExtra) | |||||
| saveSyncLog( | |||||
| type = "DO2_EXTRA", | |||||
| status = "SUCCESS", | |||||
| result = extraResult, | |||||
| start = extraStart, | |||||
| ) | |||||
| } catch (e: Exception) { | |||||
| logger.error("DO2_EXTRA sync failed: ${e.message}", e) | |||||
| saveSyncLog( | |||||
| type = "DO2_EXTRA", | |||||
| status = "FAIL", | |||||
| error = e.message, | |||||
| start = LocalDateTime.now(), | |||||
| ) | |||||
| } | |||||
| } | } | ||||
| open fun getPostCompletedDnAndProcessGrn( | open fun getPostCompletedDnAndProcessGrn( | ||||
| @@ -88,4 +88,9 @@ class SchedulerController( | |||||
| schedulerService.init() | schedulerService.init() | ||||
| return "Cron Schedules Refreshed from Database" | return "Cron Schedules Refreshed from Database" | ||||
| } | } | ||||
| @GetMapping("/trigger/jo-plan-start") | |||||
| fun triggerJoPlanStart(): String { | |||||
| schedulerService.runJobOrderPlanStartAuto() | |||||
| return "Job order plan-start auto triggered" | |||||
| } | |||||
| } | } | ||||
| @@ -64,6 +64,6 @@ open class DeliveryOrder: BaseEntity<Long>() { | |||||
| open var m18BeId: Long? = null | open var m18BeId: Long? = null | ||||
| /** 加單:由 M18「加單」專用同步標記;一般 DO 為 false */ | /** 加單:由 M18「加單」專用同步標記;一般 DO 為 false */ | ||||
| @Column(name = "isEtra", nullable = false) | |||||
| open var isEtra: Boolean = false | |||||
| @Column(name = "isExtra", nullable = false) | |||||
| open var isExtra: Boolean = false | |||||
| } | } | ||||
| @@ -111,7 +111,7 @@ fun searchDoLite( | |||||
| and (:status is null or d.status = :status) | and (:status is null or d.status = :status) | ||||
| and (:etaStart is null or d.estimatedArrivalDate >= :etaStart) | and (:etaStart is null or d.estimatedArrivalDate >= :etaStart) | ||||
| and (:etaEnd is null or d.estimatedArrivalDate < :etaEnd) | and (:etaEnd is null or d.estimatedArrivalDate < :etaEnd) | ||||
| and (:isEtra is null or d.isEtra = :isEtra) | |||||
| and (:isExtra is null or d.isExtra = :isExtra) | |||||
| order by d.id desc | order by d.id desc | ||||
| """) | """) | ||||
| fun searchDoLitePage( | fun searchDoLitePage( | ||||
| @@ -120,7 +120,7 @@ fun searchDoLitePage( | |||||
| @Param("status") status: DeliveryOrderStatus?, | @Param("status") status: DeliveryOrderStatus?, | ||||
| @Param("etaStart") etaStart: LocalDateTime?, | @Param("etaStart") etaStart: LocalDateTime?, | ||||
| @Param("etaEnd") etaEnd: LocalDateTime?, | @Param("etaEnd") etaEnd: LocalDateTime?, | ||||
| @Param("isEtra") isEtra: Boolean?, | |||||
| @Param("isExtra") isExtra: Boolean?, | |||||
| pageable: Pageable | pageable: Pageable | ||||
| ): Page<DeliveryOrderInfoLite> | ): Page<DeliveryOrderInfoLite> | ||||
| @@ -136,7 +136,7 @@ fun searchDoLitePage( | |||||
| and (:status is null or d.status = :status) | and (:status is null or d.status = :status) | ||||
| and (:etaStart is null or d.estimatedArrivalDate >= :etaStart) | and (:etaStart is null or d.estimatedArrivalDate >= :etaStart) | ||||
| and (:etaEnd is null or d.estimatedArrivalDate < :etaEnd) | and (:etaEnd is null or d.estimatedArrivalDate < :etaEnd) | ||||
| and (:isEtra is null or d.isEtra = :isEtra) | |||||
| and (:isExtra is null or d.isExtra = :isExtra) | |||||
| and d.supplier is not null | and d.supplier is not null | ||||
| and d.supplier.code in :allowedSupplierCodes | and d.supplier.code in :allowedSupplierCodes | ||||
| order by d.id desc | order by d.id desc | ||||
| @@ -148,7 +148,7 @@ fun searchDoLitePageWithSupplierCodes( | |||||
| @Param("status") status: DeliveryOrderStatus?, | @Param("status") status: DeliveryOrderStatus?, | ||||
| @Param("etaStart") etaStart: LocalDateTime?, | @Param("etaStart") etaStart: LocalDateTime?, | ||||
| @Param("etaEnd") etaEnd: LocalDateTime?, | @Param("etaEnd") etaEnd: LocalDateTime?, | ||||
| @Param("isEtra") isEtra: Boolean?, | |||||
| @Param("isExtra") isExtra: Boolean?, | |||||
| @Param("allowedSupplierCodes") allowedSupplierCodes: List<String>, | @Param("allowedSupplierCodes") allowedSupplierCodes: List<String>, | ||||
| pageable: Pageable, | pageable: Pageable, | ||||
| ): Page<DeliveryOrderInfoLite> | ): Page<DeliveryOrderInfoLite> | ||||
| @@ -48,8 +48,8 @@ interface DeliveryOrderInfoLite { | |||||
| @get:Value("#{target.shop?.addr3}") | @get:Value("#{target.shop?.addr3}") | ||||
| val shopAddress: String? | val shopAddress: String? | ||||
| @get:Value("#{target.isEtra}") | |||||
| val isEtra: Boolean | |||||
| @get:Value("#{target.isExtra}") | |||||
| val isExtra: Boolean | |||||
| } | } | ||||
| data class DeliveryOrderInfoLiteDto( | data class DeliveryOrderInfoLiteDto( | ||||
| val id: Long, | val id: Long, | ||||
| @@ -61,5 +61,5 @@ data class DeliveryOrderInfoLiteDto( | |||||
| val supplierName: String?, | val supplierName: String?, | ||||
| val shopAddress: String?, | val shopAddress: String?, | ||||
| val truckLanceCode: String?, | val truckLanceCode: String?, | ||||
| val isEtra: Boolean = false, | |||||
| val isExtra: Boolean = false, | |||||
| ) | ) | ||||
| @@ -90,7 +90,6 @@ import com.ffii.fpsms.modules.stock.entity.InventoryLotLine | |||||
| import com.ffii.fpsms.modules.stock.entity.projection.StockOutLineInfo | import com.ffii.fpsms.modules.stock.entity.projection.StockOutLineInfo | ||||
| import java.util.Locale | import java.util.Locale | ||||
| import org.slf4j.Logger | import org.slf4j.Logger | ||||
| @Service | @Service | ||||
| open class DeliveryOrderService( | open class DeliveryOrderService( | ||||
| private val deliveryOrderRepository: DeliveryOrderRepository, | private val deliveryOrderRepository: DeliveryOrderRepository, | ||||
| @@ -121,23 +120,23 @@ open class DeliveryOrderService( | |||||
| private val doPickOrderLineRepository: DoPickOrderLineRepository, | private val doPickOrderLineRepository: DoPickOrderLineRepository, | ||||
| private val doPickOrderLineRecordRepository: DoPickOrderLineRecordRepository, | private val doPickOrderLineRecordRepository: DoPickOrderLineRecordRepository, | ||||
| private val itemsRepository: ItemsRepository, | private val itemsRepository: ItemsRepository, | ||||
| private val doFloorSupplierSettingsService: DoFloorSupplierSettingsService, | |||||
| ) { | ) { | ||||
| /** | /** | ||||
| * 樓層篩選:2F → P07/P06D(車線- 族);4F → P06B(P06B_ 族);全部 → 三者。 | |||||
| * 車線-X 仍屬該 DO 的 supplier,故 P06B+車線-X 不會出現在 2F,P06D+車線-X 不會出現在 4F。 | |||||
| * 樓層篩選:2F/4F/ALL 由 [DoFloorSupplierSettingsService] 讀 `settings`。 | |||||
| * 車線-X 仍依 DO supplier 所屬樓層出現在對應 tab。 | |||||
| */ | */ | ||||
| private fun allowedSupplierCodesForFloor(floor: String?): List<String> { | |||||
| val f = floor?.trim()?.uppercase(Locale.ROOT).orEmpty() | |||||
| if (f.isEmpty() || f == "ALL" || f == "All") { | |||||
| return listOf("P06B", "P07", "P06D") | |||||
| } | |||||
| return when (f) { | |||||
| "2F" -> listOf("P07", "P06D") | |||||
| "4F" -> listOf("P06B") | |||||
| else -> listOf("P06B", "P07", "P06D") | |||||
| } | |||||
| } | |||||
| private fun allowedSupplierCodesForFloor(floor: String?): List<String> = | |||||
| doFloorSupplierSettingsService.allowedSupplierCodesForFloor(floor) | |||||
| private fun loadDoFloorSupplierLists(): Pair<List<String>, List<String>> = | |||||
| doFloorSupplierSettingsService.loadDoFloorSupplierLists() | |||||
| private fun preferredStoreFloorForSupplier( | |||||
| supplierCode: String?, | |||||
| suppliers2F: List<String>, | |||||
| suppliers4F: List<String>, | |||||
| ): String = doFloorSupplierSettingsService.preferredStoreFloorForSupplier(supplierCode, suppliers2F, suppliers4F) | |||||
| open fun searchDoLiteByPage( | open fun searchDoLiteByPage( | ||||
| code: String?, | code: String?, | ||||
| shopName: String?, | shopName: String?, | ||||
| @@ -147,7 +146,7 @@ open class DeliveryOrderService( | |||||
| pageSize: Int?, | pageSize: Int?, | ||||
| truckLanceCode: String?, | truckLanceCode: String?, | ||||
| floor: String? = null, | floor: String? = null, | ||||
| isEtra: Boolean? = null, | |||||
| isExtra: Boolean? = null, | |||||
| ): RecordsRes<DeliveryOrderInfoLiteDto> { | ): RecordsRes<DeliveryOrderInfoLiteDto> { | ||||
| val page = (pageNum ?: 1) - 1 | val page = (pageNum ?: 1) - 1 | ||||
| @@ -169,7 +168,7 @@ open class DeliveryOrderService( | |||||
| status = statusEnum, | status = statusEnum, | ||||
| etaStart = etaStart, | etaStart = etaStart, | ||||
| etaEnd = etaEnd, | etaEnd = etaEnd, | ||||
| isEtra = isEtra, | |||||
| isExtra = isExtra, | |||||
| allowedSupplierCodes = allowedForFloor, | allowedSupplierCodes = allowedForFloor, | ||||
| pageable = PageRequest.of(0, 100_000), | pageable = PageRequest.of(0, 100_000), | ||||
| ) | ) | ||||
| @@ -181,6 +180,7 @@ open class DeliveryOrderService( | |||||
| .associateBy { it.id } | .associateBy { it.id } | ||||
| val preFilteredContent = allResult.content | val preFilteredContent = allResult.content | ||||
| val (floorSuppliers2F, floorSuppliers4F) = loadDoFloorSupplierLists() | |||||
| // ✅ 优化3: 收集所有需要查询的 shopId 和日期组合(只处理预过滤后的记录) | // ✅ 优化3: 收集所有需要查询的 shopId 和日期组合(只处理预过滤后的记录) | ||||
| val shopIdAndDatePairs = preFilteredContent.mapNotNull { info -> | val shopIdAndDatePairs = preFilteredContent.mapNotNull { info -> | ||||
| @@ -191,11 +191,7 @@ open class DeliveryOrderService( | |||||
| val targetDate = estimatedArrivalDate.toLocalDate() | val targetDate = estimatedArrivalDate.toLocalDate() | ||||
| val dayAbbr = getDayOfWeekAbbr(targetDate) | val dayAbbr = getDayOfWeekAbbr(targetDate) | ||||
| val supplierCode = deliveryOrder.supplier?.code | val supplierCode = deliveryOrder.supplier?.code | ||||
| val preferredFloor = when (supplierCode) { | |||||
| "P06B" -> "4F" | |||||
| "P07", "P06D" -> "2F" | |||||
| else -> "2F" // 或者改成 null / 其他默认值,看你业务需要 | |||||
| } | |||||
| val preferredFloor = preferredStoreFloorForSupplier(supplierCode, floorSuppliers2F, floorSuppliers4F) | |||||
| Triple(shopId, preferredFloor, dayAbbr) | Triple(shopId, preferredFloor, dayAbbr) | ||||
| } else { | } else { | ||||
| null | null | ||||
| @@ -217,11 +213,7 @@ open class DeliveryOrderService( | |||||
| val processedRecords = preFilteredContent.map { info -> | val processedRecords = preFilteredContent.map { info -> | ||||
| val deliveryOrder = deliveryOrdersMap[info.id] | val deliveryOrder = deliveryOrdersMap[info.id] | ||||
| val supplierCode = deliveryOrder?.supplier?.code | val supplierCode = deliveryOrder?.supplier?.code | ||||
| val preferredFloor = when (supplierCode) { | |||||
| "P06B" -> "4F" | |||||
| "P07", "P06D" -> "2F" | |||||
| else -> "2F" | |||||
| } | |||||
| val preferredFloor = preferredStoreFloorForSupplier(supplierCode, floorSuppliers2F, floorSuppliers4F) | |||||
| val shop = deliveryOrder?.shop | val shop = deliveryOrder?.shop | ||||
| val shopId = shop?.id | val shopId = shop?.id | ||||
| val estimatedArrivalDate = info.estimatedArrivalDate | val estimatedArrivalDate = info.estimatedArrivalDate | ||||
| @@ -248,7 +240,7 @@ open class DeliveryOrderService( | |||||
| supplierName = info.supplierName, | supplierName = info.supplierName, | ||||
| shopAddress = info.shopAddress, | shopAddress = info.shopAddress, | ||||
| truckLanceCode = calculatedTruckLanceCode, | truckLanceCode = calculatedTruckLanceCode, | ||||
| isEtra = deliveryOrdersMap[info.id]?.isEtra ?: info.isEtra, | |||||
| isExtra = deliveryOrdersMap[info.id]?.isExtra ?: info.isExtra, | |||||
| ) | ) | ||||
| }.filter { dto -> | }.filter { dto -> | ||||
| val dtoTruckLanceCode = dto.truckLanceCode?.lowercase() ?: "" | val dtoTruckLanceCode = dto.truckLanceCode?.lowercase() ?: "" | ||||
| @@ -279,19 +271,16 @@ open class DeliveryOrderService( | |||||
| status = statusEnum, | status = statusEnum, | ||||
| etaStart = etaStart, | etaStart = etaStart, | ||||
| etaEnd = etaEnd, | etaEnd = etaEnd, | ||||
| isEtra = isEtra, | |||||
| isExtra = isExtra, | |||||
| allowedSupplierCodes = allowedSupplierCodes, | allowedSupplierCodes = allowedSupplierCodes, | ||||
| pageable = PageRequest.of(page.coerceAtLeast(0), size), | pageable = PageRequest.of(page.coerceAtLeast(0), size), | ||||
| ) | ) | ||||
| val (floorSuppliers2F, floorSuppliers4F) = loadDoFloorSupplierLists() | |||||
| val records = result.content.map { info -> | val records = result.content.map { info -> | ||||
| val deliveryOrder = deliveryOrderRepository.findByIdAndDeletedIsFalse(info.id) | val deliveryOrder = deliveryOrderRepository.findByIdAndDeletedIsFalse(info.id) | ||||
| val supplierCode = deliveryOrder?.supplier?.code | val supplierCode = deliveryOrder?.supplier?.code | ||||
| val preferredFloor = when (supplierCode) { | |||||
| "P06B" -> "4F" | |||||
| "P07", "P06D" -> "2F" | |||||
| else -> "2F" | |||||
| } | |||||
| val preferredFloor = preferredStoreFloorForSupplier(supplierCode, floorSuppliers2F, floorSuppliers4F) | |||||
| val shop = deliveryOrder?.shop | val shop = deliveryOrder?.shop | ||||
| val shopId = shop?.id | val shopId = shop?.id | ||||
| val estimatedArrivalDate = info.estimatedArrivalDate | val estimatedArrivalDate = info.estimatedArrivalDate | ||||
| @@ -315,7 +304,7 @@ open class DeliveryOrderService( | |||||
| supplierName = info.supplierName, | supplierName = info.supplierName, | ||||
| shopAddress = info.shopAddress, | shopAddress = info.shopAddress, | ||||
| truckLanceCode = calculatedTruckLanceCode, | truckLanceCode = calculatedTruckLanceCode, | ||||
| isEtra = deliveryOrder?.isEtra ?: info.isEtra, | |||||
| isExtra = deliveryOrder?.isExtra ?: info.isExtra, | |||||
| ) | ) | ||||
| } | } | ||||
| @@ -338,7 +327,7 @@ open class DeliveryOrderService( | |||||
| pageSize: Int?, | pageSize: Int?, | ||||
| truckLanceCode: String?, | truckLanceCode: String?, | ||||
| floor: String? = null, | floor: String? = null, | ||||
| isEtra: Boolean? = null, | |||||
| isExtra: Boolean? = null, | |||||
| ): RecordsRes<DeliveryOrderInfoLiteDto> { | ): RecordsRes<DeliveryOrderInfoLiteDto> { | ||||
| val mode = TruckLaneSearchSpec.parse(truckLanceCode) | val mode = TruckLaneSearchSpec.parse(truckLanceCode) | ||||
| if (mode is TruckLaneSearchSpec.Mode.NoFilter) { | if (mode is TruckLaneSearchSpec.Mode.NoFilter) { | ||||
| @@ -351,7 +340,7 @@ open class DeliveryOrderService( | |||||
| pageSize, | pageSize, | ||||
| null, | null, | ||||
| floor, | floor, | ||||
| isEtra, | |||||
| isExtra, | |||||
| ) | ) | ||||
| } | } | ||||
| val pageIdx = (pageNum ?: 1).coerceAtLeast(1) - 1 | val pageIdx = (pageNum ?: 1).coerceAtLeast(1) - 1 | ||||
| @@ -367,7 +356,7 @@ open class DeliveryOrderService( | |||||
| statusEnum = statusEnum, | statusEnum = statusEnum, | ||||
| etaStart = etaStart, | etaStart = etaStart, | ||||
| etaEnd = etaEnd, | etaEnd = etaEnd, | ||||
| isEtra = isEtra, | |||||
| isExtra = isExtra, | |||||
| allowedSupplierCodes = allowedSupplierCodesForFloor(floor), | allowedSupplierCodes = allowedSupplierCodesForFloor(floor), | ||||
| lanePredicate = lanePredicate, | lanePredicate = lanePredicate, | ||||
| ) | ) | ||||
| @@ -391,7 +380,7 @@ open class DeliveryOrderService( | |||||
| pageNum: Int?, | pageNum: Int?, | ||||
| pageSize: Int?, | pageSize: Int?, | ||||
| floor: String? = null, | floor: String? = null, | ||||
| isEtra: Boolean? = null, | |||||
| isExtra: Boolean? = null, | |||||
| ): RecordsRes<DeliveryOrderInfoLiteDto> { | ): RecordsRes<DeliveryOrderInfoLiteDto> { | ||||
| val page = (pageNum ?: 1) - 1 | val page = (pageNum ?: 1) - 1 | ||||
| val size = pageSize ?: 10 | val size = pageSize ?: 10 | ||||
| @@ -406,22 +395,19 @@ open class DeliveryOrderService( | |||||
| status = statusEnum, | status = statusEnum, | ||||
| etaStart = etaStart, | etaStart = etaStart, | ||||
| etaEnd = etaEnd, | etaEnd = etaEnd, | ||||
| isEtra = isEtra, | |||||
| isExtra = isExtra, | |||||
| allowedSupplierCodes = allowedSupplierCodes, | allowedSupplierCodes = allowedSupplierCodes, | ||||
| pageable = PageRequest.of(0, 100_000), | pageable = PageRequest.of(0, 100_000), | ||||
| ) | ) | ||||
| val deliveryOrderIds = allResult.content.mapNotNull { it.id } | val deliveryOrderIds = allResult.content.mapNotNull { it.id } | ||||
| val deliveryOrdersMap = deliveryOrderRepository.findAllById(deliveryOrderIds).associateBy { it.id } | val deliveryOrdersMap = deliveryOrderRepository.findAllById(deliveryOrderIds).associateBy { it.id } | ||||
| val (floorSuppliers2F, floorSuppliers4F) = loadDoFloorSupplierLists() | |||||
| val processedRecords = allResult.content.map { info -> | val processedRecords = allResult.content.map { info -> | ||||
| val deliveryOrder = deliveryOrdersMap[info.id] | val deliveryOrder = deliveryOrdersMap[info.id] | ||||
| val supplierCode = deliveryOrder?.supplier?.code | val supplierCode = deliveryOrder?.supplier?.code | ||||
| val preferredFloor = when (supplierCode) { | |||||
| "P06B" -> "4F" | |||||
| "P07", "P06D" -> "2F" | |||||
| else -> "2F" | |||||
| } | |||||
| val preferredFloor = preferredStoreFloorForSupplier(supplierCode, floorSuppliers2F, floorSuppliers4F) | |||||
| val shop = deliveryOrder?.shop | val shop = deliveryOrder?.shop | ||||
| val shopId = shop?.id | val shopId = shop?.id | ||||
| val infoEta = info.estimatedArrivalDate | val infoEta = info.estimatedArrivalDate | ||||
| @@ -445,7 +431,7 @@ open class DeliveryOrderService( | |||||
| supplierName = info.supplierName, | supplierName = info.supplierName, | ||||
| shopAddress = info.shopAddress, | shopAddress = info.shopAddress, | ||||
| truckLanceCode = calculatedTruckLanceCode, | truckLanceCode = calculatedTruckLanceCode, | ||||
| isEtra = deliveryOrdersMap[info.id]?.isEtra ?: info.isEtra, | |||||
| isExtra = deliveryOrdersMap[info.id]?.isExtra ?: info.isExtra, | |||||
| ) | ) | ||||
| }.filter { dto -> TruckLaneSearchSpec.isUnassignedResolvedLane(dto.truckLanceCode) } | }.filter { dto -> TruckLaneSearchSpec.isUnassignedResolvedLane(dto.truckLanceCode) } | ||||
| @@ -487,7 +473,7 @@ open class DeliveryOrderService( | |||||
| estimatedArrivalDate = deliveryOrder.estimatedArrivalDate, | estimatedArrivalDate = deliveryOrder.estimatedArrivalDate, | ||||
| completeDate = deliveryOrder.completeDate, | completeDate = deliveryOrder.completeDate, | ||||
| status = deliveryOrder.status?.value, | status = deliveryOrder.status?.value, | ||||
| isEtra = deliveryOrder.isEtra, | |||||
| isExtra = deliveryOrder.isExtra, | |||||
| deliveryOrderLines = deliveryOrder.deliveryOrderLines.map { line -> | deliveryOrderLines = deliveryOrder.deliveryOrderLines.map { line -> | ||||
| DoDetailLineResponse( | DoDetailLineResponse( | ||||
| id = line.id!!, | id = line.id!!, | ||||
| @@ -808,7 +794,7 @@ open class DeliveryOrderService( | |||||
| this.handler = handler | this.handler = handler | ||||
| m18BeId = request.m18BeId | m18BeId = request.m18BeId | ||||
| this.deleted = request.deleted | this.deleted = request.deleted | ||||
| isEtra = request.isEtra ?: false | |||||
| isExtra = request.isExtra ?: false | |||||
| } | } | ||||
| val savedDeliveryOrder = deliveryOrderRepository.saveAndFlush(deliveryOrder).let { | val savedDeliveryOrder = deliveryOrderRepository.saveAndFlush(deliveryOrder).let { | ||||
| @@ -948,14 +934,10 @@ open class DeliveryOrderService( | |||||
| println(" DEBUG: Target date: $targetDate, Date prefix: $datePrefix") | println(" DEBUG: Target date: $targetDate, Date prefix: $datePrefix") | ||||
| // 新逻辑:根据 supplier code 决定楼层 | |||||
| // 如果 supplier code 是 "P06B",使用 4F,否则使用 2F | |||||
| // 新逻辑:根据 supplier code 决定楼层(清單來自 settings) | |||||
| val supplierCode = deliveryOrder.supplier?.code | val supplierCode = deliveryOrder.supplier?.code | ||||
| val preferredFloor = when (supplierCode) { | |||||
| "P06B" -> "4F" | |||||
| "P07", "P06D" -> "2F" | |||||
| else -> "2F" // 或者改成 null / 其他默认值,看你业务需要 | |||||
| } | |||||
| val (floorSuppliers2F, floorSuppliers4F) = loadDoFloorSupplierLists() | |||||
| val preferredFloor = preferredStoreFloorForSupplier(supplierCode, floorSuppliers2F, floorSuppliers4F) | |||||
| println(" DEBUG: Supplier code: $supplierCode, Preferred floor: $preferredFloor") | println(" DEBUG: Supplier code: $supplierCode, Preferred floor: $preferredFloor") | ||||
| @@ -1839,15 +1821,11 @@ val inventoryLotLine = illId?.let { inventoryLotLineMap[it] } | |||||
| } | } | ||||
| } | } | ||||
| // 新逻辑:根据 supplier code 决定楼层 | |||||
| // 如果 supplier code 是 "P06B",使用 4F,否则使用 2F | |||||
| // 新逻辑:根据 supplier code 决定楼层(清單來自 settings) | |||||
| val targetDate = deliveryOrder.estimatedArrivalDate?.toLocalDate() ?: LocalDate.now() | val targetDate = deliveryOrder.estimatedArrivalDate?.toLocalDate() ?: LocalDate.now() | ||||
| val supplierCode = deliveryOrder.supplier?.code | val supplierCode = deliveryOrder.supplier?.code | ||||
| val preferredFloor = when (supplierCode) { | |||||
| "P06B" -> "4F" | |||||
| "P07", "P06D" -> "2F" | |||||
| else -> "2F" // 或者改成 null / 其他默认值,看你业务需要 | |||||
| } | |||||
| val (floorSuppliers2F, floorSuppliers4F) = loadDoFloorSupplierLists() | |||||
| val preferredFloor = preferredStoreFloorForSupplier(supplierCode, floorSuppliers2F, floorSuppliers4F) | |||||
| println(" DEBUG: Floor calculation for DO ${deliveryOrder.id}") | println(" DEBUG: Floor calculation for DO ${deliveryOrder.id}") | ||||
| println(" - Supplier code: $supplierCode") | println(" - Supplier code: $supplierCode") | ||||
| @@ -1936,7 +1914,8 @@ val inventoryLotLine = illId?.let { inventoryLotLineMap[it] } | |||||
| truckDepartureTime = effectiveTruck.departureTime, | truckDepartureTime = effectiveTruck.departureTime, | ||||
| truckLanceCode = effectiveTruck.truckLanceCode, | truckLanceCode = effectiveTruck.truckLanceCode, | ||||
| loadingSequence = effectiveTruck.loadingSequence, | loadingSequence = effectiveTruck.loadingSequence, | ||||
| usedDefaultTruck = usedDefaultTruck | |||||
| usedDefaultTruck = usedDefaultTruck, | |||||
| isExtra = deliveryOrder.isExtra ?: false, | |||||
| ) | ) | ||||
| } | } | ||||
| @@ -2022,11 +2001,8 @@ val inventoryLotLine = illId?.let { inventoryLotLineMap[it] } | |||||
| // Truck selection (reuse normal logic) | // Truck selection (reuse normal logic) | ||||
| val targetDate = deliveryOrder.estimatedArrivalDate?.toLocalDate() ?: LocalDate.now() | val targetDate = deliveryOrder.estimatedArrivalDate?.toLocalDate() ?: LocalDate.now() | ||||
| val supplierCode = deliveryOrder.supplier?.code | val supplierCode = deliveryOrder.supplier?.code | ||||
| val preferredFloor = when (supplierCode) { | |||||
| "P06B" -> "4F" | |||||
| "P07", "P06D" -> "2F" | |||||
| else -> "2F" | |||||
| } | |||||
| val (floorSuppliers2F, floorSuppliers4F) = loadDoFloorSupplierLists() | |||||
| val preferredFloor = preferredStoreFloorForSupplier(supplierCode, floorSuppliers2F, floorSuppliers4F) | |||||
| val truck = deliveryOrder.shop?.id?.let { shopId -> | val truck = deliveryOrder.shop?.id?.let { shopId -> | ||||
| val trucks = truckRepository.findByShopIdAndDeletedFalse(shopId) | val trucks = truckRepository.findByShopIdAndDeletedFalse(shopId) | ||||
| @@ -2094,7 +2070,8 @@ val inventoryLotLine = illId?.let { inventoryLotLineMap[it] } | |||||
| truckDepartureTime = effectiveTruck.departureTime, | truckDepartureTime = effectiveTruck.departureTime, | ||||
| truckLanceCode = effectiveTruck.truckLanceCode, | truckLanceCode = effectiveTruck.truckLanceCode, | ||||
| loadingSequence = effectiveTruck.loadingSequence, | loadingSequence = effectiveTruck.loadingSequence, | ||||
| usedDefaultTruck = usedDefaultTruck | |||||
| usedDefaultTruck = usedDefaultTruck, | |||||
| isExtra = deliveryOrder.isExtra ?: false, | |||||
| ) | ) | ||||
| } | } | ||||
| @@ -2104,7 +2081,7 @@ val inventoryLotLine = illId?.let { inventoryLotLineMap[it] } | |||||
| statusEnum: DeliveryOrderStatus?, | statusEnum: DeliveryOrderStatus?, | ||||
| etaStart: LocalDateTime?, | etaStart: LocalDateTime?, | ||||
| etaEnd: LocalDateTime?, | etaEnd: LocalDateTime?, | ||||
| isEtra: Boolean?, | |||||
| isExtra: Boolean?, | |||||
| allowedSupplierCodes: List<String>, | allowedSupplierCodes: List<String>, | ||||
| lanePredicate: (String?) -> Boolean, | lanePredicate: (String?) -> Boolean, | ||||
| ): List<DeliveryOrderInfoLiteDto> { | ): List<DeliveryOrderInfoLiteDto> { | ||||
| @@ -2118,7 +2095,7 @@ val inventoryLotLine = illId?.let { inventoryLotLineMap[it] } | |||||
| status = statusEnum, | status = statusEnum, | ||||
| etaStart = etaStart, | etaStart = etaStart, | ||||
| etaEnd = etaEnd, | etaEnd = etaEnd, | ||||
| isEtra = isEtra, | |||||
| isExtra = isExtra, | |||||
| allowedSupplierCodes = allowedSupplierCodes, | allowedSupplierCodes = allowedSupplierCodes, | ||||
| pageable = PageRequest.of(dbPage, 500), | pageable = PageRequest.of(dbPage, 500), | ||||
| ) | ) | ||||
| @@ -2140,6 +2117,7 @@ val inventoryLotLine = illId?.let { inventoryLotLineMap[it] } | |||||
| val ids = rows.mapNotNull { it.id } | val ids = rows.mapNotNull { it.id } | ||||
| if (ids.isEmpty()) return emptyList() | if (ids.isEmpty()) return emptyList() | ||||
| val deliveryOrdersMap = deliveryOrderRepository.findAllById(ids).associateBy { it.id } | val deliveryOrdersMap = deliveryOrderRepository.findAllById(ids).associateBy { it.id } | ||||
| val (floorSuppliers2F, floorSuppliers4F) = loadDoFloorSupplierLists() | |||||
| val shopIdAndDatePairs = rows.mapNotNull { info -> | val shopIdAndDatePairs = rows.mapNotNull { info -> | ||||
| val d = deliveryOrdersMap[info.id] | val d = deliveryOrdersMap[info.id] | ||||
| @@ -2149,11 +2127,7 @@ val inventoryLotLine = illId?.let { inventoryLotLineMap[it] } | |||||
| val targetDate = eta.toLocalDate() | val targetDate = eta.toLocalDate() | ||||
| val dayAbbr = getDayOfWeekAbbr(targetDate) | val dayAbbr = getDayOfWeekAbbr(targetDate) | ||||
| val supplierCode = d.supplier?.code | val supplierCode = d.supplier?.code | ||||
| val preferredFloor = when (supplierCode) { | |||||
| "P06B" -> "4F" | |||||
| "P07", "P06D" -> "2F" | |||||
| else -> "2F" | |||||
| } | |||||
| val preferredFloor = preferredStoreFloorForSupplier(supplierCode, floorSuppliers2F, floorSuppliers4F) | |||||
| Triple(shopId, preferredFloor, dayAbbr) | Triple(shopId, preferredFloor, dayAbbr) | ||||
| } else { | } else { | ||||
| null | null | ||||
| @@ -2169,11 +2143,7 @@ val inventoryLotLine = illId?.let { inventoryLotLineMap[it] } | |||||
| return rows.map { info -> | return rows.map { info -> | ||||
| val deliveryOrder = deliveryOrdersMap[info.id] | val deliveryOrder = deliveryOrdersMap[info.id] | ||||
| val supplierCode = deliveryOrder?.supplier?.code | val supplierCode = deliveryOrder?.supplier?.code | ||||
| val preferredFloor = when (supplierCode) { | |||||
| "P06B" -> "4F" | |||||
| "P07", "P06D" -> "2F" | |||||
| else -> "2F" | |||||
| } | |||||
| val preferredFloor = preferredStoreFloorForSupplier(supplierCode, floorSuppliers2F, floorSuppliers4F) | |||||
| val shopId = deliveryOrder?.shop?.id | val shopId = deliveryOrder?.shop?.id | ||||
| val infoEta = info.estimatedArrivalDate | val infoEta = info.estimatedArrivalDate | ||||
| val calculatedTruckLanceCode = | val calculatedTruckLanceCode = | ||||
| @@ -2194,14 +2164,14 @@ val inventoryLotLine = illId?.let { inventoryLotLineMap[it] } | |||||
| supplierName = info.supplierName, | supplierName = info.supplierName, | ||||
| shopAddress = info.shopAddress, | shopAddress = info.shopAddress, | ||||
| truckLanceCode = calculatedTruckLanceCode, | truckLanceCode = calculatedTruckLanceCode, | ||||
| isEtra = deliveryOrder?.isEtra ?: info.isEtra, | |||||
| isExtra = deliveryOrder?.isExtra ?: info.isExtra, | |||||
| ) | ) | ||||
| } | } | ||||
| } | } | ||||
| /** | /** | ||||
| * 依店鋪 + 揀貨樓層解析當日應顯示之車線。 | * 依店鋪 + 揀貨樓層解析當日應顯示之車線。 | ||||
| * - **2F**(P07/P06D):`TruckLanceCode` 多為 `車線-…` 且不含星期縮寫,不依 `LIKE '%Mon%'` 篩選;取該店該樓層未刪除車輛中出發時間最早者。 | |||||
| * - **2F**(P07/P06D/P06Y):`TruckLanceCode` 多為 `車線-…` 且不含星期縮寫,不依 `LIKE '%Mon%'` 篩選;取該店該樓層未刪除車輛中出發時間最早者。 | |||||
| * - **4F**(P06B):維持以星期縮寫篩選 [TruckRepository.findByShopIdAndStoreIdAndDayOfWeek];無命中時再退回同樓層最早出發。 | * - **4F**(P06B):維持以星期縮寫篩選 [TruckRepository.findByShopIdAndStoreIdAndDayOfWeek];無命中時再退回同樓層最早出發。 | ||||
| */ | */ | ||||
| private fun resolveTruckForShopFloorAndDay( | private fun resolveTruckForShopFloorAndDay( | ||||
| @@ -0,0 +1,95 @@ | |||||
| package com.ffii.fpsms.modules.deliveryOrder.service | |||||
| import com.ffii.fpsms.modules.settings.entity.SettingsRepository | |||||
| import org.springframework.stereotype.Service | |||||
| import java.util.Locale | |||||
| /** 供 DO 搜尋/車線/報表 SQL 等共用的 2F/4F 供應商代碼(來自 `settings` CSV)。 */ | |||||
| @Service | |||||
| open class DoFloorSupplierSettingsService( | |||||
| private val settingsRepository: SettingsRepository, | |||||
| ) { | |||||
| companion object { | |||||
| private const val SETTING_DO_FLOOR_SUPPLIERS_2F = "DO.floor.suppliers.2F" | |||||
| private const val SETTING_DO_FLOOR_SUPPLIERS_4F = "DO.floor.suppliers.4F" | |||||
| private val DEFAULT_SUPPLIERS_2F = listOf("P07", "P06D", "P06Y") | |||||
| private val DEFAULT_SUPPLIERS_4F = listOf("P06B") | |||||
| } | |||||
| open fun supplierCodesFromSetting(settingName: String, defaultList: List<String>): List<String> { | |||||
| val raw = settingsRepository.findByName(settingName).map { it.value }.orElse(null) | |||||
| ?.trim() | |||||
| .orEmpty() | |||||
| if (raw.isEmpty()) return defaultList | |||||
| val parsed = raw.split(",").map { it.trim() }.filter { it.isNotEmpty() }.distinct() | |||||
| return parsed.ifEmpty { defaultList } | |||||
| } | |||||
| open fun loadDoFloorSupplierLists(): Pair<List<String>, List<String>> { | |||||
| val suppliers2F = supplierCodesFromSetting(SETTING_DO_FLOOR_SUPPLIERS_2F, DEFAULT_SUPPLIERS_2F) | |||||
| val suppliers4F = supplierCodesFromSetting(SETTING_DO_FLOOR_SUPPLIERS_4F, DEFAULT_SUPPLIERS_4F) | |||||
| return suppliers2F to suppliers4F | |||||
| } | |||||
| open fun allowedSupplierCodesForFloor(floor: String?): List<String> { | |||||
| val f = floor?.trim()?.uppercase(Locale.ROOT).orEmpty() | |||||
| val (codes2F, codes4F) = loadDoFloorSupplierLists() | |||||
| return when { | |||||
| f.isEmpty() || f == "ALL" || f == "All" -> (codes2F + codes4F).distinct() | |||||
| f == "2F" -> codes2F | |||||
| f == "4F" -> codes4F | |||||
| else -> (codes2F + codes4F).distinct() | |||||
| } | |||||
| } | |||||
| /** 4F 清單優先;其餘預設 2F(與既有 DO 車線邏輯一致)。 */ | |||||
| open fun preferredStoreFloorForSupplier( | |||||
| supplierCode: String?, | |||||
| suppliers2F: List<String>, | |||||
| suppliers4F: List<String>, | |||||
| ): String { | |||||
| val code = supplierCode?.trim().orEmpty() | |||||
| if (code.isEmpty()) return "2F" | |||||
| if (suppliers4F.contains(code)) return "4F" | |||||
| if (suppliers2F.contains(code)) return "2F" | |||||
| return "2F" | |||||
| } | |||||
| /** DO 揀貨建議:名單外供應商不限制 2F/4F。 */ | |||||
| open fun preferredFloorForPickLotOrNull( | |||||
| supplierCode: String?, | |||||
| suppliers2F: List<String>, | |||||
| suppliers4F: List<String>, | |||||
| ): String? { | |||||
| val code = supplierCode?.trim().orEmpty() | |||||
| if (code.isEmpty()) return null | |||||
| if (suppliers4F.contains(code)) return "4F" | |||||
| if (suppliers2F.contains(code)) return "2F" | |||||
| return null | |||||
| } | |||||
| data class SqlPreferredFloorCases( | |||||
| /** 例如 `CASE WHEN s.code IN (...) THEN '4F' ... END`(單行,可嵌入原生 SQL) */ | |||||
| val floorStringCase: String, | |||||
| val storeIdNumericCase: String, | |||||
| ) | |||||
| /** | |||||
| * 依目前 settings 產生原生 SQL CASE(供 JDBC 字串拼接)。 | |||||
| * @param codeExpr 已加別名的欄位,如 `s.code`、`supplier.code` | |||||
| */ | |||||
| open fun sqlPreferredFloorCases(codeExpr: String = "s.code"): SqlPreferredFloorCases { | |||||
| val (s2f, s4f) = loadDoFloorSupplierLists() | |||||
| val in4 = joinSqlInList(s4f) | |||||
| val in2 = joinSqlInList(s2f) | |||||
| val floor = | |||||
| "CASE WHEN $codeExpr IN ($in4) THEN '4F' WHEN $codeExpr IN ($in2) THEN '2F' ELSE NULL END" | |||||
| val storeId = | |||||
| "CASE WHEN $codeExpr IN ($in4) THEN 4 WHEN $codeExpr IN ($in2) THEN 2 ELSE NULL END" | |||||
| return SqlPreferredFloorCases(floorStringCase = floor, storeIdNumericCase = storeId) | |||||
| } | |||||
| private fun joinSqlInList(codes: List<String>): String = | |||||
| codes.joinToString(", ") { "'" + it.replace("'", "''") + "'" } | |||||
| } | |||||
| @@ -103,6 +103,7 @@ class DoReleaseCoordinatorService( | |||||
| private val userRepository: UserRepository, | private val userRepository: UserRepository, | ||||
| private val pickOrderRepository: PickOrderRepository, | private val pickOrderRepository: PickOrderRepository, | ||||
| private val doPickOrderRecordRepository: DoPickOrderRecordRepository, | private val doPickOrderRecordRepository: DoPickOrderRecordRepository, | ||||
| private val doFloorSupplierSettingsService: DoFloorSupplierSettingsService, | |||||
| ) { | ) { | ||||
| private val poolSize = Runtime.getRuntime().availableProcessors() | private val poolSize = Runtime.getRuntime().availableProcessors() | ||||
| private val executor = Executors.newFixedThreadPool(min(poolSize, 4)) | private val executor = Executors.newFixedThreadPool(min(poolSize, 4)) | ||||
| @@ -140,22 +141,15 @@ class DoReleaseCoordinatorService( | |||||
| private fun updateBatchTicketNumbers() { | private fun updateBatchTicketNumbers() { | ||||
| try { | try { | ||||
| val dayOfWeekSql = getDayOfWeekAbbrSql("do.estimatedArrivalDate") | val dayOfWeekSql = getDayOfWeekAbbrSql("do.estimatedArrivalDate") | ||||
| val pfCases = doFloorSupplierSettingsService.sqlPreferredFloorCases("s.code") | |||||
| val updateSql = """ | val updateSql = """ | ||||
| UPDATE fpsmsdb.do_pick_order dpo | UPDATE fpsmsdb.do_pick_order dpo | ||||
| INNER JOIN ( | INNER JOIN ( | ||||
| WITH PreferredFloor AS ( | WITH PreferredFloor AS ( | ||||
| SELECT | SELECT | ||||
| do.id AS deliveryOrderId, | do.id AS deliveryOrderId, | ||||
| CASE | |||||
| WHEN s.code = 'P06B' THEN '4F' | |||||
| WHEN s.code = 'P07' OR s.code = 'P06D' THEN '2F' | |||||
| ELSE NULL | |||||
| END AS preferred_floor, | |||||
| CASE | |||||
| WHEN s.code = 'P06B' THEN 4 | |||||
| WHEN s.code = 'P07' OR s.code = 'P06D' THEN 2 | |||||
| ELSE NULL | |||||
| END AS preferred_store_id | |||||
| ${pfCases.floorStringCase} AS preferred_floor, | |||||
| ${pfCases.storeIdNumericCase} AS preferred_store_id | |||||
| FROM fpsmsdb.delivery_order do | FROM fpsmsdb.delivery_order do | ||||
| LEFT JOIN fpsmsdb.shop s ON s.id = do.supplierId AND s.deleted = 0 | LEFT JOIN fpsmsdb.shop s ON s.id = do.supplierId AND s.deleted = 0 | ||||
| WHERE do.deleted = 0 | WHERE do.deleted = 0 | ||||
| @@ -307,20 +301,13 @@ class DoReleaseCoordinatorService( | |||||
| println(" DEBUG: Getting ordered IDs for ${ids.size} orders") | println(" DEBUG: Getting ordered IDs for ${ids.size} orders") | ||||
| println(" DEBUG: First 5 IDs: ${ids.take(5)}") | println(" DEBUG: First 5 IDs: ${ids.take(5)}") | ||||
| val dayOfWeekSql = getDayOfWeekAbbrSql("do.estimatedArrivalDate") | val dayOfWeekSql = getDayOfWeekAbbrSql("do.estimatedArrivalDate") | ||||
| val pfCases = doFloorSupplierSettingsService.sqlPreferredFloorCases("s.code") | |||||
| val sql = """ | val sql = """ | ||||
| WITH PreferredFloor AS ( | WITH PreferredFloor AS ( | ||||
| SELECT | SELECT | ||||
| do.id AS deliveryOrderId, | do.id AS deliveryOrderId, | ||||
| CASE | |||||
| WHEN s.code = 'P06B' THEN '4F' | |||||
| WHEN s.code = 'P07' OR s.code = 'P06D' THEN '2F' | |||||
| ELSE NULL | |||||
| END AS preferred_floor, | |||||
| CASE | |||||
| WHEN s.code = 'P06B' THEN 4 | |||||
| WHEN s.code = 'P07' OR s.code = 'P06D' THEN 2 | |||||
| ELSE NULL | |||||
| END AS preferred_store_id | |||||
| ${pfCases.floorStringCase} AS preferred_floor, | |||||
| ${pfCases.storeIdNumericCase} AS preferred_store_id | |||||
| FROM fpsmsdb.delivery_order do | FROM fpsmsdb.delivery_order do | ||||
| LEFT JOIN fpsmsdb.shop s ON s.id = do.supplierId AND s.deleted = 0 | LEFT JOIN fpsmsdb.shop s ON s.id = do.supplierId AND s.deleted = 0 | ||||
| WHERE do.id IN (${ids.joinToString(",")}) | WHERE do.id IN (${ids.joinToString(",")}) | ||||
| @@ -144,6 +144,9 @@ open class DoWorkbenchDopoAssignmentService( | |||||
| sql.append(" AND dop.loadingSequence = :loadingSequence ") | sql.append(" AND dop.loadingSequence = :loadingSequence ") | ||||
| params["loadingSequence"] = request.loadingSequence | params["loadingSequence"] = request.loadingSequence | ||||
| } | } | ||||
| if (isisExtraReleaseType(request.releaseType)) { | |||||
| sql.append(" AND LOWER(COALESCE(dop.releaseType, '')) = 'isExtra' ") | |||||
| } | |||||
| // Fetch a batch of candidates and try atomic-assign sequentially. | // Fetch a batch of candidates and try atomic-assign sequentially. | ||||
| // This avoids forcing the frontend to refresh when a single picked candidate is concurrently assigned. | // This avoids forcing the frontend to refresh when a single picked candidate is concurrently assigned. | ||||
| val candidateLimit = 50 | val candidateLimit = 50 | ||||
| @@ -247,6 +250,9 @@ open class DoWorkbenchDopoAssignmentService( | |||||
| sql.append(" AND dop.loadingSequence = :loadingSequence ") | sql.append(" AND dop.loadingSequence = :loadingSequence ") | ||||
| params["loadingSequence"] = request.loadingSequence | params["loadingSequence"] = request.loadingSequence | ||||
| } | } | ||||
| if (isisExtraReleaseType(request.releaseType)) { | |||||
| sql.append(" AND LOWER(COALESCE(dop.releaseType, '')) = 'isExtra' ") | |||||
| } | |||||
| val shouldOrderBySequenceV1 = actualStoreId == "2/F" && request.loadingSequence == null | val shouldOrderBySequenceV1 = actualStoreId == "2/F" && request.loadingSequence == null | ||||
| if (shouldOrderBySequenceV1) { | if (shouldOrderBySequenceV1) { | ||||
| sql.append(" ORDER BY dop.requiredDeliveryDate ASC, dop.truckDepartureTime ASC, dop.loadingSequence ASC, dop.id ASC LIMIT 1 ") | sql.append(" ORDER BY dop.requiredDeliveryDate ASC, dop.truckDepartureTime ASC, dop.loadingSequence ASC, dop.id ASC LIMIT 1 ") | ||||
| @@ -301,6 +307,11 @@ open class DoWorkbenchDopoAssignmentService( | |||||
| } else null | } else null | ||||
| } | } | ||||
| private fun isisExtraReleaseType(releaseType: String?): Boolean { | |||||
| val n = releaseType?.trim()?.lowercase().orEmpty() | |||||
| return n == "isExtra" | |||||
| } | |||||
| private fun parseDepartureTimeToSql(raw: String?): Time? { | private fun parseDepartureTimeToSql(raw: String?): Time? { | ||||
| if (raw.isNullOrBlank()) return null | if (raw.isNullOrBlank()) return null | ||||
| val s = raw.trim() | val s = raw.trim() | ||||
| @@ -1,3 +1,4 @@ | |||||
| package com.ffii.fpsms.modules.deliveryOrder.service | package com.ffii.fpsms.modules.deliveryOrder.service | ||||
| import com.ffii.core.support.JdbcDao | import com.ffii.core.support.JdbcDao | ||||
| @@ -54,6 +55,7 @@ import java.time.format.DateTimeFormatter | |||||
| import com.ffii.fpsms.modules.deliveryOrder.web.models.StoreLaneSummary | import com.ffii.fpsms.modules.deliveryOrder.web.models.StoreLaneSummary | ||||
| import com.ffii.fpsms.modules.deliveryOrder.web.models.LaneRow | import com.ffii.fpsms.modules.deliveryOrder.web.models.LaneRow | ||||
| import com.ffii.fpsms.modules.deliveryOrder.web.models.LaneBtn | import com.ffii.fpsms.modules.deliveryOrder.web.models.LaneBtn | ||||
| import com.ffii.fpsms.modules.deliveryOrder.web.models.WorkbenchEtraShopLaneGroup | |||||
| import com.ffii.fpsms.modules.deliveryOrder.web.models.ReleasedDoPickOrderListItem | import com.ffii.fpsms.modules.deliveryOrder.web.models.ReleasedDoPickOrderListItem | ||||
| import com.ffii.fpsms.modules.deliveryOrder.web.models.WorkbenchTicketReleaseTableResponse | import com.ffii.fpsms.modules.deliveryOrder.web.models.WorkbenchTicketReleaseTableResponse | ||||
| import com.ffii.fpsms.modules.user.service.UserService | import com.ffii.fpsms.modules.user.service.UserService | ||||
| @@ -670,6 +672,7 @@ return MessageResponse( | |||||
| val releaseFilterClause = when (rt) { | val releaseFilterClause = when (rt) { | ||||
| "batch" -> " AND LOWER(COALESCE(dop.releaseType, '')) = 'batch' " | "batch" -> " AND LOWER(COALESCE(dop.releaseType, '')) = 'batch' " | ||||
| "single" -> " AND LOWER(COALESCE(dop.releaseType, '')) = 'single' " | "single" -> " AND LOWER(COALESCE(dop.releaseType, '')) = 'single' " | ||||
| "isExtra" -> " AND LOWER(COALESCE(dop.releaseType, '')) = 'isExtra' " | |||||
| else -> "" | else -> "" | ||||
| } | } | ||||
| val sql = """ | val sql = """ | ||||
| @@ -812,6 +815,7 @@ return MessageResponse( | |||||
| unassigned = it.unassigned, | unassigned = it.unassigned, | ||||
| total = it.total, | total = it.total, | ||||
| handlerName = it.handlerName, | handlerName = it.handlerName, | ||||
| storeId = actualStoreId, | |||||
| ) | ) | ||||
| } | } | ||||
| .sortedWith( | .sortedWith( | ||||
| @@ -853,24 +857,181 @@ return MessageResponse( | |||||
| ) | ) | ||||
| } | } | ||||
| /** | |||||
| * Workbench Etra view: all `delivery_order_pick_order` with `releaseType` = isExtra (case-insensitive), | |||||
| * for one [requiredDeliveryDate], grouped by shop then by truck / time / loading sequence. | |||||
| */ | |||||
| open fun getWorkbenchEtraLaneSummary(requiredDate: LocalDate?): List<WorkbenchEtraShopLaneGroup> { | |||||
| val targetDate = requiredDate ?: LocalDate.now() | |||||
| val defaultTruck = truckRepository.findById(5577L).orElse(null) | |||||
| val defaultTruckLaneCode = defaultTruck?.truckLanceCode ?: "" | |||||
| val sql = """ | |||||
| SELECT | |||||
| dop.shopCode AS shopCode, | |||||
| dop.shopName AS shopName, | |||||
| dop.storeId AS storeId, | |||||
| dop.truckDepartureTime AS truckDepartureTime, | |||||
| dop.truckLanceCode AS truckLanceCode, | |||||
| dop.loadingSequence AS loadingSequence, | |||||
| COUNT(DISTINCT dop.id) AS total_cnt, | |||||
| SUM(CASE WHEN dop.handledBy IS NULL THEN 1 ELSE 0 END) AS unassigned_cnt, | |||||
| GROUP_CONCAT( | |||||
| DISTINCT NULLIF(TRIM(dop.handlerName), '') | |||||
| ORDER BY dop.handlerName | |||||
| SEPARATOR ', ' | |||||
| ) AS handler_names | |||||
| FROM fpsmsdb.delivery_order_pick_order dop | |||||
| WHERE dop.deleted = 0 | |||||
| AND LOWER(COALESCE(dop.releaseType, '')) = 'isExtra' | |||||
| AND dop.requiredDeliveryDate = :requiredDate | |||||
| AND dop.ticketStatus IN ('pending', 'released') | |||||
| AND EXISTS ( | |||||
| SELECT 1 FROM fpsmsdb.pick_order po | |||||
| WHERE po.deliveryOrderPickOrderId = dop.id AND po.deleted = 0 | |||||
| ) | |||||
| GROUP BY dop.shopCode, dop.shopName, dop.storeId, dop.truckDepartureTime, dop.truckLanceCode, dop.loadingSequence | |||||
| """.trimIndent() | |||||
| val rawRows: List<Map<String, Any?>> = try { | |||||
| jdbcDao.queryForList(sql, mapOf("requiredDate" to targetDate)) | |||||
| } catch (e: Exception) { | |||||
| println("❌ getWorkbenchEtraLaneSummary: ${e.message}") | |||||
| emptyList() | |||||
| } | |||||
| fun cellStr(row: Map<String, Any?>, name: String): String? { | |||||
| val k = row.keys.find { it.equals(name, true) } ?: return null | |||||
| return row[k]?.toString()?.trim()?.takeIf { it.isNotEmpty() } | |||||
| } | |||||
| fun cellNum(row: Map<String, Any?>, vararg names: String): Int { | |||||
| for (n in names) { | |||||
| val k = row.keys.find { it.equals(n, true) } ?: continue | |||||
| (row[k] as? Number)?.toInt()?.let { return it } | |||||
| } | |||||
| return 0 | |||||
| } | |||||
| fun cellNullableInt(row: Map<String, Any?>, vararg names: String): Int? { | |||||
| for (n in names) { | |||||
| val k = row.keys.find { it.equals(n, true) } ?: continue | |||||
| val v = row[k] ?: continue | |||||
| when (v) { | |||||
| is Number -> return v.toInt() | |||||
| is String -> v.trim().toIntOrNull()?.let { return it } | |||||
| } | |||||
| } | |||||
| return null | |||||
| } | |||||
| data class EtraAgg( | |||||
| val shopCode: String?, | |||||
| val shopName: String?, | |||||
| val storeId: String?, | |||||
| val sortTime: LocalTime, | |||||
| val lance: String, | |||||
| val loadingSequence: Int?, | |||||
| val unassigned: Int, | |||||
| val total: Int, | |||||
| val handlerName: String?, | |||||
| ) | |||||
| val aggs = rawRows.mapNotNull { row -> | |||||
| val lance = cellStr(row, "truckLanceCode") ?: return@mapNotNull null | |||||
| if (lance == defaultTruckLaneCode) return@mapNotNull null | |||||
| val storeIdCol = cellStr(row, "storeId") | |||||
| val ttKey = row.keys.find { it.equals("truckDepartureTime", true) } | |||||
| val ttVal = ttKey?.let { row[it] } | |||||
| val sortTime = when (ttVal) { | |||||
| null -> LocalTime.MIDNIGHT | |||||
| is java.sql.Time -> ttVal.toLocalTime() | |||||
| is LocalTime -> ttVal | |||||
| is java.sql.Timestamp -> ttVal.toLocalDateTime().toLocalTime() | |||||
| else -> runCatching { LocalTime.parse(ttVal.toString().take(8)) }.getOrNull() | |||||
| ?: runCatching { LocalTime.parse(ttVal.toString()) }.getOrNull() | |||||
| ?: LocalTime.MIDNIGHT | |||||
| } | |||||
| val loadingSeq = cellNullableInt(row, "loadingSequence") | |||||
| val unassigned = cellNum(row, "unassigned_cnt", "unassignedCnt") | |||||
| val total = cellNum(row, "total_cnt", "totalCnt") | |||||
| if (total <= 0) return@mapNotNull null | |||||
| EtraAgg( | |||||
| shopCode = cellStr(row, "shopCode"), | |||||
| shopName = cellStr(row, "shopName"), | |||||
| storeId = storeIdCol, | |||||
| sortTime = sortTime, | |||||
| lance = lance, | |||||
| loadingSequence = loadingSeq, | |||||
| unassigned = unassigned, | |||||
| total = total, | |||||
| handlerName = cellStr(row, "handler_names"), | |||||
| ) | |||||
| } | |||||
| val byShop = aggs.groupBy { a -> | |||||
| listOf(a.shopCode ?: "", a.shopName ?: "").joinToString("|") | |||||
| } | |||||
| return byShop.entries | |||||
| .map { (key, group) -> | |||||
| val head = group.first() | |||||
| val lanes = group | |||||
| .sortedWith( | |||||
| compareBy<EtraAgg> { it.sortTime } | |||||
| .thenBy { it.lance } | |||||
| .thenBy { it.loadingSequence ?: 999 } | |||||
| ) | |||||
| .map { | |||||
| val is4F = it.storeId?.replace("/", "")?.trim()?.equals("4F", ignoreCase = true) == true | |||||
| LaneBtn( | |||||
| truckLanceCode = it.lance, | |||||
| loadingSequence = if (is4F) it.loadingSequence else null, | |||||
| unassigned = it.unassigned, | |||||
| total = it.total, | |||||
| handlerName = it.handlerName, | |||||
| storeId = it.storeId, | |||||
| truckDepartureTime = it.sortTime.toString(), | |||||
| ) | |||||
| } | |||||
| WorkbenchEtraShopLaneGroup( | |||||
| shopCode = head.shopCode, | |||||
| shopName = head.shopName, | |||||
| lanes = lanes, | |||||
| ) | |||||
| } | |||||
| .sortedWith( | |||||
| compareBy<WorkbenchEtraShopLaneGroup> { it.shopName ?: it.shopCode ?: "" } | |||||
| .thenBy { it.shopCode ?: "" } | |||||
| ) | |||||
| } | |||||
| open fun findWorkbenchReleasedDeliveryOrderPickOrdersForSelection( | open fun findWorkbenchReleasedDeliveryOrderPickOrdersForSelection( | ||||
| shopName: String?, | shopName: String?, | ||||
| storeId: String?, | storeId: String?, | ||||
| truck: String?, | truck: String?, | ||||
| releaseTypeFilter: String? = null, | |||||
| ): List<ReleasedDoPickOrderListItem> = | ): List<ReleasedDoPickOrderListItem> = | ||||
| queryWorkbenchReleasedDopoList(shopName, storeId, truck, beforeToday = true) | |||||
| queryWorkbenchReleasedDopoList(shopName, storeId, truck, beforeToday = true, releaseTypeFilter = releaseTypeFilter) | |||||
| /** | /** | ||||
| * @param requiredDeliveryDate when null, uses [LocalDate.now] (calendar today). | * @param requiredDeliveryDate when null, uses [LocalDate.now] (calendar today). | ||||
| * When set, filters `dop.requiredDeliveryDate = :targetDate` (workbench date picker / select day). | * When set, filters `dop.requiredDeliveryDate = :targetDate` (workbench date picker / select day). | ||||
| * @param releaseTypeFilter when `isExtra` (case-insensitive), only `delivery_order_pick_order.releaseType = isExtra` rows. | |||||
| */ | */ | ||||
| open fun findWorkbenchReleasedDeliveryOrderPickOrdersForSelectionToday( | open fun findWorkbenchReleasedDeliveryOrderPickOrdersForSelectionToday( | ||||
| shopName: String?, | shopName: String?, | ||||
| storeId: String?, | storeId: String?, | ||||
| truck: String?, | truck: String?, | ||||
| requiredDeliveryDate: LocalDate? = null, | requiredDeliveryDate: LocalDate? = null, | ||||
| releaseTypeFilter: String? = null, | |||||
| ): List<ReleasedDoPickOrderListItem> = | ): List<ReleasedDoPickOrderListItem> = | ||||
| queryWorkbenchReleasedDopoList(shopName, storeId, truck, beforeToday = false, equalsDeliveryDate = requiredDeliveryDate) | |||||
| queryWorkbenchReleasedDopoList( | |||||
| shopName, | |||||
| storeId, | |||||
| truck, | |||||
| beforeToday = false, | |||||
| equalsDeliveryDate = requiredDeliveryDate, | |||||
| releaseTypeFilter = releaseTypeFilter, | |||||
| ) | |||||
| /** | /** | ||||
| * Workbench completed tickets: query `delivery_order_pick_order` where `ticketStatus = completed`. | * Workbench completed tickets: query `delivery_order_pick_order` where `ticketStatus = completed`. | ||||
| @@ -1362,7 +1523,11 @@ return MessageResponse( | |||||
| dop.deliveryNoteCode = CodeGenerator.generateNo(prefix = prefix, midfix = midfix, latestCode = latestCode) | dop.deliveryNoteCode = CodeGenerator.generateNo(prefix = prefix, midfix = midfix, latestCode = latestCode) | ||||
| } | } | ||||
| deliveryOrderPickOrderRepository.save(dop) | deliveryOrderPickOrderRepository.save(dop) | ||||
| <<<<<<< HEAD | |||||
| ======= | |||||
| >>>>>>> e9f1f48edb57d3696af3ffb23bc40d9644c8c44f | |||||
| } | } | ||||
| markDeliveryOrdersCompletedForDeliveryOrderPickOrder(deliveryOrderPickOrderId) | markDeliveryOrdersCompletedForDeliveryOrderPickOrder(deliveryOrderPickOrderId) | ||||
| return MessageResponse( | return MessageResponse( | ||||
| @@ -1469,6 +1634,7 @@ return MessageResponse( | |||||
| truck: String?, | truck: String?, | ||||
| beforeToday: Boolean, | beforeToday: Boolean, | ||||
| equalsDeliveryDate: LocalDate? = null, | equalsDeliveryDate: LocalDate? = null, | ||||
| releaseTypeFilter: String? = null, | |||||
| ): List<ReleasedDoPickOrderListItem> { | ): List<ReleasedDoPickOrderListItem> { | ||||
| val today = LocalDate.now() | val today = LocalDate.now() | ||||
| val params = mutableMapOf<String, Any>() | val params = mutableMapOf<String, Any>() | ||||
| @@ -1519,6 +1685,10 @@ return MessageResponse( | |||||
| sqlBuilder.append(" AND (dop.shopName LIKE :shopPat OR dop.shopCode LIKE :shopPat) ") | sqlBuilder.append(" AND (dop.shopName LIKE :shopPat OR dop.shopCode LIKE :shopPat) ") | ||||
| params["shopPat"] = "%${shopName.trim()}%" | params["shopPat"] = "%${shopName.trim()}%" | ||||
| } | } | ||||
| val rtNorm = releaseTypeFilter?.trim()?.lowercase().orEmpty() | |||||
| if (rtNorm == "isExtra") { | |||||
| sqlBuilder.append(" AND LOWER(COALESCE(dop.releaseType, '')) = 'isExtra' ") | |||||
| } | |||||
| sqlBuilder.append(" ORDER BY dop.requiredDeliveryDate, dop.truckDepartureTime, dop.truckLanceCode, dop.id ") | sqlBuilder.append(" ORDER BY dop.requiredDeliveryDate, dop.truckDepartureTime, dop.truckLanceCode, dop.id ") | ||||
| val rows: List<Map<String, Any?>> = try { | val rows: List<Map<String, Any?>> = try { | ||||
| jdbcDao.queryForList(sqlBuilder.toString(), params) | jdbcDao.queryForList(sqlBuilder.toString(), params) | ||||
| @@ -1913,6 +2083,7 @@ return MessageResponse( | |||||
| tryCompleteDeliveryOrderPickOrderTicketCompleted(poId) | tryCompleteDeliveryOrderPickOrderTicketCompleted(poId) | ||||
| } | } | ||||
| } | } | ||||
| private fun registerAfterCommit(action: () -> Unit) { | private fun registerAfterCommit(action: () -> Unit) { | ||||
| if (!TransactionSynchronizationManager.isSynchronizationActive()) { | if (!TransactionSynchronizationManager.isSynchronizationActive()) { | ||||
| action() | action() | ||||
| @@ -2048,6 +2219,7 @@ return MessageResponse( | |||||
| ) | ) | ||||
| } | } | ||||
| } | } | ||||
| private fun postWorkbenchPickSideEffects(savedStockOutLine: StockOutLine, deltaQty: BigDecimal, createLedger: Boolean = true) { | private fun postWorkbenchPickSideEffects(savedStockOutLine: StockOutLine, deltaQty: BigDecimal, createLedger: Boolean = true) { | ||||
| if (deltaQty <= BigDecimal.ZERO) return | if (deltaQty <= BigDecimal.ZERO) return | ||||
| val wall0 = System.nanoTime() | val wall0 = System.nanoTime() | ||||
| @@ -2230,9 +2402,10 @@ return MessageResponse( | |||||
| throw last ?: RuntimeException("Failed to complete pick order after retries (poId=$poId)") | throw last ?: RuntimeException("Failed to complete pick order after retries (poId=$poId)") | ||||
| } | } | ||||
| /** | |||||
| /** | |||||
| * Workbench completion: if all pick_orders under the same delivery_order_pick_order are completed, | * Workbench completion: if all pick_orders under the same delivery_order_pick_order are completed, | ||||
| * update ONLY delivery_order_pick_order.ticketStatus (no do_pick_order/do_pick_order_line records). | |||||
| * update delivery_order_pick_order.ticketStatus and related delivery_order.status → completed. | |||||
| * Does not create do_pick_order / do_pick_order_line records. | |||||
| */ | */ | ||||
| private fun tryCompleteDeliveryOrderPickOrderTicketCompleted(poId: Long) { | private fun tryCompleteDeliveryOrderPickOrderTicketCompleted(poId: Long) { | ||||
| val dopRow = jdbcDao.queryForMap( | val dopRow = jdbcDao.queryForMap( | ||||
| @@ -2307,7 +2480,6 @@ return MessageResponse( | |||||
| deliveryOrderRepository.save(deliveryOrder) | deliveryOrderRepository.save(deliveryOrder) | ||||
| } | } | ||||
| } | } | ||||
| private fun checkWorkbenchPickOrderLineCompleted(pickOrderLineId: Long, allStockOutLines: List<StockOutLineInfo>) { | private fun checkWorkbenchPickOrderLineCompleted(pickOrderLineId: Long, allStockOutLines: List<StockOutLineInfo>) { | ||||
| val pol = pickOrderLineRepository.findById(pickOrderLineId).orElse(null) ?: return | val pol = pickOrderLineRepository.findById(pickOrderLineId).orElse(null) ?: return | ||||
| if (pol.status == PickOrderLineStatus.COMPLETED) return | if (pol.status == PickOrderLineStatus.COMPLETED) return | ||||
| @@ -2745,11 +2917,7 @@ return MessageResponse( | |||||
| } | } | ||||
| } | } | ||||
| /** | |||||
| * Carton label reprint for workbench: [request.doPickOrderId] is [delivery_order_pick_order.id], | |||||
| * same as [getWorkbenchPrintContext]. Legacy [DeliveryOrderService.printDNLabelsReprint] expects | |||||
| * [do_pick_order_record.recordId] and must not be used here. | |||||
| */ | |||||
| private fun exportDNLabelsReprintWorkbench(request: PrintDNLabelsReprintRequest): Map<String, Any> { | private fun exportDNLabelsReprintWorkbench(request: PrintDNLabelsReprintRequest): Map<String, Any> { | ||||
| validateWorkbenchCartonReprintRange( | validateWorkbenchCartonReprintRange( | ||||
| fromCarton = request.fromCarton, | fromCarton = request.fromCarton, | ||||
| @@ -359,14 +359,17 @@ open class DoWorkbenchReleaseService( | |||||
| } | } | ||||
| /** | /** | ||||
| * `TI-B-yyyyMMdd-2F-001` (batch) or `TI-S-yyyyMMdd-2F-001` (single), same suffix rules as [DoReleaseCoordinatorService] / legacy `do_pick_order`. | |||||
| * `TI-B-yyyyMMdd-2F-001` (batch), `TI-S-yyyyMMdd-2F-001` (single), or `TI-E-yyyyMMdd-2F-001` (Etra), | |||||
| * same suffix rules as [DoReleaseCoordinatorService] / legacy `do_pick_order`. | |||||
| */ | */ | ||||
| private fun nextDeliveryOrderPickOrderTicketNo( | private fun nextDeliveryOrderPickOrderTicketNo( | ||||
| requiredDate: LocalDate, | requiredDate: LocalDate, | ||||
| storeDisplay: String, | storeDisplay: String, | ||||
| ticketLetter: String, | ticketLetter: String, | ||||
| ): String { | ): String { | ||||
| require(ticketLetter == "B" || ticketLetter == "S") { "ticketLetter must be B or S" } | |||||
| require(ticketLetter == "B" || ticketLetter == "S" || ticketLetter == "E") { | |||||
| "ticketLetter must be B, S or E" | |||||
| } | |||||
| val ymd = requiredDate.format(DateTimeFormatter.ofPattern("yyyyMMdd")) | val ymd = requiredDate.format(DateTimeFormatter.ofPattern("yyyyMMdd")) | ||||
| val floor = storeDisplay.replace("/", "").trim() | val floor = storeDisplay.replace("/", "").trim() | ||||
| val prefix = "TI-$ticketLetter-$ymd-$floor-" | val prefix = "TI-$ticketLetter-$ymd-$floor-" | ||||
| @@ -397,6 +400,9 @@ open class DoWorkbenchReleaseService( | |||||
| private fun nextDeliveryOrderPickOrderSingleTicketNo(requiredDate: LocalDate, storeDisplay: String): String = | private fun nextDeliveryOrderPickOrderSingleTicketNo(requiredDate: LocalDate, storeDisplay: String): String = | ||||
| nextDeliveryOrderPickOrderTicketNo(requiredDate, storeDisplay, "S") | nextDeliveryOrderPickOrderTicketNo(requiredDate, storeDisplay, "S") | ||||
| private fun nextDeliveryOrderPickOrderEtraTicketNo(requiredDate: LocalDate, storeDisplay: String): String = | |||||
| nextDeliveryOrderPickOrderTicketNo(requiredDate, storeDisplay, "E") | |||||
| private fun asyncJobType(useV2: Boolean, dopReleaseType: String): String { | private fun asyncJobType(useV2: Boolean, dopReleaseType: String): String { | ||||
| val single = dopReleaseType.equals("single", ignoreCase = true) | val single = dopReleaseType.equals("single", ignoreCase = true) | ||||
| return when { | return when { | ||||
| @@ -440,11 +446,6 @@ open class DoWorkbenchReleaseService( | |||||
| ): Int { | ): Int { | ||||
| if (results.isEmpty()) return 0 | if (results.isEmpty()) return 0 | ||||
| val releaseTypeCol = when (dopReleaseType.lowercase()) { | |||||
| "single" -> "single" | |||||
| else -> "batch" | |||||
| } | |||||
| val grouped = results.groupBy { | val grouped = results.groupBy { | ||||
| listOf( | listOf( | ||||
| it.shopId?.toString() ?: "", | it.shopId?.toString() ?: "", | ||||
| @@ -452,7 +453,8 @@ open class DoWorkbenchReleaseService( | |||||
| it.preferredFloor, | it.preferredFloor, | ||||
| it.truckId?.toString() ?: "", | it.truckId?.toString() ?: "", | ||||
| it.truckDepartureTime?.toString() ?: "", | it.truckDepartureTime?.toString() ?: "", | ||||
| it.truckLanceCode ?: "" | |||||
| it.truckLanceCode ?: "", | |||||
| it.isExtra.toString(), | |||||
| ).joinToString("|") | ).joinToString("|") | ||||
| } | } | ||||
| @@ -477,7 +479,16 @@ open class DoWorkbenchReleaseService( | |||||
| (storeId ?: "2/F").replace("/", "").trim() | (storeId ?: "2/F").replace("/", "").trim() | ||||
| } | } | ||||
| val requiredDate = first.estimatedArrivalDate ?: LocalDate.now() | val requiredDate = first.estimatedArrivalDate ?: LocalDate.now() | ||||
| val tempTicket = if (releaseTypeCol == "single") { | |||||
| val releaseTypeCol = if (first.isExtra) { | |||||
| "isExtra" | |||||
| } else if (dopReleaseType.equals("single", ignoreCase = true)) { | |||||
| "single" | |||||
| } else { | |||||
| "batch" | |||||
| } | |||||
| val tempTicket = if (first.isExtra) { | |||||
| nextDeliveryOrderPickOrderEtraTicketNo(requiredDate, ticketFloorSegment) | |||||
| } else if (releaseTypeCol == "single") { | |||||
| nextDeliveryOrderPickOrderSingleTicketNo(requiredDate, ticketFloorSegment) | nextDeliveryOrderPickOrderSingleTicketNo(requiredDate, ticketFloorSegment) | ||||
| } else { | } else { | ||||
| nextDeliveryOrderPickOrderBatchTicketNo(requiredDate, ticketFloorSegment) | nextDeliveryOrderPickOrderBatchTicketNo(requiredDate, ticketFloorSegment) | ||||
| @@ -72,7 +72,7 @@ class DeliveryOrderController( | |||||
| pageSize = request.pageSize, | pageSize = request.pageSize, | ||||
| truckLanceCode = request.truckLanceCode, | truckLanceCode = request.truckLanceCode, | ||||
| floor = request.floor, | floor = request.floor, | ||||
| isEtra = request.isEtra, | |||||
| isExtra = request.isExtra, | |||||
| ) | ) | ||||
| } | } | ||||
| @@ -89,7 +89,7 @@ class DeliveryOrderController( | |||||
| pageNum = request.pageNum, | pageNum = request.pageNum, | ||||
| pageSize = request.pageSize, | pageSize = request.pageSize, | ||||
| floor = request.floor, | floor = request.floor, | ||||
| isEtra = request.isEtra, | |||||
| isExtra = request.isExtra, | |||||
| ) | ) | ||||
| } | } | ||||
| @@ -108,7 +108,7 @@ class DeliveryOrderController( | |||||
| pageSize = request.pageSize, | pageSize = request.pageSize, | ||||
| truckLanceCode = request.truckLanceCode, | truckLanceCode = request.truckLanceCode, | ||||
| floor = request.floor, | floor = request.floor, | ||||
| isEtra = request.isEtra, | |||||
| isExtra = request.isExtra, | |||||
| ) | ) | ||||
| } | } | ||||
| @@ -96,14 +96,27 @@ class DoWorkbenchController( | |||||
| ) | ) | ||||
| } | } | ||||
| /** All Etra workbench tickets for a day, grouped by shop → truck (see [DoWorkbenchMainService.getWorkbenchEtraLaneSummary]). */ | |||||
| @GetMapping("/summary-is-etra") | |||||
| fun getWorkbenchEtraSummary( | |||||
| @RequestParam(required = false) @DateTimeFormat(iso = DateTimeFormat.ISO.DATE) requiredDate: LocalDate?, | |||||
| ): List<WorkbenchEtraShopLaneGroup> = | |||||
| doWorkbenchMainService.getWorkbenchEtraLaneSummary(requiredDate) | |||||
| /** Past-date backlog tickets from `delivery_order_pick_order` (not `do_pick_order`). */ | /** Past-date backlog tickets from `delivery_order_pick_order` (not `do_pick_order`). */ | ||||
| @GetMapping("/released") | @GetMapping("/released") | ||||
| fun getWorkbenchReleasedDoPickOrders( | fun getWorkbenchReleasedDoPickOrders( | ||||
| @RequestParam(required = false) shopName: String?, | @RequestParam(required = false) shopName: String?, | ||||
| @RequestParam(required = false) storeId: String?, | @RequestParam(required = false) storeId: String?, | ||||
| @RequestParam(required = false) truck: String? | |||||
| @RequestParam(required = false) truck: String?, | |||||
| @RequestParam(required = false) releaseType: String?, | |||||
| ): List<ReleasedDoPickOrderListItem> { | ): List<ReleasedDoPickOrderListItem> { | ||||
| return doWorkbenchMainService.findWorkbenchReleasedDeliveryOrderPickOrdersForSelection(shopName, storeId, truck) | |||||
| return doWorkbenchMainService.findWorkbenchReleasedDeliveryOrderPickOrdersForSelection( | |||||
| shopName, | |||||
| storeId, | |||||
| truck, | |||||
| releaseTypeFilter = releaseType, | |||||
| ) | |||||
| } | } | ||||
| @GetMapping("/released-today") | @GetMapping("/released-today") | ||||
| @@ -112,12 +125,14 @@ class DoWorkbenchController( | |||||
| @RequestParam(required = false) storeId: String?, | @RequestParam(required = false) storeId: String?, | ||||
| @RequestParam(required = false) truck: String?, | @RequestParam(required = false) truck: String?, | ||||
| @RequestParam(required = false) @DateTimeFormat(iso = DateTimeFormat.ISO.DATE) requiredDate: LocalDate?, | @RequestParam(required = false) @DateTimeFormat(iso = DateTimeFormat.ISO.DATE) requiredDate: LocalDate?, | ||||
| @RequestParam(required = false) releaseType: String?, | |||||
| ): List<ReleasedDoPickOrderListItem> { | ): List<ReleasedDoPickOrderListItem> { | ||||
| return doWorkbenchMainService.findWorkbenchReleasedDeliveryOrderPickOrdersForSelectionToday( | return doWorkbenchMainService.findWorkbenchReleasedDeliveryOrderPickOrdersForSelectionToday( | ||||
| shopName, | shopName, | ||||
| storeId, | storeId, | ||||
| truck, | truck, | ||||
| requiredDeliveryDate = requiredDate, | requiredDeliveryDate = requiredDate, | ||||
| releaseTypeFilter = releaseType, | |||||
| ) | ) | ||||
| } | } | ||||
| @@ -19,7 +19,7 @@ data class DoDetailResponse( | |||||
| val completeDate: LocalDateTime?, | val completeDate: LocalDateTime?, | ||||
| val status: String?, | val status: String?, | ||||
| /** 加單 DO(M18 加單專用同步) */ | /** 加單 DO(M18 加單專用同步) */ | ||||
| val isEtra: Boolean = false, | |||||
| val isExtra: Boolean = false, | |||||
| val deliveryOrderLines: List<DoDetailLineResponse> | val deliveryOrderLines: List<DoDetailLineResponse> | ||||
| ) | ) | ||||
| @@ -51,7 +51,18 @@ data class LaneBtn( | |||||
| val unassigned: Int, | val unassigned: Int, | ||||
| val total: Int, | val total: Int, | ||||
| // 同一 truckLanceCode + loadingSequence 的 handler 去重后逗号拼接 | // 同一 truckLanceCode + loadingSequence 的 handler 去重后逗号拼接 | ||||
| val handlerName: String? = null | |||||
| val handlerName: String? = null, | |||||
| /** Workbench Etra lane: `delivery_order_pick_order.storeId` (2/F, 4/F, …) for assign / modal scope */ | |||||
| val storeId: String? = null, | |||||
| /** Workbench Etra / lane row: `truckDepartureTime` as ISO local time string for assign-by-lane */ | |||||
| val truckDepartureTime: String? = null, | |||||
| ) | |||||
| /** All Etra (`releaseType=isExtra`) tickets for a day, grouped by shop then truck (no 2F/4F split in UI). */ | |||||
| data class WorkbenchEtraShopLaneGroup( | |||||
| val shopCode: String?, | |||||
| val shopName: String?, | |||||
| val lanes: List<LaneBtn>, | |||||
| ) | ) | ||||
| data class AssignByLaneRequest( | data class AssignByLaneRequest( | ||||
| val userId: Long, | val userId: Long, | ||||
| @@ -59,7 +70,9 @@ data class AssignByLaneRequest( | |||||
| val truckDepartureTime: String?, // 可选:限定出车时间 | val truckDepartureTime: String?, // 可选:限定出车时间 | ||||
| val truckLanceCode: String , | val truckLanceCode: String , | ||||
| val loadingSequence: Int? = null, | val loadingSequence: Int? = null, | ||||
| val requiredDate: LocalDate? // 必填:车道编号 | |||||
| val requiredDate: LocalDate?, // 必填:车道编号 | |||||
| /** When `isExtra`, assignment candidates are limited to `releaseType = isExtra` rows. */ | |||||
| val releaseType: String? = null, | |||||
| ) | ) | ||||
| data class DoPickOrderSummaryItem( | data class DoPickOrderSummaryItem( | ||||
| val truckDepartureTime: java.time.LocalTime?, | val truckDepartureTime: java.time.LocalTime?, | ||||
| @@ -21,7 +21,8 @@ data class ReleaseDoResult( | |||||
| val truckDepartureTime: LocalTime?, | val truckDepartureTime: LocalTime?, | ||||
| val truckLanceCode: String?, | val truckLanceCode: String?, | ||||
| val loadingSequence: Int? | |||||
| val loadingSequence: Int?, | |||||
| val isExtra: Boolean = false, | |||||
| ) | ) | ||||
| data class SearchDeliveryOrderInfoRequest( | data class SearchDeliveryOrderInfoRequest( | ||||
| val code: String?, | val code: String?, | ||||
| @@ -31,8 +32,8 @@ data class SearchDeliveryOrderInfoRequest( | |||||
| val pageSize: Int?, | val pageSize: Int?, | ||||
| val pageNum: Int?, | val pageNum: Int?, | ||||
| val truckLanceCode: String?, | val truckLanceCode: String?, | ||||
| /** `ALL`/`All`/null:P06B+P07+P06D;`2F`:P07+P06D;`4F`:P06B。車線-X 亦依供應商歸屬出現在對應樓層。 */ | |||||
| /** `ALL`/`All`/null:P06B+P07+P06D+P06Y;`2F`:P07+P06D+P06Y ;`4F`:P06B。車線-X 亦依供應商歸屬出現在對應樓層。 */ | |||||
| val floor: String? = null, | val floor: String? = null, | ||||
| /** null:不篩 isEtra;true/false:只顯示加單或非加單 DO */ | |||||
| val isEtra: Boolean? = null, | |||||
| /** null:不篩 isExtra;true/false:只顯示加單或非加單 DO */ | |||||
| val isExtra: Boolean? = null, | |||||
| ) | ) | ||||
| @@ -20,7 +20,7 @@ data class SaveDeliveryOrderRequest( | |||||
| val handlerId: Long?, | val handlerId: Long?, | ||||
| val m18BeId: Long?, | val m18BeId: Long?, | ||||
| val deleted: Boolean? = false, | val deleted: Boolean? = false, | ||||
| val isEtra: Boolean? = false, | |||||
| val isExtra: Boolean? = false, | |||||
| ) | ) | ||||
| data class SaveDeliveryOrderStatusRequest( | data class SaveDeliveryOrderStatusRequest( | ||||
| @@ -0,0 +1,243 @@ | |||||
| package com.ffii.fpsms.modules.jobOrder.service | |||||
| import com.ffii.fpsms.modules.jobOrder.entity.JobOrder | |||||
| import com.ffii.fpsms.modules.jobOrder.entity.JobOrderRepository | |||||
| import com.ffii.fpsms.modules.jobOrder.enums.JobOrderStatus | |||||
| import com.ffii.fpsms.modules.pickOrder.entity.PickOrder | |||||
| import com.ffii.fpsms.modules.pickOrder.entity.PickOrderRepository | |||||
| import com.ffii.fpsms.modules.productProcess.entity.ProductProcess | |||||
| import com.ffii.fpsms.modules.productProcess.entity.ProductProcessRepository | |||||
| import com.ffii.fpsms.modules.productProcess.enums.ProductProcessStatus | |||||
| import com.ffii.fpsms.modules.stock.entity.StockInLineRepository | |||||
| import com.ffii.fpsms.modules.stock.service.StockInLineService | |||||
| import org.slf4j.LoggerFactory | |||||
| import org.springframework.stereotype.Service | |||||
| import org.springframework.transaction.support.TransactionTemplate | |||||
| import java.time.LocalDateTime | |||||
| import java.util.concurrent.atomic.AtomicBoolean | |||||
| /** | |||||
| * Daily batch after plan day ends: at run time (default 00:00:15), process job orders whose | |||||
| * [JobOrder.planStart] fell on the previous calendar day. | |||||
| * | |||||
| * - Branch A: no pick submitted, product process pending → hide job order. | |||||
| * - Branch B: pick submitted, product process still pending → reschedule to today 00:00:00 and renumber. | |||||
| */ | |||||
| @Service | |||||
| open class JobOrderPlanStartAutoService( | |||||
| private val jobOrderRepository: JobOrderRepository, | |||||
| private val pickOrderRepository: PickOrderRepository, | |||||
| private val productProcessRepository: ProductProcessRepository, | |||||
| private val stockInLineRepository: StockInLineRepository, | |||||
| private val jobOrderService: JobOrderService, | |||||
| private val stockInLineService: StockInLineService, | |||||
| private val transactionTemplate: TransactionTemplate, | |||||
| ) { | |||||
| private val logger = LoggerFactory.getLogger(javaClass) | |||||
| private val inFlight = AtomicBoolean(false) | |||||
| data class JobOrderPlanStartAutoReport( | |||||
| val runAt: LocalDateTime, | |||||
| val targetPlanDayFrom: LocalDateTime, | |||||
| val targetPlanDayToExclusive: LocalDateTime, | |||||
| val candidates: Int = 0, | |||||
| val hidden: Int = 0, | |||||
| val rescheduled: Int = 0, | |||||
| val skipped: Int = 0, | |||||
| val errors: Int = 0, | |||||
| ) | |||||
| open fun runAutoProcess(runAt: LocalDateTime = LocalDateTime.now()): JobOrderPlanStartAutoReport { | |||||
| if (!inFlight.compareAndSet(false, true)) { | |||||
| logger.warn("Job order plan-start auto process skipped: previous run still in flight") | |||||
| val targetDay = runAt.toLocalDate().minusDays(1) | |||||
| return JobOrderPlanStartAutoReport( | |||||
| runAt = runAt, | |||||
| targetPlanDayFrom = targetDay.atStartOfDay(), | |||||
| targetPlanDayToExclusive = targetDay.plusDays(1).atStartOfDay(), | |||||
| ) | |||||
| } | |||||
| try { | |||||
| return runAutoProcessInternal(runAt) | |||||
| } finally { | |||||
| inFlight.set(false) | |||||
| } | |||||
| } | |||||
| private fun runAutoProcessInternal(runAt: LocalDateTime): JobOrderPlanStartAutoReport { | |||||
| val targetDay = runAt.toLocalDate().minusDays(1) | |||||
| val from = targetDay.atStartOfDay() | |||||
| val toExclusive = targetDay.plusDays(1).atStartOfDay() | |||||
| val newPlanStart = runAt.toLocalDate().atStartOfDay() | |||||
| var hidden = 0 | |||||
| var rescheduled = 0 | |||||
| var skipped = 0 | |||||
| var errors = 0 | |||||
| val jobOrders = jobOrderRepository | |||||
| .findByDeletedFalseAndPlanStartFromBeforeExclusiveOrderByIdAsc(from, toExclusive) | |||||
| .filter { isEligibleCandidate(it) } | |||||
| val joIds = jobOrders.mapNotNull { it.id } | |||||
| val pickOrdersByJoId = loadPickOrdersByJobOrderId(joIds) | |||||
| val productProcessesByJoId = loadProductProcessesByJobOrderId(joIds) | |||||
| logger.info( | |||||
| "Job order plan-start auto: runAt={}, targetPlanDay=[{}, {}), candidates={}", | |||||
| runAt, | |||||
| from, | |||||
| toExclusive, | |||||
| jobOrders.size, | |||||
| ) | |||||
| for (jo in jobOrders) { | |||||
| val joId = jo.id ?: continue | |||||
| try { | |||||
| when ( | |||||
| classify( | |||||
| jo, | |||||
| pickOrdersByJoId[joId].orEmpty(), | |||||
| productProcessesByJoId[joId], | |||||
| ) | |||||
| ) { | |||||
| Branch.HIDE -> { | |||||
| transactionTemplate.executeWithoutResult { | |||||
| applyHide(jo, runAt) | |||||
| } | |||||
| hidden++ | |||||
| } | |||||
| Branch.RESCHEDULE -> { | |||||
| transactionTemplate.executeWithoutResult { | |||||
| applyReschedule(jo, productProcessesByJoId[joId], newPlanStart, runAt) | |||||
| } | |||||
| rescheduled++ | |||||
| } | |||||
| Branch.SKIP -> skipped++ | |||||
| } | |||||
| } catch (e: Exception) { | |||||
| errors++ | |||||
| logger.error("Job order plan-start auto failed for joId={} code={}: {}", joId, jo.code, e.message, e) | |||||
| } | |||||
| } | |||||
| val report = JobOrderPlanStartAutoReport( | |||||
| runAt = runAt, | |||||
| targetPlanDayFrom = from, | |||||
| targetPlanDayToExclusive = toExclusive, | |||||
| candidates = jobOrders.size, | |||||
| hidden = hidden, | |||||
| rescheduled = rescheduled, | |||||
| skipped = skipped, | |||||
| errors = errors, | |||||
| ) | |||||
| logger.info("Job order plan-start auto finished: {}", report) | |||||
| return report | |||||
| } | |||||
| private fun isEligibleCandidate(jo: JobOrder): Boolean { | |||||
| if (jo.isHidden == true) return false | |||||
| if (jo.status == JobOrderStatus.COMPLETED) return false | |||||
| return true | |||||
| } | |||||
| private enum class Branch { | |||||
| HIDE, | |||||
| RESCHEDULE, | |||||
| SKIP, | |||||
| } | |||||
| private fun classify( | |||||
| jo: JobOrder, | |||||
| pickOrders: List<PickOrder>, | |||||
| productProcess: ProductProcess?, | |||||
| ): Branch { | |||||
| if (!isProductProcessPendingNotStarted(productProcess)) { | |||||
| return Branch.SKIP | |||||
| } | |||||
| val maxSubmittedLines = pickOrders.maxOfOrNull { it.submittedLines ?: 0 } ?: 0 | |||||
| return when { | |||||
| maxSubmittedLines == 0 -> Branch.HIDE | |||||
| maxSubmittedLines > 0 -> Branch.RESCHEDULE | |||||
| else -> Branch.SKIP | |||||
| } | |||||
| } | |||||
| private fun isProductProcessPendingNotStarted(productProcess: ProductProcess?): Boolean { | |||||
| if (productProcess == null) return false | |||||
| if (productProcess.deleted) return false | |||||
| if (productProcess.status != ProductProcessStatus.PENDING) return false | |||||
| if (productProcess.startTime != null) return false | |||||
| return true | |||||
| } | |||||
| private fun loadPickOrdersByJobOrderId(jobOrderIds: List<Long>): Map<Long, List<PickOrder>> { | |||||
| if (jobOrderIds.isEmpty()) return emptyMap() | |||||
| return pickOrderRepository | |||||
| .findAllByJobOrder_IdInAndDeletedFalseOrderByJobOrder_IdAscCreatedDesc(jobOrderIds) | |||||
| .groupBy { it.jobOrder?.id ?: -1L } | |||||
| .filterKeys { it > 0L } | |||||
| } | |||||
| private fun loadProductProcessesByJobOrderId(jobOrderIds: List<Long>): Map<Long, ProductProcess> { | |||||
| if (jobOrderIds.isEmpty()) return emptyMap() | |||||
| return productProcessRepository | |||||
| .findByJobOrder_IdInAndDeletedIsFalse(jobOrderIds) | |||||
| .mapNotNull { pp -> pp.jobOrder?.id?.let { it to pp } } | |||||
| .groupBy { it.first } | |||||
| .mapValues { (_, entries) -> | |||||
| entries.map { it.second }.firstOrNull { isProductProcessPendingNotStarted(it) } | |||||
| ?: entries.map { it.second }.first() | |||||
| } | |||||
| .filterValues { it != null } | |||||
| .mapValues { it.value!! } | |||||
| } | |||||
| private fun applyHide(jo: JobOrder, runAt: LocalDateTime) { | |||||
| jo.isHidden = true | |||||
| appendRemarks(jo, "[auto ${runAt.toLocalDate()}] hidden: overdue plan day, no pick submitted, process pending") | |||||
| jobOrderRepository.save(jo) | |||||
| logger.info("Job order plan-start auto hid joId={} code={}", jo.id, jo.code) | |||||
| } | |||||
| private fun applyReschedule( | |||||
| jo: JobOrder, | |||||
| productProcess: ProductProcess?, | |||||
| newPlanStart: LocalDateTime, | |||||
| runAt: LocalDateTime, | |||||
| ) { | |||||
| val pp = productProcess?.takeIf { isProductProcessPendingNotStarted(it) } | |||||
| ?: throw IllegalStateException("Product process not pending for reschedule, joId=${jo.id}") | |||||
| val newCode = jobOrderService.assignJobNo(newPlanStart) | |||||
| jo.planStart = newPlanStart | |||||
| jo.code = newCode | |||||
| appendRemarks( | |||||
| jo, | |||||
| "[auto ${runAt.toLocalDate()}] rescheduled from overdue plan day; pick started, process pending", | |||||
| ) | |||||
| jobOrderRepository.save(jo) | |||||
| pp.date = newPlanStart.toLocalDate() | |||||
| productProcessRepository.save(pp) | |||||
| val sil = jo.id?.let { stockInLineRepository.findFirstByJobOrder_IdAndDeletedFalse(it) } | |||||
| if (sil != null) { | |||||
| sil.lotNo = stockInLineService.assignLotNoForJo(newPlanStart.toLocalDate()) | |||||
| sil.productLotNo = newCode | |||||
| stockInLineRepository.save(sil) | |||||
| } | |||||
| logger.info( | |||||
| "Job order plan-start auto rescheduled joId={} newCode={} newPlanStart={}", | |||||
| jo.id, | |||||
| newCode, | |||||
| newPlanStart, | |||||
| ) | |||||
| } | |||||
| private fun appendRemarks(jo: JobOrder, snippet: String) { | |||||
| val existing = jo.remarks?.trim().orEmpty() | |||||
| jo.remarks = if (existing.isEmpty()) snippet else "$existing | $snippet" | |||||
| } | |||||
| } | |||||
| @@ -1367,15 +1367,18 @@ class PlasticBagPrinterService( | |||||
| } | } | ||||
| val qrValue = zplEscape(qrPayload) | val qrValue = zplEscape(qrPayload) | ||||
| // Must match python Bag2.py generate_zpl_dataflex() | |||||
| // Must match python Bag3.py generate_zpl_dataflex() field layout / fonts. | |||||
| val fontRegular = "E:STXihei.ttf" | val fontRegular = "E:STXihei.ttf" | ||||
| val fontBold = "E:STXihei.ttf" | val fontBold = "E:STXihei.ttf" | ||||
| // Match python Bag3.py DataFlex defaults: narrower ^PW so job preview is not mostly empty on the right (^A@R fields are tall, not wide). | |||||
| val labelPw = 400 | |||||
| val labelLl = 500 | |||||
| return """ | return """ | ||||
| ^XA | ^XA | ||||
| ^CI28 | ^CI28 | ||||
| ^PW700 | |||||
| ^LL500 | |||||
| ^PW$labelPw | |||||
| ^LL$labelLl | |||||
| ^PO N | ^PO N | ||||
| ^FO10,20 | ^FO10,20 | ||||
| ^BQN,2,4^FDQA,$qrValue^FS | ^BQN,2,4^FDQA,$qrValue^FS | ||||
| @@ -0,0 +1,33 @@ | |||||
| package com.ffii.fpsms.modules.logistic.entity | |||||
| import com.ffii.core.entity.BaseEntity | |||||
| import jakarta.persistence.Column | |||||
| import jakarta.persistence.Entity | |||||
| import jakarta.persistence.Table | |||||
| import jakarta.validation.constraints.NotNull | |||||
| import jakarta.validation.constraints.Size | |||||
| @Entity | |||||
| @Table(name = "logistic") | |||||
| open class Logistic : BaseEntity<Long>() { | |||||
| @field:NotNull | |||||
| @field:Size(max = 255) | |||||
| @Column(name = "logisticName", nullable = false, length = 255) | |||||
| open var logisticName: String? = null | |||||
| @field:NotNull | |||||
| @field:Size(max = 50) | |||||
| @Column(name = "carPlate", nullable = false, length = 50) | |||||
| open var carPlate: String? = null | |||||
| @field:NotNull | |||||
| @field:Size(max = 255) | |||||
| @Column(name = "driverName", nullable = false, length = 255) | |||||
| open var driverName: String? = null | |||||
| @field:NotNull | |||||
| @Column(name = "driverNumber", nullable = false) | |||||
| open var driverNumber: Int? = null | |||||
| } | |||||
| @@ -0,0 +1,12 @@ | |||||
| package com.ffii.fpsms.modules.logistic.entity | |||||
| import com.ffii.core.support.AbstractRepository | |||||
| import org.springframework.stereotype.Repository | |||||
| @Repository | |||||
| interface LogisticRepository : AbstractRepository<Logistic, Long> { | |||||
| fun findAllByDeletedFalseOrderByIdAsc(): List<Logistic> | |||||
| fun findByIdAndDeletedFalse(id: Long): Logistic? | |||||
| fun findByCarPlateAndDeletedFalse(carPlate: String): Logistic? | |||||
| } | |||||
| @@ -0,0 +1,82 @@ | |||||
| package com.ffii.fpsms.modules.logistic.service | |||||
| import com.ffii.fpsms.modules.logistic.entity.Logistic | |||||
| import com.ffii.fpsms.modules.logistic.entity.LogisticRepository | |||||
| import com.ffii.fpsms.modules.logistic.web.models.SaveLogisticRequest | |||||
| import jakarta.transaction.Transactional | |||||
| import org.springframework.stereotype.Service | |||||
| import org.springframework.web.server.ResponseStatusException | |||||
| import org.springframework.http.HttpStatus | |||||
| @Service | |||||
| open class LogisticService( | |||||
| private val logisticRepository: LogisticRepository, | |||||
| ) { | |||||
| open fun findAll(): List<Logistic> { | |||||
| return logisticRepository.findAllByDeletedFalseOrderByIdAsc() | |||||
| } | |||||
| open fun findById(id: Long): Logistic? { | |||||
| return logisticRepository.findByIdAndDeletedFalse(id) | |||||
| } | |||||
| open fun requireById(id: Long): Logistic { | |||||
| return logisticRepository.findByIdAndDeletedFalse(id) | |||||
| ?: throw ResponseStatusException(HttpStatus.NOT_FOUND, "Logistic not found with id: $id") | |||||
| } | |||||
| @Transactional | |||||
| open fun save(request: SaveLogisticRequest): Logistic { | |||||
| val entity = request.id?.let { requireById(it) } ?: Logistic() | |||||
| entity.apply { | |||||
| logisticName = request.logisticName.trim() | |||||
| carPlate = request.carPlate.trim() | |||||
| driverName = request.driverName.trim() | |||||
| driverNumber = request.driverNumber | |||||
| } | |||||
| return logisticRepository.save(entity) | |||||
| } | |||||
| /** | |||||
| * 批次「新增」物流主檔:同一交易內寫入,任一筆失敗則整批 rollback。 | |||||
| * 供看板一次儲存多筆暫存主檔,避免逐筆 POST 中途失敗留下孤兒列。 | |||||
| */ | |||||
| @Transactional | |||||
| open fun saveBatchCreate(requests: List<SaveLogisticRequest>): List<Logistic> { | |||||
| if (requests.isEmpty()) return emptyList() | |||||
| if (requests.size > 100) { | |||||
| throw ResponseStatusException( | |||||
| HttpStatus.BAD_REQUEST, | |||||
| "Batch size exceeds limit (100)", | |||||
| ) | |||||
| } | |||||
| requests.forEach { r -> | |||||
| if (r.id != null) { | |||||
| throw ResponseStatusException( | |||||
| HttpStatus.BAD_REQUEST, | |||||
| "save-batch only accepts new rows (id must be null)", | |||||
| ) | |||||
| } | |||||
| } | |||||
| return requests.map { req -> | |||||
| val entity = Logistic().apply { | |||||
| logisticName = req.logisticName.trim() | |||||
| carPlate = req.carPlate.trim() | |||||
| driverName = req.driverName.trim() | |||||
| driverNumber = req.driverNumber | |||||
| } | |||||
| logisticRepository.save(entity) | |||||
| } | |||||
| } | |||||
| @Transactional | |||||
| open fun deleteById(id: Long): String { | |||||
| val entity = requireById(id) | |||||
| entity.deleted = true | |||||
| logisticRepository.save(entity) | |||||
| return "Logistic deleted successfully with id: $id" | |||||
| } | |||||
| } | |||||
| @@ -0,0 +1,65 @@ | |||||
| package com.ffii.fpsms.modules.logistic.web | |||||
| import com.ffii.fpsms.modules.logistic.service.LogisticService | |||||
| import com.ffii.fpsms.modules.logistic.web.models.DeleteLogisticRequest | |||||
| import com.ffii.fpsms.modules.logistic.web.models.LogisticResponse | |||||
| import com.ffii.fpsms.modules.logistic.web.models.SaveLogisticRequest | |||||
| import com.ffii.fpsms.modules.logistic.web.models.SaveLogisticsBatchRequest | |||||
| import com.ffii.fpsms.modules.master.web.models.MessageResponse | |||||
| import jakarta.validation.Valid | |||||
| import org.springframework.http.ResponseEntity | |||||
| import org.springframework.web.bind.annotation.* | |||||
| @RestController | |||||
| @RequestMapping("/logistic") | |||||
| class LogisticController( | |||||
| private val logisticService: LogisticService, | |||||
| ) { | |||||
| @GetMapping("/all") | |||||
| fun findAll(): List<LogisticResponse> { | |||||
| return logisticService.findAll().map { it.toResponse() } | |||||
| } | |||||
| @GetMapping("/{id}") | |||||
| fun findById(@PathVariable id: Long): LogisticResponse { | |||||
| return logisticService.requireById(id).toResponse() | |||||
| } | |||||
| @PostMapping("/save") | |||||
| fun save(@Valid @RequestBody request: SaveLogisticRequest): LogisticResponse { | |||||
| return logisticService.save(request).toResponse() | |||||
| } | |||||
| /** 批次新增主檔;單一 transaction,與 [save] 分開避免誤用 id 更新混進批次。 */ | |||||
| @PostMapping("/save-batch") | |||||
| fun saveBatch(@Valid @RequestBody body: SaveLogisticsBatchRequest): List<LogisticResponse> { | |||||
| return logisticService.saveBatchCreate(body.items).map { it.toResponse() } | |||||
| } | |||||
| @PostMapping("/delete") | |||||
| fun delete(@Valid @RequestBody request: DeleteLogisticRequest): ResponseEntity<MessageResponse> { | |||||
| val result = logisticService.deleteById(request.id) | |||||
| return ResponseEntity.ok( | |||||
| MessageResponse( | |||||
| id = request.id, | |||||
| name = null, | |||||
| code = null, | |||||
| type = "logistic", | |||||
| message = result, | |||||
| errorPosition = null, | |||||
| entity = null, | |||||
| ) | |||||
| ) | |||||
| } | |||||
| private fun com.ffii.fpsms.modules.logistic.entity.Logistic.toResponse(): LogisticResponse { | |||||
| return LogisticResponse( | |||||
| id = this.id ?: 0L, | |||||
| logisticName = this.logisticName ?: "", | |||||
| carPlate = this.carPlate ?: "", | |||||
| driverName = this.driverName ?: "", | |||||
| driverNumber = this.driverNumber ?: 0, | |||||
| ) | |||||
| } | |||||
| } | |||||
| @@ -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, | |||||
| ) | |||||
| @@ -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, | |||||
| ) | |||||
| @@ -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, | |||||
| ) | |||||
| @@ -0,0 +1,12 @@ | |||||
| package com.ffii.fpsms.modules.logistic.web.models | |||||
| import jakarta.validation.Valid | |||||
| import jakarta.validation.constraints.NotEmpty | |||||
| import jakarta.validation.constraints.Size | |||||
| data class SaveLogisticsBatchRequest( | |||||
| @field:NotEmpty | |||||
| @field:Size(max = 100) | |||||
| @field:Valid | |||||
| val items: List<SaveLogisticRequest>, | |||||
| ) | |||||
| @@ -6,6 +6,7 @@ import com.ffii.fpsms.modules.master.entity.projections.ShopCombo | |||||
| import com.ffii.fpsms.modules.master.enums.ShopType | import com.ffii.fpsms.modules.master.enums.ShopType | ||||
| import com.ffii.fpsms.modules.pickOrder.entity.Truck | import com.ffii.fpsms.modules.pickOrder.entity.Truck | ||||
| import org.springframework.data.jpa.repository.Query | import org.springframework.data.jpa.repository.Query | ||||
| import org.springframework.data.repository.query.Param | |||||
| import org.springframework.stereotype.Repository | import org.springframework.stereotype.Repository | ||||
| @Repository | @Repository | ||||
| @@ -30,6 +31,15 @@ interface ShopRepository : AbstractRepository<Shop, Long> { | |||||
| fun findByCode(code: String): Shop? | fun findByCode(code: String): Shop? | ||||
| @Query( | |||||
| """ | |||||
| SELECT s FROM Shop s | |||||
| WHERE s.deleted = false | |||||
| AND s.code IN :codes | |||||
| """ | |||||
| ) | |||||
| fun findAllByCodeInAndDeletedIsFalse(@Param("codes") codes: Collection<String>): List<Shop> | |||||
| @Query( | @Query( | ||||
| nativeQuery = true, | nativeQuery = true, | ||||
| value = """ | value = """ | ||||
| @@ -34,6 +34,15 @@ import java.util.Comparator | |||||
| import com.ffii.fpsms.modules.jobOrder.entity.JobOrderRepository | import com.ffii.fpsms.modules.jobOrder.entity.JobOrderRepository | ||||
| import com.ffii.fpsms.modules.productProcess.entity.ProductProcessRepository | import com.ffii.fpsms.modules.productProcess.entity.ProductProcessRepository | ||||
| import org.springframework.transaction.annotation.Transactional | import org.springframework.transaction.annotation.Transactional | ||||
| import com.fasterxml.jackson.databind.ObjectMapper | |||||
| import com.ffii.fpsms.m18.service.M18BomForShopService | |||||
| import com.ffii.fpsms.m18.model.M18BomShopSyncTriggerResult | |||||
| import com.ffii.fpsms.m18.model.GoodsReceiptNoteResponse | |||||
| import com.ffii.fpsms.m18.entity.M18BomShopSyncLog | |||||
| import com.ffii.fpsms.m18.entity.M18BomShopSyncLogRepository | |||||
| import com.ffii.fpsms.modules.common.SettingNames | |||||
| import com.ffii.fpsms.modules.settings.entity.Settings | |||||
| import com.ffii.fpsms.modules.settings.service.SettingsService | |||||
| @Service | @Service | ||||
| open class BomService( | open class BomService( | ||||
| @@ -52,6 +61,10 @@ open class BomService( | |||||
| private val itemUomService: ItemUomService, | private val itemUomService: ItemUomService, | ||||
| private val jobOrderRepository: JobOrderRepository, | private val jobOrderRepository: JobOrderRepository, | ||||
| private val productProcessRepository: ProductProcessRepository, | private val productProcessRepository: ProductProcessRepository, | ||||
| private val m18BomForShopService: M18BomForShopService, | |||||
| private val m18BomShopSyncLogRepository: M18BomShopSyncLogRepository, | |||||
| private val objectMapper: ObjectMapper, | |||||
| private val settingsService: SettingsService, | |||||
| @Value("\${bom.import.temp-dir:\${java.io.tmpdir}/fpsms-bom-import}") private val bomImportTempDir: String, | @Value("\${bom.import.temp-dir:\${java.io.tmpdir}/fpsms-bom-import}") private val bomImportTempDir: String, | ||||
| ) { | ) { | ||||
| open fun uploadBomFiles(files: List<MultipartFile>): BomUploadResponse { | open fun uploadBomFiles(files: List<MultipartFile>): BomUploadResponse { | ||||
| @@ -119,6 +132,29 @@ open class BomService( | |||||
| ?: bomRepository.findAllByItemIdAndDeletedIsFalse(itemId).firstOrNull() | ?: bomRepository.findAllByItemIdAndDeletedIsFalse(itemId).firstOrNull() | ||||
| } | } | ||||
| /** Resolve BOM header for a finished-good item code ([Items.code] on [Bom.item]). */ | |||||
| open fun findBomSummaryByItemCode(itemCodeTrimmed: String): BomIdByItemCodeResponse { | |||||
| val code = itemCodeTrimmed.trim() | |||||
| val item = itemsRepository.findByCodeAndDeletedFalse(code) | |||||
| ?: return BomIdByItemCodeResponse( | |||||
| itemCode = code, | |||||
| message = "Item not found for code", | |||||
| ) | |||||
| val bom = findByItemId(item.id!!) | |||||
| ?: return BomIdByItemCodeResponse( | |||||
| itemCode = code, | |||||
| itemId = item.id, | |||||
| message = "No BOM linked to this item", | |||||
| ) | |||||
| return BomIdByItemCodeResponse( | |||||
| itemCode = code, | |||||
| itemId = item.id, | |||||
| bomId = bom.id, | |||||
| bomCode = bom.code, | |||||
| bomM18Id = bom.m18Id, | |||||
| ) | |||||
| } | |||||
| open fun saveBom(request: SaveBomRequest): SaveBomResponse { | open fun saveBom(request: SaveBomRequest): SaveBomResponse { | ||||
| val item = request.code.let { itemsService.findByM18BomCode(it) } ?: request.itemId?.let { itemsService.findById(it) } | val item = request.code.let { itemsService.findByM18BomCode(it) } ?: request.itemId?.let { itemsService.findById(it) } | ||||
| @@ -371,6 +407,114 @@ open class BomService( | |||||
| return getBomDetail(bom.id!!) | return getBomDetail(bom.id!!) | ||||
| } | } | ||||
| /** | |||||
| * When {@link SettingNames#M18_BOM_SHOP_SYNC_ENABLED} is true, push BOM to M18 udfBomForShop. | |||||
| * Use {@code POST /m18/test/bom-shop-sync/{bomId}} (or future UI) to trigger explicitly. | |||||
| * Optional [m18HeaderId]: M18 udfBomForShop **header** record id (e.g. [BomIdByItemCodeResponse.bomM18Id]) | |||||
| * to force **update** when [Bom.m18Id] is missing or stale. When null, uses [Bom.m18Id] if set. | |||||
| */ | |||||
| open fun pushBomToM18ShopIfAllowed(bomId: Long, m18HeaderId: Long? = null): M18BomShopSyncTriggerResult { | |||||
| if (!isM18BomShopSyncEnabled()) { | |||||
| return M18BomShopSyncTriggerResult( | |||||
| bomId = bomId, | |||||
| synced = false, | |||||
| skippedReason = "M18 BOM shop sync disabled (${SettingNames.M18_BOM_SHOP_SYNC_ENABLED} is not true)", | |||||
| ) | |||||
| } | |||||
| val bom = bomRepository.findByIdAndDeletedIsFalse(bomId) | |||||
| ?: return M18BomShopSyncTriggerResult(bomId, false, skippedReason = "BOM not found") | |||||
| val req = m18BomForShopService.buildSaveRequest(bom, m18HeaderId) | |||||
| ?: return M18BomShopSyncTriggerResult(bomId, false, skippedReason = "Cannot build M18 payload (missing m18 UOM/item ids or no materials)") | |||||
| val requestJsonPayload = m18BomForShopService.toJson(req) | |||||
| var resp: GoodsReceiptNoteResponse? = null | |||||
| var callError: Throwable? = null | |||||
| try { | |||||
| resp = m18BomForShopService.saveBomForShop(req) | |||||
| } catch (e: Exception) { | |||||
| callError = e | |||||
| } | |||||
| val responseJsonPayload = when { | |||||
| resp != null -> m18BomForShopService.toJson(resp) | |||||
| callError != null -> | |||||
| runCatching { | |||||
| objectMapper.writeValueAsString( | |||||
| mapOf( | |||||
| "exceptionType" to callError.javaClass.name, | |||||
| "message" to (callError.message ?: ""), | |||||
| ), | |||||
| ) | |||||
| }.getOrElse { """{"error":"failed to serialize exception"}""" } | |||||
| else -> """{"error":"M18 API returned null"}""" | |||||
| } | |||||
| val msgSummary = resp?.messages?.joinToString("; ") { it.msgDetail ?: it.msgCode ?: "" }.orEmpty() | |||||
| val apiStatus = resp?.status == true | |||||
| val recordId = resp?.recordId ?: 0L | |||||
| val result = when { | |||||
| callError != null -> | |||||
| M18BomShopSyncTriggerResult( | |||||
| bomId = bomId, | |||||
| synced = false, | |||||
| skippedReason = callError.message ?: "M18 API call failed", | |||||
| status = false, | |||||
| messageSummary = callError.message, | |||||
| ) | |||||
| resp == null -> | |||||
| M18BomShopSyncTriggerResult(bomId, false, skippedReason = "M18 API returned null") | |||||
| resp.status == true && resp.recordId > 0L -> { | |||||
| bom.m18Id = resp.recordId | |||||
| bomRepository.saveAndFlush(bom) | |||||
| M18BomShopSyncTriggerResult( | |||||
| bomId = bomId, | |||||
| synced = true, | |||||
| recordId = resp.recordId, | |||||
| status = true, | |||||
| messageSummary = msgSummary.ifBlank { null }, | |||||
| ) | |||||
| } | |||||
| else -> | |||||
| M18BomShopSyncTriggerResult( | |||||
| bomId = bomId, | |||||
| synced = false, | |||||
| skippedReason = "M18 save failed or status=false", | |||||
| recordId = resp.recordId.takeIf { it > 0 }, | |||||
| status = resp.status, | |||||
| messageSummary = msgSummary.ifBlank { null }, | |||||
| ) | |||||
| } | |||||
| val logMessage = listOfNotNull( | |||||
| msgSummary.ifBlank { null }, | |||||
| callError?.message, | |||||
| result.skippedReason?.takeIf { !result.synced }, | |||||
| ).joinToString("; ").take(4000) | |||||
| m18BomShopSyncLogRepository.save( | |||||
| M18BomShopSyncLog().apply { | |||||
| this.bomId = bomId | |||||
| finishedItemCode = req.udfbomforshop.values.firstOrNull()?.udfBomCode | |||||
| m18HeaderCode = req.udfbomforshop.values.firstOrNull()?.code | |||||
| requestFingerprint = m18BomForShopService.contentFingerprint(req) | |||||
| m18RecordId = recordId.takeIf { it > 0 } | |||||
| m18ApiStatus = apiStatus | |||||
| synced = result.synced | |||||
| message = logMessage.ifBlank { null } | |||||
| requestJson = requestJsonPayload | |||||
| responseJson = responseJsonPayload | |||||
| }, | |||||
| ) | |||||
| return result | |||||
| } | |||||
| private fun isM18BomShopSyncEnabled(): Boolean = | |||||
| settingsService.findByName(SettingNames.M18_BOM_SHOP_SYNC_ENABLED) | |||||
| .map { Settings.VALUE_BOOLEAN_TRUE == it.value } | |||||
| .orElse(false) | |||||
| private fun resolveEquipmentForBomProcess(pReq: EditBomProcessRequest): Equipment { | private fun resolveEquipmentForBomProcess(pReq: EditBomProcessRequest): Equipment { | ||||
| val equipmentId = pReq.equipmentId | val equipmentId = pReq.equipmentId | ||||
| val equipmentCode = pReq.equipmentCode?.trim().orEmpty() | val equipmentCode = pReq.equipmentCode?.trim().orEmpty() | ||||
| @@ -282,6 +282,20 @@ open class ItemUomService( | |||||
| return finalizePreciseStockQty(stockUnit, stockQty) | return finalizePreciseStockQty(stockUnit, stockQty) | ||||
| } | } | ||||
| /** | |||||
| * Convert quantity from [uomId] (must exist on `item_uom` for [itemId]) to the item's **base unit** quantity. | |||||
| * Returns null when no `item_uom` row links the item to that UOM. | |||||
| */ | |||||
| open fun convertQtyToBaseQtyPrecise(itemId: Long, uomId: Long, sourceQty: BigDecimal): BigDecimal? { | |||||
| val itemUom = findFirstByItemIdAndUomId(itemId, uomId) ?: return null | |||||
| val one = BigDecimal.ONE | |||||
| val calcScale = 10 | |||||
| return sourceQty | |||||
| .multiply(itemUom.ratioN ?: one) | |||||
| .divide(itemUom.ratioD ?: one, calcScale, RoundingMode.HALF_UP) | |||||
| .stripTrailingZeros() | |||||
| } | |||||
| // See if need to update the response | // See if need to update the response | ||||
| open fun saveItemUom(request: ItemUomRequest): ItemUom { | open fun saveItemUom(request: ItemUomRequest): ItemUom { | ||||
| val itemUom = request.m18Id?.let { itemUomRespository.findFirstByM18IdOrderByIdDesc(it) } | val itemUom = request.m18Id?.let { itemUomRespository.findFirstByM18IdOrderByIdDesc(it) } | ||||
| @@ -29,6 +29,8 @@ import com.ffii.fpsms.modules.master.web.models.ImportBomRequestPayload | |||||
| import com.ffii.fpsms.modules.master.web.models.BomDetailResponse | import com.ffii.fpsms.modules.master.web.models.BomDetailResponse | ||||
| import com.ffii.fpsms.modules.master.web.models.EditBomRequest | import com.ffii.fpsms.modules.master.web.models.EditBomRequest | ||||
| import com.ffii.fpsms.modules.master.web.models.BomExcelCheckProgress | import com.ffii.fpsms.modules.master.web.models.BomExcelCheckProgress | ||||
| import com.ffii.fpsms.modules.master.web.models.BomIdByItemCodeResponse | |||||
| import com.ffii.core.exception.BadRequestException | |||||
| import java.util.logging.Logger | import java.util.logging.Logger | ||||
| import java.nio.file.Files | import java.nio.file.Files | ||||
| import org.springframework.core.io.FileSystemResource | import org.springframework.core.io.FileSystemResource | ||||
| @@ -120,6 +122,16 @@ fun downloadBomFormatIssueLog( | |||||
| // fun exportProblematicBom() { | // fun exportProblematicBom() { | ||||
| // return bomService.importBOM() | // return bomService.importBOM() | ||||
| // } | // } | ||||
| /** Testing: FPSMS BOM id by finished-good item code (same item as BOM header). */ | |||||
| @GetMapping("/by-item-code") | |||||
| fun getBomByItemCode(@RequestParam code: String): BomIdByItemCodeResponse { | |||||
| if (code.isBlank()) { | |||||
| throw BadRequestException("query parameter code is required") | |||||
| } | |||||
| return bomService.findBomSummaryByItemCode(code.trim()) | |||||
| } | |||||
| @GetMapping("/{id}/detail") | @GetMapping("/{id}/detail") | ||||
| fun getBomDetail(@PathVariable id: Long): BomDetailResponse { | fun getBomDetail(@PathVariable id: Long): BomDetailResponse { | ||||
| return bomService.getBomDetail(id) | return bomService.getBomDetail(id) | ||||
| @@ -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, | |||||
| ) | |||||
| @@ -1,6 +1,7 @@ | |||||
| package com.ffii.fpsms.modules.pickOrder.entity | package com.ffii.fpsms.modules.pickOrder.entity | ||||
| import com.ffii.core.entity.BaseEntity | import com.ffii.core.entity.BaseEntity | ||||
| import com.ffii.fpsms.modules.logistic.entity.Logistic | |||||
| import com.ffii.fpsms.modules.master.entity.Shop | import com.ffii.fpsms.modules.master.entity.Shop | ||||
| import jakarta.persistence.* | import jakarta.persistence.* | ||||
| import jakarta.validation.constraints.NotNull | import jakarta.validation.constraints.NotNull | ||||
| @@ -42,4 +43,8 @@ open class Truck : BaseEntity<Long>() { | |||||
| @Column(name = "remark") | @Column(name = "remark") | ||||
| open var remark: String? = null | open var remark: String? = null | ||||
| } | |||||
| @ManyToOne(fetch = FetchType.LAZY) | |||||
| @JoinColumn(name = "logisticId") | |||||
| open var logistic: Logistic? = null | |||||
| } | |||||
| @@ -0,0 +1,19 @@ | |||||
| package com.ffii.fpsms.modules.pickOrder.entity | |||||
| import com.ffii.core.entity.BaseEntity | |||||
| import jakarta.persistence.* | |||||
| import jakarta.validation.constraints.Size | |||||
| @Entity | |||||
| @Table(name = "truck_lane_version") | |||||
| open class TruckLaneVersion : BaseEntity<Long>() { | |||||
| @field:Size(max = 100) | |||||
| @Column(name = "truckLanceCode", nullable = true, length = 100) | |||||
| open var truckLanceCode: String? = null | |||||
| @field:Size(max = 500) | |||||
| @Column(name = "note", length = 500) | |||||
| open var note: String? = null | |||||
| } | |||||
| @@ -0,0 +1,55 @@ | |||||
| package com.ffii.fpsms.modules.pickOrder.entity | |||||
| import com.ffii.core.entity.BaseEntity | |||||
| import jakarta.persistence.* | |||||
| import jakarta.validation.constraints.NotNull | |||||
| import jakarta.validation.constraints.Size | |||||
| @Entity | |||||
| @Table(name = "truck_lane_version_line") | |||||
| open class TruckLaneVersionLine : BaseEntity<Long>() { | |||||
| @ManyToOne(fetch = FetchType.LAZY) | |||||
| @JoinColumn(name = "truckLaneVersionId", nullable = false) | |||||
| open var truckLaneVersion: TruckLaneVersion? = null | |||||
| @field:NotNull | |||||
| @Column(name = "truckRowId", nullable = false) | |||||
| open var truckRowId: Long? = null | |||||
| @field:Size(max = 100) | |||||
| @Column(name = "truckLanceCode", length = 100) | |||||
| open var truckLanceCode: String? = null | |||||
| @field:Size(max = 50) | |||||
| @Column(name = "shopCode", length = 50) | |||||
| open var shopCode: String? = null | |||||
| @field:Size(max = 255) | |||||
| @Column(name = "branchName", length = 255) | |||||
| open var branchName: String? = null | |||||
| @field:Size(max = 255) | |||||
| @Column(name = "districtReference", length = 255) | |||||
| open var districtReference: String? = null | |||||
| @Column(name = "loadingSequence") | |||||
| open var loadingSequence: Int? = null | |||||
| @field:Size(max = 30) | |||||
| @Column(name = "departureTime", length = 30) | |||||
| open var departureTime: String? = null | |||||
| @field:NotNull | |||||
| @field:Size(max = 10) | |||||
| @Column(name = "storeId", nullable = false, length = 10) | |||||
| open var storeId: String? = null | |||||
| @field:Size(max = 255) | |||||
| @Column(name = "remark", length = 255) | |||||
| open var remark: String? = null | |||||
| @Column(name = "logisticId") | |||||
| open var logisticId: Long? = null | |||||
| } | |||||
| @@ -0,0 +1,10 @@ | |||||
| package com.ffii.fpsms.modules.pickOrder.entity | |||||
| import com.ffii.core.support.AbstractRepository | |||||
| import org.springframework.stereotype.Repository | |||||
| @Repository | |||||
| interface TruckLaneVersionLineRepository : AbstractRepository<TruckLaneVersionLine, Long> { | |||||
| fun findAllByTruckLaneVersion_IdAndDeletedFalseOrderByIdAsc(truckLaneVersionId: Long): List<TruckLaneVersionLine> | |||||
| } | |||||
| @@ -0,0 +1,12 @@ | |||||
| package com.ffii.fpsms.modules.pickOrder.entity | |||||
| import com.ffii.core.support.AbstractRepository | |||||
| import org.springframework.stereotype.Repository | |||||
| @Repository | |||||
| interface TruckLaneVersionRepository : AbstractRepository<TruckLaneVersion, Long> { | |||||
| fun findAllByTruckLanceCodeAndDeletedFalseOrderByCreatedDesc(truckLanceCode: String): List<TruckLaneVersion> | |||||
| fun findAllByDeletedFalseOrderByCreatedDesc(): List<TruckLaneVersion> | |||||
| fun findByIdAndDeletedFalse(id: Long): TruckLaneVersion? | |||||
| } | |||||
| @@ -1,6 +1,8 @@ | |||||
| package com.ffii.fpsms.modules.pickOrder.entity | package com.ffii.fpsms.modules.pickOrder.entity | ||||
| import com.ffii.core.support.AbstractRepository | import com.ffii.core.support.AbstractRepository | ||||
| import com.ffii.fpsms.modules.logistic.entity.Logistic | |||||
| import org.springframework.data.jpa.repository.Modifying | |||||
| import org.springframework.data.jpa.repository.Query | import org.springframework.data.jpa.repository.Query | ||||
| import org.springframework.data.repository.query.Param | import org.springframework.data.repository.query.Param | ||||
| import org.springframework.stereotype.Repository | import org.springframework.stereotype.Repository | ||||
| @@ -32,8 +34,81 @@ interface TruckRepository : AbstractRepository<Truck, Long> { | |||||
| fun findByTruckLanceCode(truckLanceCode: String): Truck? | fun findByTruckLanceCode(truckLanceCode: String): Truck? | ||||
| @Query("SELECT t FROM Truck t WHERE t.truckLanceCode = :truckLanceCode AND t.deleted = false") | @Query("SELECT t FROM Truck t WHERE t.truckLanceCode = :truckLanceCode AND t.deleted = false") | ||||
| fun findAllByTruckLanceCodeAndDeletedFalse(@Param("truckLanceCode") truckLanceCode: String): List<Truck> | fun findAllByTruckLanceCodeAndDeletedFalse(@Param("truckLanceCode") truckLanceCode: String): List<Truck> | ||||
| /** | |||||
| * Same lane group as `findAllUniqueTruckLanceCodeAndRemarkCombinations`: | |||||
| * remark NULL / blank belong to one bucket; non-blank matches exactly. | |||||
| */ | |||||
| @Query( | |||||
| """ | |||||
| SELECT DISTINCT t FROM Truck t | |||||
| LEFT JOIN FETCH t.logistic | |||||
| LEFT JOIN FETCH t.shop | |||||
| WHERE t.truckLanceCode = :truckLanceCode | |||||
| AND t.deleted = false | |||||
| AND ( | |||||
| (:blankRemark = true AND (t.remark IS NULL OR trim(t.remark) = '')) | |||||
| OR (:blankRemark = false AND trim(t.remark) = :exactRemark) | |||||
| ) | |||||
| """ | |||||
| ) | |||||
| fun findAllByTruckLanceCodeAndRemarkAndDeletedFalse( | |||||
| @Param("truckLanceCode") truckLanceCode: String, | |||||
| @Param("blankRemark") blankRemark: Boolean, | |||||
| @Param("exactRemark") exactRemark: String?, | |||||
| ): List<Truck> | |||||
| /** | |||||
| * RouteBoard O(1) load: return all truck rows used by lanes, with logistic pre-fetched. | |||||
| * Frontend groups by (truckLanceCode, normalizedRemark) where normalizedRemark is: | |||||
| * - NULL / blank => "" | |||||
| * - else TRIM(remark) | |||||
| */ | |||||
| @Query( | |||||
| """ | |||||
| SELECT t FROM Truck t | |||||
| LEFT JOIN FETCH t.logistic | |||||
| LEFT JOIN FETCH t.shop | |||||
| WHERE t.deleted = false | |||||
| AND t.truckLanceCode IS NOT NULL | |||||
| AND trim(t.truckLanceCode) <> '' | |||||
| ORDER BY t.truckLanceCode ASC, | |||||
| CASE WHEN t.remark IS NULL OR trim(t.remark) = '' THEN '' ELSE trim(t.remark) END ASC, | |||||
| t.loadingSequence ASC, | |||||
| t.id ASC | |||||
| """ | |||||
| ) | |||||
| fun findAllForRouteBoard(): List<Truck> | |||||
| /** | |||||
| * 單一 UPDATE 寫入整條 lane 的 logistic,避免先 JOIN FETCH 載入再逐列 save(大車線會極慢)。 | |||||
| */ | |||||
| @Modifying(clearAutomatically = true, flushAutomatically = true) | |||||
| @Query( | |||||
| """ | |||||
| UPDATE Truck t SET t.logistic = :logistic | |||||
| WHERE t.truckLanceCode = :truckLanceCode | |||||
| AND t.deleted = false | |||||
| AND ( | |||||
| (:blankRemark = true AND (t.remark IS NULL OR trim(t.remark) = '')) | |||||
| OR (:blankRemark = false AND trim(t.remark) = :exactRemark) | |||||
| ) | |||||
| """, | |||||
| ) | |||||
| fun bulkUpdateLogisticForLaneGroup( | |||||
| @Param("logistic") logistic: Logistic?, | |||||
| @Param("truckLanceCode") truckLanceCode: String, | |||||
| @Param("blankRemark") blankRemark: Boolean, | |||||
| @Param("exactRemark") exactRemark: String?, | |||||
| ): Int | |||||
| fun findAllByTruckLanceCodeAndStoreIdAndDeletedFalse(truckLanceCode: String, storeId: String): List<Truck> | |||||
| fun findByShopNameAndStoreIdAndTruckLanceCode(shopName: String, storeId: String, truckLanceCode: String): Truck? | fun findByShopNameAndStoreIdAndTruckLanceCode(shopName: String, storeId: String, truckLanceCode: String): Truck? | ||||
| fun findByShopCodeAndStoreId(shopCode: String, storeId: String): Truck? | |||||
| /** 同店同樓層重複列時取 id 最小一筆,避免 NonUniqueResultException */ | |||||
| fun findFirstByShopCodeAndStoreIdAndDeletedFalseOrderByIdAsc( | |||||
| shopCode: String, | |||||
| storeId: String, | |||||
| ): Truck? | |||||
| fun findByShopIdAndStoreId(shopId: Long, storeId: String): Truck? | fun findByShopIdAndStoreId(shopId: Long, storeId: String): Truck? | ||||
| @@ -60,15 +135,17 @@ fun findByShopIdAndStoreIdAndDayOfWeek( | |||||
| SELECT t.* | SELECT t.* | ||||
| FROM truck t | FROM truck t | ||||
| INNER JOIN ( | INNER JOIN ( | ||||
| SELECT TruckLanceCode, remark, MIN(id) as min_id | |||||
| SELECT TruckLanceCode, | |||||
| COALESCE(NULLIF(TRIM(remark), ''), '') AS remark_norm, | |||||
| MIN(id) AS min_id | |||||
| FROM truck | FROM truck | ||||
| WHERE deleted = false | WHERE deleted = false | ||||
| AND TruckLanceCode IS NOT NULL | AND TruckLanceCode IS NOT NULL | ||||
| GROUP BY TruckLanceCode, remark | |||||
| GROUP BY TruckLanceCode, COALESCE(NULLIF(TRIM(remark), ''), '') | |||||
| ) AS unique_combos | ) AS unique_combos | ||||
| ON t.id = unique_combos.min_id | ON t.id = unique_combos.min_id | ||||
| WHERE t.deleted = false | WHERE t.deleted = false | ||||
| ORDER BY t.TruckLanceCode, t.remark | |||||
| ORDER BY t.TruckLanceCode, COALESCE(NULLIF(TRIM(t.remark), ''), '') | |||||
| """ | """ | ||||
| ) | ) | ||||
| fun findAllUniqueTruckLanceCodeAndRemarkCombinations(): List<Truck> | fun findAllUniqueTruckLanceCodeAndRemarkCombinations(): List<Truck> | ||||
| @@ -240,6 +240,9 @@ ORDER BY | |||||
| "id" to row["stockOutLineId"], | "id" to row["stockOutLineId"], | ||||
| "status" to row["stockOutLineStatus"], | "status" to row["stockOutLineStatus"], | ||||
| "qty" to row["stockOutLineQty"], | "qty" to row["stockOutLineQty"], | ||||
| "requiredQty" to row["requiredQty"], | |||||
| "suggestedPickLotQty" to row["requiredQty"], | |||||
| "suggestedPickLotId" to row["suggestedPickLotId"], | |||||
| "lotId" to lotId, | "lotId" to lotId, | ||||
| "lotNo" to (row["lotNo"] ?: ""), | "lotNo" to (row["lotNo"] ?: ""), | ||||
| "location" to (row["location"] ?: ""), | "location" to (row["location"] ?: ""), | ||||
| @@ -0,0 +1,202 @@ | |||||
| package com.ffii.fpsms.modules.pickOrder.service | |||||
| import org.apache.poi.ss.usermodel.BorderStyle | |||||
| import org.apache.poi.ss.usermodel.FillPatternType | |||||
| import org.apache.poi.ss.usermodel.HorizontalAlignment | |||||
| import org.apache.poi.ss.usermodel.IndexedColors | |||||
| import org.apache.poi.ss.usermodel.Sheet | |||||
| import org.apache.poi.ss.usermodel.VerticalAlignment | |||||
| import org.apache.poi.ss.usermodel.Workbook | |||||
| import org.apache.poi.ss.util.CellRangeAddress | |||||
| import org.apache.poi.ss.util.WorkbookUtil | |||||
| import org.apache.poi.xssf.usermodel.XSSFCellStyle | |||||
| import org.apache.poi.xssf.usermodel.XSSFFont | |||||
| import org.apache.poi.xssf.usermodel.XSSFWorkbook | |||||
| import java.net.URLDecoder | |||||
| import java.nio.charset.StandardCharsets | |||||
| /** | |||||
| * MTMS 車線 Excel(PDF 圖1):每個車線一個 worksheet,格式版本 MTMS_ROUTE_V1。 | |||||
| * laneId 與前端 [encodeLaneId] 一致:`encodeURIComponent(code)|encodeURIComponent(remark)`。 | |||||
| */ | |||||
| object RouteLaneExcelSupport { | |||||
| const val FORMAT_MARKER = "MTMS_ROUTE_V1" | |||||
| const val SEP = "|" | |||||
| /** 0-based row indices */ | |||||
| const val ROW_MARKER = 0 | |||||
| const val ROW_STORE = 1 | |||||
| const val ROW_DEPARTURE_DEFAULT = 2 | |||||
| const val ROW_HEADER = 3 | |||||
| const val ROW_FIRST_DATA = 4 | |||||
| const val COL_META_A = 0 | |||||
| const val COL_META_B = 1 | |||||
| const val COL_META_C = 2 | |||||
| const val COL_AREA_PLATE = 0 | |||||
| const val COL_SHOP_NAME = 1 | |||||
| const val COL_BRAND = 2 | |||||
| const val COL_SHOP_CODE = 3 | |||||
| const val COL_SCHEDULE = 4 | |||||
| const val COL_DEPARTURE_ROW = 5 | |||||
| fun decodeLaneId(laneId: String): Pair<String, String?>? { | |||||
| val i = laneId.indexOf(SEP) | |||||
| if (i < 0) return null | |||||
| return try { | |||||
| val code = URLDecoder.decode(laneId.substring(0, i), StandardCharsets.UTF_8).trim() | |||||
| val rem = URLDecoder.decode(laneId.substring(i + SEP.length), StandardCharsets.UTF_8).trim() | |||||
| if (code.isEmpty()) return null | |||||
| code to if (rem.isEmpty()) null else rem | |||||
| } catch (_: Exception) { | |||||
| null | |||||
| } | |||||
| } | |||||
| fun plateLabel(groupIndexZeroBased: Int): String { | |||||
| val n = groupIndexZeroBased + 1 | |||||
| val digits = arrayOf("一", "二", "三", "四", "五", "六", "七", "八", "九", "十") | |||||
| val cn = when { | |||||
| n in 1..10 -> digits[n - 1] | |||||
| n in 11..19 -> "十" + digits[n - 11] | |||||
| else -> "$n" | |||||
| } | |||||
| return "板$cn" | |||||
| } | |||||
| fun uniqueSheetName(workbook: Workbook, truckLanceCode: String, remark: String?): String { | |||||
| val remarkPart = remark?.trim()?.takeIf { it.isNotEmpty() }?.let { "_${it.take(8)}" } ?: "" | |||||
| val raw = (truckLanceCode.take(22) + remarkPart).take(31) | |||||
| var base = WorkbookUtil.createSafeSheetName(raw).take(31) | |||||
| if (base.isEmpty()) base = "Lane" | |||||
| var name = base | |||||
| var i = 0 | |||||
| while (workbook.getSheet(name) != null) { | |||||
| val suffix = "_$i" | |||||
| val truncated = base.take((31 - suffix.length).coerceAtLeast(1)) | |||||
| name = WorkbookUtil.createSafeSheetName(truncated + suffix).take(31) | |||||
| i++ | |||||
| } | |||||
| return name | |||||
| } | |||||
| private data class RouteLaneExportStyles( | |||||
| val metaKey: XSSFCellStyle, | |||||
| val metaValue: XSSFCellStyle, | |||||
| val header: XSSFCellStyle, | |||||
| val data: XSSFCellStyle, | |||||
| val dataAlt: XSSFCellStyle, | |||||
| ) | |||||
| private fun buildExportStyles(wb: XSSFWorkbook): RouteLaneExportStyles { | |||||
| fun XSSFCellStyle.borders() { | |||||
| borderTop = BorderStyle.THIN | |||||
| borderBottom = BorderStyle.THIN | |||||
| borderLeft = BorderStyle.THIN | |||||
| borderRight = BorderStyle.THIN | |||||
| } | |||||
| val metaKey = (wb.createCellStyle() as XSSFCellStyle).apply { | |||||
| alignment = HorizontalAlignment.LEFT | |||||
| verticalAlignment = VerticalAlignment.CENTER | |||||
| fillForegroundColor = IndexedColors.GREY_40_PERCENT.index | |||||
| fillPattern = FillPatternType.SOLID_FOREGROUND | |||||
| borders() | |||||
| val f = wb.createFont() as XSSFFont | |||||
| f.bold = true | |||||
| f.fontHeightInPoints = 11 | |||||
| setFont(f) | |||||
| } | |||||
| val metaValue = (wb.createCellStyle() as XSSFCellStyle).apply { | |||||
| alignment = HorizontalAlignment.LEFT | |||||
| verticalAlignment = VerticalAlignment.CENTER | |||||
| fillForegroundColor = IndexedColors.WHITE.index | |||||
| fillPattern = FillPatternType.SOLID_FOREGROUND | |||||
| borders() | |||||
| val f = wb.createFont() | |||||
| f.fontHeightInPoints = 11 | |||||
| setFont(f) | |||||
| } | |||||
| val headerFont = (wb.createFont() as XSSFFont).apply { | |||||
| bold = true | |||||
| fontHeightInPoints = 11 | |||||
| color = IndexedColors.WHITE.index | |||||
| } | |||||
| val header = (wb.createCellStyle() as XSSFCellStyle).apply { | |||||
| alignment = HorizontalAlignment.CENTER | |||||
| verticalAlignment = VerticalAlignment.CENTER | |||||
| fillForegroundColor = IndexedColors.ROYAL_BLUE.index | |||||
| fillPattern = FillPatternType.SOLID_FOREGROUND | |||||
| borders() | |||||
| setFont(headerFont) | |||||
| } | |||||
| val data = (wb.createCellStyle() as XSSFCellStyle).apply { | |||||
| alignment = HorizontalAlignment.LEFT | |||||
| verticalAlignment = VerticalAlignment.CENTER | |||||
| wrapText = true | |||||
| borders() | |||||
| val f = wb.createFont() | |||||
| f.fontHeightInPoints = 11 | |||||
| setFont(f) | |||||
| } | |||||
| val dataAlt = (wb.createCellStyle() as XSSFCellStyle).apply { | |||||
| cloneStyleFrom(data) | |||||
| fillPattern = FillPatternType.SOLID_FOREGROUND | |||||
| fillForegroundColor = IndexedColors.GREY_25_PERCENT.index | |||||
| } | |||||
| return RouteLaneExportStyles(metaKey, metaValue, header, data, dataAlt) | |||||
| } | |||||
| /** | |||||
| * 表頭/邊框/隔行底色/欄寬/凍結首列資料之上/AutoFilter。不改儲存格值(import 仍讀 raw)。 | |||||
| */ | |||||
| fun applyRouteLaneExportFinishing( | |||||
| sheet: Sheet, | |||||
| wb: XSSFWorkbook, | |||||
| firstDataRow: Int, | |||||
| lastDataRow: Int, | |||||
| ) { | |||||
| val st = buildExportStyles(wb) | |||||
| for (r in intArrayOf(ROW_MARKER, ROW_STORE, ROW_DEPARTURE_DEFAULT)) { | |||||
| val row = sheet.getRow(r) ?: continue | |||||
| for (c in 0..COL_META_C) { | |||||
| val cell = row.getCell(c) ?: continue | |||||
| cell.cellStyle = if (c == COL_META_A) st.metaKey else st.metaValue | |||||
| } | |||||
| } | |||||
| val headerRow = sheet.getRow(ROW_HEADER) | |||||
| if (headerRow != null) { | |||||
| for (c in COL_AREA_PLATE..COL_DEPARTURE_ROW) { | |||||
| headerRow.getCell(c)?.cellStyle = st.header | |||||
| } | |||||
| } | |||||
| if (lastDataRow >= firstDataRow) { | |||||
| for (r in firstDataRow..lastDataRow) { | |||||
| val alt = (r - firstDataRow) % 2 == 1 | |||||
| val style = if (alt) st.dataAlt else st.data | |||||
| val row = sheet.getRow(r) ?: continue | |||||
| for (c in COL_AREA_PLATE..COL_DEPARTURE_ROW) { | |||||
| row.getCell(c)?.cellStyle = style | |||||
| } | |||||
| } | |||||
| } | |||||
| sheet.setColumnWidth(COL_AREA_PLATE, 14 * 256) | |||||
| sheet.setColumnWidth(COL_SHOP_NAME, 28 * 256) | |||||
| sheet.setColumnWidth(COL_BRAND, 14 * 256) | |||||
| sheet.setColumnWidth(COL_SHOP_CODE, 12 * 256) | |||||
| sheet.setColumnWidth(COL_SCHEDULE, 12 * 256) | |||||
| sheet.setColumnWidth(COL_DEPARTURE_ROW, 12 * 256) | |||||
| sheet.createFreezePane(0, ROW_FIRST_DATA) | |||||
| val filterLast = if (lastDataRow >= firstDataRow) lastDataRow else ROW_HEADER | |||||
| sheet.setAutoFilter( | |||||
| CellRangeAddress(ROW_HEADER, filterLast, COL_AREA_PLATE, COL_DEPARTURE_ROW), | |||||
| ) | |||||
| } | |||||
| } | |||||
| @@ -0,0 +1,359 @@ | |||||
| package com.ffii.fpsms.modules.pickOrder.service | |||||
| import org.apache.poi.ss.usermodel.BorderStyle | |||||
| import org.apache.poi.ss.usermodel.FillPatternType | |||||
| import org.apache.poi.ss.usermodel.HorizontalAlignment | |||||
| import org.apache.poi.ss.usermodel.IndexedColors | |||||
| import org.apache.poi.ss.usermodel.Sheet | |||||
| import org.apache.poi.ss.usermodel.VerticalAlignment | |||||
| import org.apache.poi.ss.util.CellRangeAddress | |||||
| import org.apache.poi.ss.util.RegionUtil | |||||
| import org.apache.poi.xssf.usermodel.XSSFCellStyle | |||||
| import org.apache.poi.xssf.usermodel.XSSFFont | |||||
| import org.apache.poi.xssf.usermodel.XSSFWorkbook | |||||
| object RouteReportExcelSupport { | |||||
| const val SHEET_NAME = "車線Report" | |||||
| const val BLOCK_WIDTH = 2 // 每間物流公司一個 block:2 欄 | |||||
| data class Styles( | |||||
| val title: XSSFCellStyle, | |||||
| val titlePreparedBy: XSSFCellStyle, | |||||
| val company: XSSFCellStyle, | |||||
| val plate: XSSFCellStyle, | |||||
| val timeHeader: XSSFCellStyle, | |||||
| val laneLeft: XSSFCellStyle, | |||||
| val laneFill: XSSFCellStyle, | |||||
| val district: XSSFCellStyle, | |||||
| val shopNo: XSSFCellStyle, | |||||
| val shopText: XSSFCellStyle, | |||||
| val total: XSSFCellStyle, | |||||
| val driverLabel: XSSFCellStyle, | |||||
| val driverValue: XSSFCellStyle, | |||||
| ) | |||||
| private fun borders(st: XSSFCellStyle, border: BorderStyle = BorderStyle.THIN) { | |||||
| st.borderTop = border | |||||
| st.borderBottom = border | |||||
| st.borderLeft = border | |||||
| st.borderRight = border | |||||
| } | |||||
| fun buildStyles(wb: XSSFWorkbook): Styles { | |||||
| fun font( | |||||
| size: Short, | |||||
| bold: Boolean = false, | |||||
| color: Short? = null, | |||||
| ): XSSFFont { | |||||
| val f = wb.createFont() as XSSFFont | |||||
| f.fontHeightInPoints = size | |||||
| f.bold = bold | |||||
| if (color != null) f.color = color | |||||
| return f | |||||
| } | |||||
| val title = (wb.createCellStyle() as XSSFCellStyle).apply { | |||||
| alignment = HorizontalAlignment.CENTER | |||||
| verticalAlignment = VerticalAlignment.CENTER | |||||
| borders(this, BorderStyle.MEDIUM) | |||||
| setFont(font(16, bold = true)) | |||||
| } | |||||
| val titlePreparedBy = (wb.createCellStyle() as XSSFCellStyle).apply { | |||||
| alignment = HorizontalAlignment.RIGHT | |||||
| verticalAlignment = VerticalAlignment.CENTER | |||||
| borders(this, BorderStyle.MEDIUM) | |||||
| setFont(font(11, bold = true)) | |||||
| } | |||||
| val company = (wb.createCellStyle() as XSSFCellStyle).apply { | |||||
| alignment = HorizontalAlignment.CENTER | |||||
| verticalAlignment = VerticalAlignment.CENTER | |||||
| fillForegroundColor = IndexedColors.GREY_25_PERCENT.index | |||||
| fillPattern = FillPatternType.SOLID_FOREGROUND | |||||
| borders(this, BorderStyle.MEDIUM) | |||||
| setFont(font(12, bold = true)) | |||||
| } | |||||
| val plate = (wb.createCellStyle() as XSSFCellStyle).apply { | |||||
| alignment = HorizontalAlignment.CENTER | |||||
| verticalAlignment = VerticalAlignment.CENTER | |||||
| borders(this, BorderStyle.THIN) | |||||
| setFont(font(11, bold = true)) | |||||
| } | |||||
| val timeHeader = (wb.createCellStyle() as XSSFCellStyle).apply { | |||||
| alignment = HorizontalAlignment.CENTER | |||||
| verticalAlignment = VerticalAlignment.CENTER | |||||
| fillForegroundColor = IndexedColors.LIGHT_YELLOW.index | |||||
| fillPattern = FillPatternType.SOLID_FOREGROUND | |||||
| borders(this, BorderStyle.MEDIUM) | |||||
| setFont(font(11, bold = true)) | |||||
| } | |||||
| val laneLeft = (wb.createCellStyle() as XSSFCellStyle).apply { | |||||
| alignment = HorizontalAlignment.CENTER | |||||
| verticalAlignment = VerticalAlignment.CENTER | |||||
| fillForegroundColor = IndexedColors.GREY_40_PERCENT.index | |||||
| fillPattern = FillPatternType.SOLID_FOREGROUND | |||||
| borders(this, BorderStyle.MEDIUM) | |||||
| setFont(font(11, bold = true, color = IndexedColors.WHITE.index)) | |||||
| } | |||||
| val laneFill = (wb.createCellStyle() as XSSFCellStyle).apply { | |||||
| alignment = HorizontalAlignment.LEFT | |||||
| verticalAlignment = VerticalAlignment.CENTER | |||||
| fillForegroundColor = IndexedColors.GREY_25_PERCENT.index | |||||
| fillPattern = FillPatternType.SOLID_FOREGROUND | |||||
| borders(this, BorderStyle.MEDIUM) | |||||
| setFont(font(11, bold = true)) | |||||
| } | |||||
| val district = (wb.createCellStyle() as XSSFCellStyle).apply { | |||||
| alignment = HorizontalAlignment.LEFT | |||||
| verticalAlignment = VerticalAlignment.CENTER | |||||
| fillForegroundColor = IndexedColors.LIGHT_CORNFLOWER_BLUE.index | |||||
| fillPattern = FillPatternType.SOLID_FOREGROUND | |||||
| borders(this, BorderStyle.THIN) | |||||
| setFont(font(11, bold = true)) | |||||
| } | |||||
| val shopNo = (wb.createCellStyle() as XSSFCellStyle).apply { | |||||
| alignment = HorizontalAlignment.RIGHT | |||||
| verticalAlignment = VerticalAlignment.TOP | |||||
| borders(this, BorderStyle.THIN) | |||||
| setFont(font(11, bold = true)) | |||||
| } | |||||
| val shopText = (wb.createCellStyle() as XSSFCellStyle).apply { | |||||
| alignment = HorizontalAlignment.LEFT | |||||
| verticalAlignment = VerticalAlignment.TOP | |||||
| wrapText = true | |||||
| borders(this, BorderStyle.THIN) | |||||
| setFont(font(11)) | |||||
| } | |||||
| val total = (wb.createCellStyle() as XSSFCellStyle).apply { | |||||
| alignment = HorizontalAlignment.LEFT | |||||
| verticalAlignment = VerticalAlignment.CENTER | |||||
| fillForegroundColor = IndexedColors.GREY_25_PERCENT.index | |||||
| fillPattern = FillPatternType.SOLID_FOREGROUND | |||||
| borders(this, BorderStyle.MEDIUM) | |||||
| setFont(font(11, bold = true)) | |||||
| } | |||||
| val driverLabel = (wb.createCellStyle() as XSSFCellStyle).apply { | |||||
| alignment = HorizontalAlignment.CENTER | |||||
| verticalAlignment = VerticalAlignment.CENTER | |||||
| fillForegroundColor = IndexedColors.GREY_40_PERCENT.index | |||||
| fillPattern = FillPatternType.SOLID_FOREGROUND | |||||
| borders(this, BorderStyle.MEDIUM) | |||||
| setFont(font(11, bold = true, color = IndexedColors.WHITE.index)) | |||||
| } | |||||
| val driverValue = (wb.createCellStyle() as XSSFCellStyle).apply { | |||||
| alignment = HorizontalAlignment.LEFT | |||||
| verticalAlignment = VerticalAlignment.CENTER | |||||
| borders(this, BorderStyle.MEDIUM) | |||||
| setFont(font(11, bold = true)) | |||||
| } | |||||
| return Styles( | |||||
| title = title, | |||||
| titlePreparedBy = titlePreparedBy, | |||||
| company = company, | |||||
| plate = plate, | |||||
| timeHeader = timeHeader, | |||||
| laneLeft = laneLeft, | |||||
| laneFill = laneFill, | |||||
| district = district, | |||||
| shopNo = shopNo, | |||||
| shopText = shopText, | |||||
| total = total, | |||||
| driverLabel = driverLabel, | |||||
| driverValue = driverValue, | |||||
| ) | |||||
| } | |||||
| private fun ensureRow(sheet: Sheet, r: Int) = sheet.getRow(r) ?: sheet.createRow(r) | |||||
| private fun ensureCell(sheet: Sheet, r: Int, c: Int) = | |||||
| ensureRow(sheet, r).let { row -> row.getCell(c) ?: row.createCell(c) } | |||||
| fun styleRange( | |||||
| sheet: Sheet, | |||||
| row: Int, | |||||
| firstCol: Int, | |||||
| lastCol: Int, | |||||
| style: XSSFCellStyle, | |||||
| ) { | |||||
| for (c in firstCol..lastCol) { | |||||
| ensureCell(sheet, row, c).cellStyle = style | |||||
| } | |||||
| } | |||||
| fun mergeAndStyle( | |||||
| sheet: Sheet, | |||||
| row: Int, | |||||
| firstCol: Int, | |||||
| lastCol: Int, | |||||
| style: XSSFCellStyle, | |||||
| border: BorderStyle = BorderStyle.MEDIUM, | |||||
| ) { | |||||
| for (c in firstCol..lastCol) { | |||||
| ensureCell(sheet, row, c).cellStyle = style | |||||
| } | |||||
| // POI 不允許 merge 單一 cell(需 2+ cells)。此時只套 style + cell border 即可。 | |||||
| if (firstCol == lastCol) return | |||||
| val region = CellRangeAddress(row, row, firstCol, lastCol) | |||||
| sheet.addMergedRegion(region) | |||||
| RegionUtil.setBorderTop(border, region, sheet) | |||||
| RegionUtil.setBorderBottom(border, region, sheet) | |||||
| RegionUtil.setBorderLeft(border, region, sheet) | |||||
| RegionUtil.setBorderRight(border, region, sheet) | |||||
| } | |||||
| fun applyColumnWidths(sheet: Sheet, blockIndex: Int) { | |||||
| val base = blockIndex * BLOCK_WIDTH | |||||
| sheet.setColumnWidth(base + 0, 10 * 256) | |||||
| sheet.setColumnWidth(base + 1, 26 * 256) | |||||
| } | |||||
| fun writeTitle( | |||||
| sheet: Sheet, | |||||
| st: Styles, | |||||
| titleText: String, | |||||
| preparedByText: String, | |||||
| totalBlocks: Int, | |||||
| ) { | |||||
| val lastCol = (totalBlocks * BLOCK_WIDTH - 1).coerceAtLeast(0) | |||||
| val r = 0 | |||||
| // 預留右邊 2 欄顯示「製表: xxx」 | |||||
| val preparedCols = 1.coerceAtMost(lastCol + 1) | |||||
| val preparedFirstCol = (lastCol - preparedCols + 1).coerceAtLeast(0) | |||||
| val titleLastCol = (preparedFirstCol - 1).coerceAtLeast(0) | |||||
| if (preparedFirstCol == 0) { | |||||
| // 欄位不足:整行仍以 title style 輸出(避免 merge 範圍倒轉) | |||||
| mergeAndStyle(sheet, r, 0, lastCol, st.title, BorderStyle.MEDIUM) | |||||
| ensureCell(sheet, r, 0).setCellValue("$titleText $preparedByText") | |||||
| } else { | |||||
| mergeAndStyle(sheet, r, 0, titleLastCol, st.title, BorderStyle.MEDIUM) | |||||
| ensureCell(sheet, r, 0).setCellValue(titleText) | |||||
| mergeAndStyle(sheet, r, preparedFirstCol, lastCol, st.titlePreparedBy, BorderStyle.MEDIUM) | |||||
| ensureCell(sheet, r, preparedFirstCol).setCellValue(preparedByText) | |||||
| } | |||||
| sheet.getRow(r)?.heightInPoints = 26f | |||||
| } | |||||
| data class BlockMeta( | |||||
| val companyName: String, | |||||
| val plate: String, | |||||
| val driverName: String, | |||||
| val driverNumber: String, | |||||
| ) | |||||
| /** | |||||
| * @return 最後寫到的 row index(含) | |||||
| */ | |||||
| fun writeCompanyBlock( | |||||
| sheet: Sheet, | |||||
| st: Styles, | |||||
| blockIndex: Int, | |||||
| startRow: Int, | |||||
| meta: BlockMeta, | |||||
| groups: List<TimeGroup>, | |||||
| totalShopCount: Int, | |||||
| ): Int { | |||||
| val baseCol = blockIndex * BLOCK_WIDTH | |||||
| applyColumnWidths(sheet, blockIndex) | |||||
| var r = startRow | |||||
| // 公司名 | |||||
| mergeAndStyle(sheet, r, baseCol, baseCol + 1, st.company, BorderStyle.MEDIUM) | |||||
| ensureCell(sheet, r, baseCol).setCellValue(meta.companyName) | |||||
| sheet.getRow(r)?.heightInPoints = 18f | |||||
| r++ | |||||
| // 車牌 | |||||
| mergeAndStyle(sheet, r, baseCol, baseCol + 1, st.plate, BorderStyle.THIN) | |||||
| ensureCell(sheet, r, baseCol).setCellValue(meta.plate) | |||||
| r++ | |||||
| for (tg in groups) { | |||||
| mergeAndStyle(sheet, r, baseCol, baseCol + 1, st.timeHeader, BorderStyle.MEDIUM) | |||||
| ensureCell(sheet, r, baseCol).setCellValue(tg.timeLabel) | |||||
| r++ | |||||
| for (lg in tg.lanes) { | |||||
| // 車線標題:左一格強調,右三格補底 | |||||
| ensureCell(sheet, r, baseCol).apply { | |||||
| cellStyle = st.laneLeft | |||||
| setCellValue(lg.laneCode) | |||||
| } | |||||
| // 2 欄版:右側只剩 1 格(不 merge) | |||||
| ensureCell(sheet, r, baseCol + 1).cellStyle = st.laneFill | |||||
| r++ | |||||
| for (dg in lg.districts) { | |||||
| mergeAndStyle(sheet, r, baseCol, baseCol + 1, st.district, BorderStyle.THIN) | |||||
| ensureCell(sheet, r, baseCol).setCellValue(dg.districtLabel) | |||||
| r++ | |||||
| var idx = 1 | |||||
| for (s in dg.shops) { | |||||
| ensureCell(sheet, r, baseCol).apply { | |||||
| cellStyle = st.shopNo | |||||
| setCellValue("$idx.") | |||||
| } | |||||
| // shop row 不做 merge:避免 merged regions 爆量導致寫檔/開檔變慢 | |||||
| styleRange(sheet, r, baseCol + 1, baseCol + 1, st.shopText) | |||||
| ensureCell(sheet, r, baseCol + 1).setCellValue(s) | |||||
| val lines = (s.count { it == '\n' } + 1).coerceAtLeast(1) | |||||
| val h = (16f * lines).coerceIn(18f, 72f) | |||||
| sheet.getRow(r)?.heightInPoints = h | |||||
| r++ | |||||
| idx++ | |||||
| } | |||||
| } | |||||
| } | |||||
| } | |||||
| // 分店數目 | |||||
| mergeAndStyle(sheet, r, baseCol, baseCol + 1, st.total, BorderStyle.MEDIUM) | |||||
| ensureCell(sheet, r, baseCol).setCellValue("分店數目:$totalShopCount") | |||||
| r++ | |||||
| // 車長 / driver | |||||
| ensureCell(sheet, r, baseCol).cellStyle = st.driverLabel | |||||
| ensureCell(sheet, r, baseCol).setCellValue("車長") | |||||
| ensureCell(sheet, r, baseCol + 1).cellStyle = st.driverValue | |||||
| ensureCell(sheet, r, baseCol + 1).setCellValue(meta.driverName) | |||||
| r++ | |||||
| // driver number | |||||
| // 2 欄版:電話/司機號碼跨兩欄合併成一格(像截圖的大白格) | |||||
| mergeAndStyle(sheet, r, baseCol, baseCol + 1, st.driverValue, BorderStyle.MEDIUM) | |||||
| ensureCell(sheet, r, baseCol).setCellValue(meta.driverNumber) | |||||
| r++ | |||||
| return r - 1 | |||||
| } | |||||
| data class TimeGroup( | |||||
| val timeLabel: String, | |||||
| val lanes: List<LaneGroup>, | |||||
| ) | |||||
| data class LaneGroup( | |||||
| val laneCode: String, | |||||
| val districts: List<DistrictGroup>, | |||||
| ) | |||||
| data class DistrictGroup( | |||||
| val districtLabel: String, | |||||
| val shops: List<String>, | |||||
| ) | |||||
| } | |||||
| @@ -0,0 +1,194 @@ | |||||
| package com.ffii.fpsms.modules.pickOrder.service | |||||
| import org.apache.poi.ss.usermodel.BorderStyle | |||||
| import org.apache.poi.ss.usermodel.FillPatternType | |||||
| import org.apache.poi.ss.usermodel.HorizontalAlignment | |||||
| import org.apache.poi.ss.usermodel.IndexedColors | |||||
| import org.apache.poi.ss.usermodel.Sheet | |||||
| import org.apache.poi.ss.usermodel.VerticalAlignment | |||||
| import org.apache.poi.ss.util.CellRangeAddress | |||||
| import org.apache.poi.ss.util.RegionUtil | |||||
| import org.apache.poi.xssf.usermodel.XSSFCellStyle | |||||
| import org.apache.poi.xssf.usermodel.XSSFFont | |||||
| import org.apache.poi.xssf.usermodel.XSSFWorkbook | |||||
| object TruckLaneVersionReportExcelSupport { | |||||
| const val SUMMARY_SHEET = "版本異動報告" | |||||
| private data class Styles( | |||||
| val title: XSSFCellStyle, | |||||
| val metaKey: XSSFCellStyle, | |||||
| val metaVal: XSSFCellStyle, | |||||
| val header: XSSFCellStyle, | |||||
| val normal: XSSFCellStyle, | |||||
| val added: XSSFCellStyle, | |||||
| val deleted: XSSFCellStyle, | |||||
| val moved: XSSFCellStyle, | |||||
| val edited: XSSFCellStyle, | |||||
| val highlight: XSSFCellStyle, | |||||
| ) | |||||
| private fun buildStyles(wb: XSSFWorkbook): Styles { | |||||
| fun font(size: Short, bold: Boolean = false, color: Short? = null): XSSFFont { | |||||
| val f = wb.createFont() as XSSFFont | |||||
| f.fontHeightInPoints = size | |||||
| f.bold = bold | |||||
| if (color != null) f.color = color | |||||
| return f | |||||
| } | |||||
| fun style( | |||||
| align: HorizontalAlignment, | |||||
| vAlign: VerticalAlignment = VerticalAlignment.CENTER, | |||||
| bg: Short? = null, | |||||
| bold: Boolean = false, | |||||
| size: Short = 11, | |||||
| border: BorderStyle = BorderStyle.THIN, | |||||
| ): XSSFCellStyle { | |||||
| return (wb.createCellStyle() as XSSFCellStyle).apply { | |||||
| alignment = align | |||||
| verticalAlignment = vAlign | |||||
| borderTop = border | |||||
| borderBottom = border | |||||
| borderLeft = border | |||||
| borderRight = border | |||||
| if (bg != null) { | |||||
| fillForegroundColor = bg | |||||
| fillPattern = FillPatternType.SOLID_FOREGROUND | |||||
| } | |||||
| setFont(font(size, bold = bold)) | |||||
| wrapText = true | |||||
| } | |||||
| } | |||||
| val title = style(HorizontalAlignment.CENTER, bg = IndexedColors.WHITE.index, bold = true, size = 16, border = BorderStyle.MEDIUM) | |||||
| val metaKey = style(HorizontalAlignment.LEFT, bg = IndexedColors.GREY_25_PERCENT.index, bold = true, border = BorderStyle.THIN) | |||||
| val metaVal = style(HorizontalAlignment.LEFT, bg = IndexedColors.WHITE.index, bold = false, border = BorderStyle.THIN) | |||||
| val header = (wb.createCellStyle() as XSSFCellStyle).apply { | |||||
| alignment = HorizontalAlignment.CENTER | |||||
| verticalAlignment = VerticalAlignment.CENTER | |||||
| fillForegroundColor = IndexedColors.ROYAL_BLUE.index | |||||
| fillPattern = FillPatternType.SOLID_FOREGROUND | |||||
| borderTop = BorderStyle.MEDIUM | |||||
| borderBottom = BorderStyle.MEDIUM | |||||
| borderLeft = BorderStyle.MEDIUM | |||||
| borderRight = BorderStyle.MEDIUM | |||||
| setFont(font(11, bold = true, color = IndexedColors.WHITE.index)) | |||||
| } | |||||
| val normal = style(HorizontalAlignment.LEFT) | |||||
| val added = style(HorizontalAlignment.LEFT, bg = IndexedColors.LIGHT_GREEN.index, border = BorderStyle.THIN) | |||||
| val deleted = style(HorizontalAlignment.LEFT, bg = IndexedColors.ROSE.index, border = BorderStyle.THIN) | |||||
| val moved = style(HorizontalAlignment.LEFT, bg = IndexedColors.LIGHT_YELLOW.index, border = BorderStyle.THIN) | |||||
| val edited = style(HorizontalAlignment.LEFT, bg = IndexedColors.GREY_25_PERCENT.index, border = BorderStyle.THIN) | |||||
| val highlight = style(HorizontalAlignment.LEFT, bg = IndexedColors.LIGHT_ORANGE.index, border = BorderStyle.THIN, bold = true) | |||||
| return Styles(title, metaKey, metaVal, header, normal, added, deleted, moved, edited, highlight) | |||||
| } | |||||
| private fun ensureRow(sheet: Sheet, r: Int) = sheet.getRow(r) ?: sheet.createRow(r) | |||||
| private fun cell(sheet: Sheet, r: Int, c: Int) = ensureRow(sheet, r).getCell(c) ?: ensureRow(sheet, r).createCell(c) | |||||
| private fun mergeRow(sheet: Sheet, r: Int, c0: Int, c1: Int, style: XSSFCellStyle) { | |||||
| for (c in c0..c1) cell(sheet, r, c).cellStyle = style | |||||
| if (c0 == c1) return | |||||
| val region = CellRangeAddress(r, r, c0, c1) | |||||
| sheet.addMergedRegion(region) | |||||
| RegionUtil.setBorderTop(BorderStyle.MEDIUM, region, sheet) | |||||
| RegionUtil.setBorderBottom(BorderStyle.MEDIUM, region, sheet) | |||||
| RegionUtil.setBorderLeft(BorderStyle.MEDIUM, region, sheet) | |||||
| RegionUtil.setBorderRight(BorderStyle.MEDIUM, region, sheet) | |||||
| } | |||||
| data class SummaryMeta( | |||||
| val title: String, | |||||
| val editor: String, | |||||
| val created: String, | |||||
| val fromVersionId: Long, | |||||
| val toVersionId: Long, | |||||
| val note: String?, | |||||
| val statsText: String, | |||||
| ) | |||||
| enum class RowType { ADDED, DELETED, MOVED, EDITED } | |||||
| data class SummaryRow( | |||||
| val type: RowType, | |||||
| val shopName: String, | |||||
| val shopCode: String, | |||||
| val fromLane: String, | |||||
| val toLane: String, | |||||
| val changeText: String, | |||||
| /** 欄位名集合,用於高亮「變更資訊」cell */ | |||||
| val changedFields: Set<String> = emptySet(), | |||||
| ) | |||||
| fun writeSummarySheet(wb: XSSFWorkbook, meta: SummaryMeta, rows: List<SummaryRow>) { | |||||
| val st = buildStyles(wb) | |||||
| val sheet = wb.createSheet(SUMMARY_SHEET) | |||||
| // column widths | |||||
| sheet.setColumnWidth(0, 10 * 256) // type | |||||
| sheet.setColumnWidth(1, 22 * 256) // shop | |||||
| sheet.setColumnWidth(2, 12 * 256) // code | |||||
| sheet.setColumnWidth(3, 18 * 256) // from | |||||
| sheet.setColumnWidth(4, 18 * 256) // to | |||||
| sheet.setColumnWidth(5, 60 * 256) // text | |||||
| var r = 0 | |||||
| mergeRow(sheet, r, 0, 5, st.title) | |||||
| cell(sheet, r, 0).setCellValue(meta.title) | |||||
| sheet.getRow(r)?.heightInPoints = 26f | |||||
| r++ | |||||
| fun metaRow(k: String, v: String) { | |||||
| cell(sheet, r, 0).apply { cellStyle = st.metaKey; setCellValue(k) } | |||||
| mergeRow(sheet, r, 1, 5, st.metaVal) | |||||
| cell(sheet, r, 1).setCellValue(v) | |||||
| r++ | |||||
| } | |||||
| metaRow("編輯者", meta.editor) | |||||
| metaRow("建立時間", meta.created) | |||||
| metaRow("版本", "from #${meta.fromVersionId} → to #${meta.toVersionId}") | |||||
| metaRow("摘要", meta.statsText) | |||||
| if (!meta.note.isNullOrBlank()) metaRow("備註", meta.note.trim()) | |||||
| r++ | |||||
| // header | |||||
| val headerRowIndex = r | |||||
| val headers = listOf("類型", "分店", "代碼", "From 車線", "To 車線", "變更資訊") | |||||
| for (c in headers.indices) { | |||||
| cell(sheet, r, c).apply { cellStyle = st.header; setCellValue(headers[c]) } | |||||
| } | |||||
| sheet.getRow(r)?.heightInPoints = 18f | |||||
| r++ | |||||
| for (row in rows) { | |||||
| val baseStyle = | |||||
| when (row.type) { | |||||
| RowType.ADDED -> st.added | |||||
| RowType.DELETED -> st.deleted | |||||
| RowType.MOVED -> st.moved | |||||
| RowType.EDITED -> st.edited | |||||
| } | |||||
| fun set(c: Int, v: String, highlight: Boolean = false) { | |||||
| cell(sheet, r, c).apply { | |||||
| cellStyle = if (highlight) st.highlight else baseStyle | |||||
| setCellValue(v) | |||||
| } | |||||
| } | |||||
| set(0, row.type.name) | |||||
| set(1, row.shopName) | |||||
| set(2, row.shopCode) | |||||
| set(3, row.fromLane, highlight = row.type == RowType.MOVED || row.type == RowType.ADDED || row.type == RowType.DELETED) | |||||
| set(4, row.toLane, highlight = row.type == RowType.MOVED || row.type == RowType.ADDED || row.type == RowType.DELETED) | |||||
| set(5, row.changeText, highlight = row.changedFields.isNotEmpty()) | |||||
| r++ | |||||
| } | |||||
| sheet.createFreezePane(0, headerRowIndex + 1) | |||||
| } | |||||
| } | |||||
| @@ -0,0 +1,300 @@ | |||||
| package com.ffii.fpsms.modules.pickOrder.service | |||||
| import org.apache.poi.ss.usermodel.BorderStyle | |||||
| import org.apache.poi.ss.usermodel.FillPatternType | |||||
| import org.apache.poi.ss.usermodel.HorizontalAlignment | |||||
| import org.apache.poi.ss.usermodel.IndexedColors | |||||
| import org.apache.poi.ss.usermodel.Sheet | |||||
| import org.apache.poi.ss.usermodel.VerticalAlignment | |||||
| import org.apache.poi.ss.util.CellRangeAddress | |||||
| import org.apache.poi.ss.util.RegionUtil | |||||
| import org.apache.poi.xssf.usermodel.XSSFCellStyle | |||||
| import org.apache.poi.xssf.usermodel.XSSFFont | |||||
| import org.apache.poi.xssf.usermodel.XSSFWorkbook | |||||
| /** | |||||
| * 版本 Log 用:輸出「車線報告」版面(同正常 RouteReport),但把異動的 shop row 高亮。 | |||||
| * | |||||
| * 2 欄 block:左序號 / label、右內容。 | |||||
| */ | |||||
| object TruckLaneVersionRouteReportExcelSupport { | |||||
| const val SHEET_NAME = "車線報告(版本)" | |||||
| const val BLOCK_WIDTH = 2 | |||||
| data class Styles( | |||||
| val title: XSSFCellStyle, | |||||
| val titlePreparedBy: XSSFCellStyle, | |||||
| val company: XSSFCellStyle, | |||||
| val plate: XSSFCellStyle, | |||||
| val timeHeader: XSSFCellStyle, | |||||
| val laneLeft: XSSFCellStyle, | |||||
| val laneFill: XSSFCellStyle, | |||||
| val district: XSSFCellStyle, | |||||
| val shopNo: XSSFCellStyle, | |||||
| val shopText: XSSFCellStyle, | |||||
| val shopNoChanged: XSSFCellStyle, | |||||
| val shopTextChanged: XSSFCellStyle, | |||||
| val total: XSSFCellStyle, | |||||
| val driverLabel: XSSFCellStyle, | |||||
| val driverValue: XSSFCellStyle, | |||||
| ) | |||||
| fun buildStyles(wb: XSSFWorkbook): Styles { | |||||
| fun font(size: Short, bold: Boolean = false, color: Short? = null): XSSFFont { | |||||
| val f = wb.createFont() as XSSFFont | |||||
| f.fontHeightInPoints = size | |||||
| f.bold = bold | |||||
| if (color != null) f.color = color | |||||
| return f | |||||
| } | |||||
| fun borders(st: XSSFCellStyle, border: BorderStyle) { | |||||
| st.borderTop = border | |||||
| st.borderBottom = border | |||||
| st.borderLeft = border | |||||
| st.borderRight = border | |||||
| } | |||||
| fun baseCell( | |||||
| align: HorizontalAlignment, | |||||
| bg: Short? = null, | |||||
| bold: Boolean = false, | |||||
| size: Short = 11, | |||||
| border: BorderStyle = BorderStyle.THIN, | |||||
| wrap: Boolean = false, | |||||
| ): XSSFCellStyle { | |||||
| return (wb.createCellStyle() as XSSFCellStyle).apply { | |||||
| alignment = align | |||||
| verticalAlignment = VerticalAlignment.CENTER | |||||
| borders(this, border) | |||||
| if (bg != null) { | |||||
| fillForegroundColor = bg | |||||
| fillPattern = FillPatternType.SOLID_FOREGROUND | |||||
| } | |||||
| setFont(font(size, bold = bold)) | |||||
| wrapText = wrap | |||||
| } | |||||
| } | |||||
| val title = baseCell(HorizontalAlignment.CENTER, bold = true, size = 16, border = BorderStyle.MEDIUM) | |||||
| val titlePreparedBy = baseCell(HorizontalAlignment.RIGHT, bold = true, size = 11, border = BorderStyle.MEDIUM) | |||||
| val company = baseCell( | |||||
| HorizontalAlignment.CENTER, | |||||
| bg = IndexedColors.GREY_25_PERCENT.index, | |||||
| bold = true, | |||||
| size = 12, | |||||
| border = BorderStyle.MEDIUM, | |||||
| ) | |||||
| val plate = baseCell(HorizontalAlignment.CENTER, bold = true, border = BorderStyle.THIN) | |||||
| val timeHeader = baseCell( | |||||
| HorizontalAlignment.CENTER, | |||||
| bg = IndexedColors.LIGHT_YELLOW.index, | |||||
| bold = true, | |||||
| border = BorderStyle.MEDIUM, | |||||
| ) | |||||
| val laneLeft = baseCell( | |||||
| HorizontalAlignment.CENTER, | |||||
| bg = IndexedColors.GREY_40_PERCENT.index, | |||||
| bold = true, | |||||
| border = BorderStyle.MEDIUM, | |||||
| ).apply { setFont(font(11, bold = true, color = IndexedColors.WHITE.index)) } | |||||
| val laneFill = baseCell( | |||||
| HorizontalAlignment.LEFT, | |||||
| bg = IndexedColors.GREY_25_PERCENT.index, | |||||
| bold = true, | |||||
| border = BorderStyle.MEDIUM, | |||||
| ) | |||||
| val district = baseCell( | |||||
| HorizontalAlignment.LEFT, | |||||
| bg = IndexedColors.LIGHT_CORNFLOWER_BLUE.index, | |||||
| bold = true, | |||||
| border = BorderStyle.THIN, | |||||
| ) | |||||
| val shopNo = baseCell(HorizontalAlignment.RIGHT, border = BorderStyle.THIN).apply { | |||||
| verticalAlignment = VerticalAlignment.TOP | |||||
| setFont(font(11, bold = true)) | |||||
| } | |||||
| val shopText = baseCell(HorizontalAlignment.LEFT, border = BorderStyle.THIN, wrap = true).apply { | |||||
| verticalAlignment = VerticalAlignment.TOP | |||||
| } | |||||
| val shopNoChanged = (wb.createCellStyle() as XSSFCellStyle).apply { | |||||
| cloneStyleFrom(shopNo) | |||||
| fillForegroundColor = IndexedColors.LIGHT_ORANGE.index | |||||
| fillPattern = FillPatternType.SOLID_FOREGROUND | |||||
| } | |||||
| val shopTextChanged = (wb.createCellStyle() as XSSFCellStyle).apply { | |||||
| cloneStyleFrom(shopText) | |||||
| fillForegroundColor = IndexedColors.LIGHT_ORANGE.index | |||||
| fillPattern = FillPatternType.SOLID_FOREGROUND | |||||
| val f = wb.createFont() | |||||
| f.fontHeightInPoints = 11 | |||||
| f.bold = true | |||||
| setFont(f) | |||||
| } | |||||
| val total = baseCell(HorizontalAlignment.LEFT, bg = IndexedColors.GREY_25_PERCENT.index, bold = true, border = BorderStyle.MEDIUM) | |||||
| val driverLabel = baseCell( | |||||
| HorizontalAlignment.CENTER, | |||||
| bg = IndexedColors.GREY_40_PERCENT.index, | |||||
| bold = true, | |||||
| border = BorderStyle.MEDIUM, | |||||
| ).apply { setFont(font(11, bold = true, color = IndexedColors.WHITE.index)) } | |||||
| val driverValue = baseCell(HorizontalAlignment.LEFT, bold = true, border = BorderStyle.MEDIUM) | |||||
| return Styles( | |||||
| title = title, | |||||
| titlePreparedBy = titlePreparedBy, | |||||
| company = company, | |||||
| plate = plate, | |||||
| timeHeader = timeHeader, | |||||
| laneLeft = laneLeft, | |||||
| laneFill = laneFill, | |||||
| district = district, | |||||
| shopNo = shopNo, | |||||
| shopText = shopText, | |||||
| shopNoChanged = shopNoChanged, | |||||
| shopTextChanged = shopTextChanged, | |||||
| total = total, | |||||
| driverLabel = driverLabel, | |||||
| driverValue = driverValue, | |||||
| ) | |||||
| } | |||||
| private fun ensureRow(sheet: Sheet, r: Int) = sheet.getRow(r) ?: sheet.createRow(r) | |||||
| private fun cell(sheet: Sheet, r: Int, c: Int) = ensureRow(sheet, r).getCell(c) ?: ensureRow(sheet, r).createCell(c) | |||||
| private fun mergeAndStyle(sheet: Sheet, r: Int, c0: Int, c1: Int, style: XSSFCellStyle, border: BorderStyle) { | |||||
| for (c in c0..c1) cell(sheet, r, c).cellStyle = style | |||||
| if (c0 == c1) return | |||||
| val region = CellRangeAddress(r, r, c0, c1) | |||||
| sheet.addMergedRegion(region) | |||||
| RegionUtil.setBorderTop(border, region, sheet) | |||||
| RegionUtil.setBorderBottom(border, region, sheet) | |||||
| RegionUtil.setBorderLeft(border, region, sheet) | |||||
| RegionUtil.setBorderRight(border, region, sheet) | |||||
| } | |||||
| fun applyColumnWidths(sheet: Sheet, blockIndex: Int) { | |||||
| val base = blockIndex * BLOCK_WIDTH | |||||
| sheet.setColumnWidth(base + 0, 10 * 256) | |||||
| sheet.setColumnWidth(base + 1, 30 * 256) | |||||
| } | |||||
| data class BlockMeta( | |||||
| val companyName: String, | |||||
| val plate: String, | |||||
| val driverName: String, | |||||
| val driverNumber: String, | |||||
| ) | |||||
| data class ShopRow( | |||||
| val truckRowId: Long, | |||||
| val text: String, | |||||
| val changed: Boolean, | |||||
| ) | |||||
| data class DistrictGroup( | |||||
| val district: String, | |||||
| val shops: List<ShopRow>, | |||||
| ) | |||||
| data class LaneGroup( | |||||
| val laneLabel: String, | |||||
| val districts: List<DistrictGroup>, | |||||
| ) | |||||
| data class TimeGroup( | |||||
| val timeLabel: String, | |||||
| val lanes: List<LaneGroup>, | |||||
| ) | |||||
| fun writeTitle(sheet: Sheet, st: Styles, titleText: String, preparedByText: String, totalBlocks: Int) { | |||||
| val lastCol = (totalBlocks * BLOCK_WIDTH - 1).coerceAtLeast(0) | |||||
| val r = 0 | |||||
| val preparedCols = 1.coerceAtMost(lastCol + 1) | |||||
| val preparedFirstCol = (lastCol - preparedCols + 1).coerceAtLeast(0) | |||||
| val titleLastCol = (preparedFirstCol - 1).coerceAtLeast(0) | |||||
| if (preparedFirstCol == 0) { | |||||
| mergeAndStyle(sheet, r, 0, lastCol, st.title, BorderStyle.MEDIUM) | |||||
| cell(sheet, r, 0).setCellValue("$titleText $preparedByText") | |||||
| } else { | |||||
| mergeAndStyle(sheet, r, 0, titleLastCol, st.title, BorderStyle.MEDIUM) | |||||
| cell(sheet, r, 0).setCellValue(titleText) | |||||
| mergeAndStyle(sheet, r, preparedFirstCol, lastCol, st.titlePreparedBy, BorderStyle.MEDIUM) | |||||
| cell(sheet, r, preparedFirstCol).setCellValue(preparedByText) | |||||
| } | |||||
| sheet.getRow(r)?.heightInPoints = 26f | |||||
| } | |||||
| fun writeCompanyBlock( | |||||
| sheet: Sheet, | |||||
| st: Styles, | |||||
| blockIndex: Int, | |||||
| startRow: Int, | |||||
| meta: BlockMeta, | |||||
| groups: List<TimeGroup>, | |||||
| totalShopCount: Int, | |||||
| ): Int { | |||||
| val baseCol = blockIndex * BLOCK_WIDTH | |||||
| applyColumnWidths(sheet, blockIndex) | |||||
| var r = startRow | |||||
| mergeAndStyle(sheet, r, baseCol, baseCol + 1, st.company, BorderStyle.MEDIUM) | |||||
| cell(sheet, r, baseCol).setCellValue(meta.companyName) | |||||
| r++ | |||||
| mergeAndStyle(sheet, r, baseCol, baseCol + 1, st.plate, BorderStyle.THIN) | |||||
| cell(sheet, r, baseCol).setCellValue(meta.plate) | |||||
| r++ | |||||
| for (tg in groups) { | |||||
| mergeAndStyle(sheet, r, baseCol, baseCol + 1, st.timeHeader, BorderStyle.MEDIUM) | |||||
| cell(sheet, r, baseCol).setCellValue(tg.timeLabel) | |||||
| r++ | |||||
| for (lg in tg.lanes) { | |||||
| cell(sheet, r, baseCol).apply { cellStyle = st.laneLeft; setCellValue(lg.laneLabel) } | |||||
| cell(sheet, r, baseCol + 1).cellStyle = st.laneFill | |||||
| r++ | |||||
| for (dg in lg.districts) { | |||||
| mergeAndStyle(sheet, r, baseCol, baseCol + 1, st.district, BorderStyle.THIN) | |||||
| cell(sheet, r, baseCol).setCellValue(dg.district) | |||||
| r++ | |||||
| var idx = 1 | |||||
| for (s in dg.shops) { | |||||
| val noStyle = if (s.changed) st.shopNoChanged else st.shopNo | |||||
| val txtStyle = if (s.changed) st.shopTextChanged else st.shopText | |||||
| cell(sheet, r, baseCol).apply { cellStyle = noStyle; setCellValue("$idx.") } | |||||
| cell(sheet, r, baseCol + 1).apply { cellStyle = txtStyle; setCellValue(s.text) } | |||||
| val lines = (s.text.count { it == '\n' } + 1).coerceAtLeast(1) | |||||
| sheet.getRow(r)?.heightInPoints = (16f * lines).coerceIn(18f, 90f) | |||||
| r++ | |||||
| idx++ | |||||
| } | |||||
| } | |||||
| } | |||||
| } | |||||
| mergeAndStyle(sheet, r, baseCol, baseCol + 1, st.total, BorderStyle.MEDIUM) | |||||
| cell(sheet, r, baseCol).setCellValue("分店數目:$totalShopCount") | |||||
| r++ | |||||
| cell(sheet, r, baseCol).apply { cellStyle = st.driverLabel; setCellValue("車長") } | |||||
| cell(sheet, r, baseCol + 1).apply { cellStyle = st.driverValue; setCellValue(meta.driverName) } | |||||
| r++ | |||||
| mergeAndStyle(sheet, r, baseCol, baseCol + 1, st.driverValue, BorderStyle.MEDIUM) | |||||
| cell(sheet, r, baseCol).setCellValue(meta.driverNumber) | |||||
| r++ | |||||
| return r - 1 | |||||
| } | |||||
| } | |||||
| @@ -0,0 +1,308 @@ | |||||
| package com.ffii.fpsms.modules.pickOrder.service | |||||
| import com.ffii.fpsms.modules.logistic.entity.LogisticRepository | |||||
| import com.ffii.fpsms.modules.pickOrder.entity.* | |||||
| import com.ffii.fpsms.modules.pickOrder.web.models.LogisticMasterDiffLine | |||||
| import com.ffii.fpsms.modules.pickOrder.web.models.* | |||||
| import jakarta.transaction.Transactional | |||||
| import org.springframework.http.HttpStatus | |||||
| import org.springframework.stereotype.Service | |||||
| import org.springframework.web.server.ResponseStatusException | |||||
| import java.time.LocalTime | |||||
| @Service | |||||
| open class TruckLaneVersionService( | |||||
| private val truckRepository: TruckRepository, | |||||
| private val truckLaneVersionRepository: TruckLaneVersionRepository, | |||||
| private val truckLaneVersionLineRepository: TruckLaneVersionLineRepository, | |||||
| private val logisticRepository: LogisticRepository, | |||||
| ) { | |||||
| private fun toResponse(v: TruckLaneVersion): TruckLaneVersionResponse = | |||||
| TruckLaneVersionResponse( | |||||
| id = v.id ?: 0, | |||||
| truckLanceCode = v.truckLanceCode ?: "", | |||||
| note = v.note, | |||||
| created = v.created?.toString(), | |||||
| modifiedBy = v.modifiedBy, | |||||
| ) | |||||
| /** | |||||
| * 全看板 snapshot:`TruckLaneVersion.truckLanceCode` 為空(建立 snapshot 時未指定單線)。 | |||||
| * 另:若 line 上出現多種 `truckLanceCode`,視為全看板誤標成單線的舊資料,仍應對「整個 findAllForRouteBoard」做 extras 軟刪。 | |||||
| */ | |||||
| private fun isFullBoardSnapshot( | |||||
| version: TruckLaneVersion, | |||||
| lines: List<TruckLaneVersionLine>, | |||||
| ): Boolean { | |||||
| if (version.truckLanceCode.isNullOrBlank()) return true | |||||
| val distinctLaneCodes = | |||||
| lines.mapNotNull { it.truckLanceCode?.trim()?.takeIf { c -> c.isNotEmpty() } }.distinct() | |||||
| return distinctLaneCodes.size > 1 | |||||
| } | |||||
| @Transactional | |||||
| open fun createSnapshot(request: CreateTruckLaneSnapshotRequest): TruckLaneVersionResponse { | |||||
| val lane = request.truckLanceCode?.trim()?.takeIf { it.isNotEmpty() } | |||||
| val version = TruckLaneVersion().apply { | |||||
| this.truckLanceCode = lane | |||||
| this.note = request.note?.trim() | |||||
| } | |||||
| val savedVersion = truckLaneVersionRepository.save(version) | |||||
| val rows = | |||||
| if (lane != null) { | |||||
| truckRepository.findAllByTruckLanceCodeAndDeletedFalse(lane) | |||||
| } else { | |||||
| truckRepository.findAllForRouteBoard() | |||||
| } | |||||
| val lines = rows.map { t -> | |||||
| TruckLaneVersionLine().apply { | |||||
| this.truckLaneVersion = savedVersion | |||||
| this.truckRowId = t.id | |||||
| this.truckLanceCode = t.truckLanceCode | |||||
| this.shopCode = t.shopCode | |||||
| this.branchName = t.shopName | |||||
| this.districtReference = t.districtReference | |||||
| this.loadingSequence = t.loadingSequence | |||||
| this.departureTime = t.departureTime?.toString() | |||||
| this.storeId = t.storeId?.trim()?.takeIf { it.isNotEmpty() } ?: "-" | |||||
| this.remark = t.remark | |||||
| this.logisticId = t.logistic?.id | |||||
| } | |||||
| } | |||||
| if (lines.isNotEmpty()) { | |||||
| truckLaneVersionLineRepository.saveAll(lines) | |||||
| } | |||||
| return toResponse(savedVersion) | |||||
| } | |||||
| open fun listVersionsByLane(truckLanceCode: String): List<TruckLaneVersionResponse> { | |||||
| val lane = truckLanceCode.trim() | |||||
| return truckLaneVersionRepository | |||||
| .findAllByTruckLanceCodeAndDeletedFalseOrderByCreatedDesc(lane) | |||||
| .map(::toResponse) | |||||
| } | |||||
| open fun listAllVersions(): List<TruckLaneVersionResponse> { | |||||
| return truckLaneVersionRepository | |||||
| .findAllByDeletedFalseOrderByCreatedDesc() | |||||
| .map(::toResponse) | |||||
| } | |||||
| open fun getVersionLines(versionId: Long): List<TruckLaneVersionLineResponse> { | |||||
| return truckLaneVersionLineRepository | |||||
| .findAllByTruckLaneVersion_IdAndDeletedFalseOrderByIdAsc(versionId) | |||||
| .map { | |||||
| TruckLaneVersionLineResponse( | |||||
| truckRowId = it.truckRowId ?: 0, | |||||
| truckLanceCode = it.truckLanceCode, | |||||
| shopCode = it.shopCode, | |||||
| branchName = it.branchName, | |||||
| districtReference = it.districtReference, | |||||
| loadingSequence = it.loadingSequence, | |||||
| departureTime = it.departureTime, | |||||
| storeId = it.storeId ?: "", | |||||
| remark = it.remark, | |||||
| logisticId = it.logisticId, | |||||
| ) | |||||
| } | |||||
| } | |||||
| open fun diff(fromVersionId: Long, toVersionId: Long): TruckLaneVersionDiffResponse { | |||||
| val fromLines = truckLaneVersionLineRepository | |||||
| .findAllByTruckLaneVersion_IdAndDeletedFalseOrderByIdAsc(fromVersionId) | |||||
| val toLines = truckLaneVersionLineRepository | |||||
| .findAllByTruckLaneVersion_IdAndDeletedFalseOrderByIdAsc(toVersionId) | |||||
| val fromByRow = fromLines.associateBy { it.truckRowId ?: -1 } | |||||
| val toByRow = toLines.associateBy { it.truckRowId ?: -1 } | |||||
| val allKeys = (fromByRow.keys + toByRow.keys).filter { it > 0 }.sorted() | |||||
| val changed = mutableListOf<TruckLaneVersionDiffLine>() | |||||
| fun s(v: Any?): String? = v?.toString() | |||||
| allKeys.forEach { key -> | |||||
| val a = fromByRow[key] | |||||
| val b = toByRow[key] | |||||
| val changes = mutableListOf<DiffFieldChange>() | |||||
| if (s(a?.truckLanceCode) != s(b?.truckLanceCode)) changes.add(DiffFieldChange("truckLanceCode", s(a?.truckLanceCode), s(b?.truckLanceCode))) | |||||
| if (s(a?.shopCode) != s(b?.shopCode)) changes.add(DiffFieldChange("shopCode", s(a?.shopCode), s(b?.shopCode))) | |||||
| if (s(a?.branchName) != s(b?.branchName)) changes.add(DiffFieldChange("branchName", s(a?.branchName), s(b?.branchName))) | |||||
| if (s(a?.districtReference) != s(b?.districtReference)) changes.add(DiffFieldChange("districtReference", s(a?.districtReference), s(b?.districtReference))) | |||||
| if (s(a?.loadingSequence) != s(b?.loadingSequence)) changes.add(DiffFieldChange("loadingSequence", s(a?.loadingSequence), s(b?.loadingSequence))) | |||||
| if (s(a?.departureTime) != s(b?.departureTime)) changes.add(DiffFieldChange("departureTime", s(a?.departureTime), s(b?.departureTime))) | |||||
| if (s(a?.storeId) != s(b?.storeId)) changes.add(DiffFieldChange("storeId", s(a?.storeId), s(b?.storeId))) | |||||
| if (s(a?.remark) != s(b?.remark)) changes.add(DiffFieldChange("remark", s(a?.remark), s(b?.remark))) | |||||
| if (s(a?.logisticId) != s(b?.logisticId)) changes.add(DiffFieldChange("logisticId", s(a?.logisticId), s(b?.logisticId))) | |||||
| if (changes.isNotEmpty()) { | |||||
| changed.add( | |||||
| TruckLaneVersionDiffLine( | |||||
| truckRowId = key, | |||||
| shopCode = b?.shopCode ?: a?.shopCode, | |||||
| changes = changes, | |||||
| truckLanceCode = b?.truckLanceCode ?: a?.truckLanceCode, | |||||
| remark = b?.remark ?: a?.remark, | |||||
| ) | |||||
| ) | |||||
| } | |||||
| } | |||||
| val fromV = truckLaneVersionRepository.findByIdAndDeletedFalse(fromVersionId) | |||||
| val toV = truckLaneVersionRepository.findByIdAndDeletedFalse(toVersionId) | |||||
| val logisticMasterChanges = | |||||
| if (fromV != null && toV != null) { | |||||
| diffLogisticMastersBetweenVersions(fromV, toV) | |||||
| } else { | |||||
| emptyList() | |||||
| } | |||||
| return TruckLaneVersionDiffResponse( | |||||
| fromVersionId = fromVersionId, | |||||
| toVersionId = toVersionId, | |||||
| changed = changed, | |||||
| logisticMasterChanges = logisticMasterChanges, | |||||
| ) | |||||
| } | |||||
| /** | |||||
| * 物流主檔在兩個版本快照時間之間的新增/修改(含尚未指派到任何 truck 列者)。 | |||||
| */ | |||||
| private fun diffLogisticMastersBetweenVersions( | |||||
| fromVersion: TruckLaneVersion, | |||||
| toVersion: TruckLaneVersion, | |||||
| ): List<LogisticMasterDiffLine> { | |||||
| val fromAt = fromVersion.created ?: return emptyList() | |||||
| val toAt = toVersion.created ?: return emptyList() | |||||
| if (!toAt.isAfter(fromAt)) return emptyList() | |||||
| fun inOpenInterval(ts: java.time.LocalDateTime?): Boolean { | |||||
| if (ts == null) return false | |||||
| return ts.isAfter(fromAt) && !ts.isAfter(toAt) | |||||
| } | |||||
| val out = ArrayList<LogisticMasterDiffLine>() | |||||
| for (l in logisticRepository.findAllByDeletedFalseOrderByIdAsc()) { | |||||
| val id = l.id ?: continue | |||||
| val name = l.logisticName?.trim().orEmpty().ifEmpty { "—" } | |||||
| val plate = l.carPlate?.trim().orEmpty().ifEmpty { "—" } | |||||
| val created = l.created | |||||
| val modified = l.modified | |||||
| if (inOpenInterval(created)) { | |||||
| out.add( | |||||
| LogisticMasterDiffLine( | |||||
| logisticId = id, | |||||
| type = "ADDED", | |||||
| logisticName = name, | |||||
| carPlate = plate, | |||||
| changeText = "新增物流公司:$name($plate)", | |||||
| ), | |||||
| ) | |||||
| continue | |||||
| } | |||||
| if (created != null && !created.isAfter(fromAt) && inOpenInterval(modified)) { | |||||
| out.add( | |||||
| LogisticMasterDiffLine( | |||||
| logisticId = id, | |||||
| type = "EDITED", | |||||
| logisticName = name, | |||||
| carPlate = plate, | |||||
| changeText = "修改物流公司:$name($plate)", | |||||
| ), | |||||
| ) | |||||
| } | |||||
| } | |||||
| return out | |||||
| } | |||||
| @Transactional | |||||
| open fun updateNote(versionId: Long, note: String?): TruckLaneVersionResponse { | |||||
| val v = truckLaneVersionRepository.findByIdAndDeletedFalse(versionId) | |||||
| ?: throw ResponseStatusException(HttpStatus.NOT_FOUND, "Version not found: $versionId") | |||||
| val trimmed = note?.trim()?.takeIf { it.isNotEmpty() } | |||||
| v.note = trimmed | |||||
| return toResponse(truckLaneVersionRepository.save(v)) | |||||
| } | |||||
| @Transactional | |||||
| open fun restore(versionId: Long): String { | |||||
| val version = truckLaneVersionRepository.findByIdAndDeletedFalse(versionId) | |||||
| ?: throw ResponseStatusException(HttpStatus.NOT_FOUND, "Version not found: $versionId") | |||||
| val lines = truckLaneVersionLineRepository.findAllByTruckLaneVersion_IdAndDeletedFalseOrderByIdAsc(versionId) | |||||
| if (lines.isEmpty()) return "No lines to restore for versionId=$versionId" | |||||
| val snapshottedIds = lines.mapNotNull { it.truckRowId }.filter { it > 0 }.toSet() | |||||
| if (snapshottedIds.isEmpty()) { | |||||
| return "No valid truckRowIds in snapshot for versionId=$versionId" | |||||
| } | |||||
| val fullBoard = isFullBoardSnapshot(version, lines) | |||||
| if (fullBoard) { | |||||
| val currentAll = truckRepository.findAllForRouteBoard() | |||||
| val extras = currentAll.filter { t -> t.id != null && t.id !in snapshottedIds } | |||||
| extras.forEach { it.deleted = true } | |||||
| if (extras.isNotEmpty()) { | |||||
| truckRepository.saveAll(extras) | |||||
| } | |||||
| } else { | |||||
| val lane = version.truckLanceCode!!.trim() | |||||
| if (lane.isNotEmpty()) { | |||||
| val currentLane = truckRepository.findAllByTruckLanceCodeAndDeletedFalse(lane) | |||||
| val extras = currentLane.filter { t -> t.id != null && t.id !in snapshottedIds } | |||||
| extras.forEach { it.deleted = true } | |||||
| if (extras.isNotEmpty()) { | |||||
| truckRepository.saveAll(extras) | |||||
| } | |||||
| } | |||||
| } | |||||
| val trucksById = truckRepository.findAllById(snapshottedIds.toList()).associateBy { it.id } | |||||
| val updated = lines.mapNotNull { line -> | |||||
| val truckId = line.truckRowId ?: return@mapNotNull null | |||||
| if (truckId <= 0) return@mapNotNull null | |||||
| val truck = trucksById[truckId] ?: return@mapNotNull null | |||||
| truck.deleted = false | |||||
| truck.apply { | |||||
| // Restore only the fields we snapshot. | |||||
| this.truckLanceCode = line.truckLanceCode ?: version.truckLanceCode | |||||
| this.loadingSequence = line.loadingSequence | |||||
| this.districtReference = line.districtReference | |||||
| val sid = line.storeId?.trim()?.takeUnless { it.isEmpty() || it == "-" } | |||||
| if (sid != null) this.storeId = sid | |||||
| this.shopCode = line.shopCode | |||||
| this.shopName = line.branchName | |||||
| this.remark = line.remark | |||||
| this.departureTime = | |||||
| line.departureTime?.trim()?.takeIf { it.isNotEmpty() }?.let { LocalTime.parse(it) } | |||||
| val lid = line.logisticId | |||||
| this.logistic = | |||||
| if (lid != null && lid > 0) { | |||||
| logisticRepository.findByIdAndDeletedFalse(lid) | |||||
| } else { | |||||
| null | |||||
| } | |||||
| } | |||||
| } | |||||
| if (updated.isNotEmpty()) { | |||||
| truckRepository.saveAll(updated) | |||||
| } | |||||
| createSnapshot( | |||||
| CreateTruckLaneSnapshotRequest( | |||||
| truckLanceCode = null, | |||||
| note = "restore from versionId=$versionId", | |||||
| ) | |||||
| ) | |||||
| return "Restored versionId=$versionId" | |||||
| } | |||||
| } | |||||
| @@ -7,7 +7,11 @@ import org.springframework.web.bind.ServletRequestBindingException | |||||
| import jakarta.servlet.http.HttpServletRequest | import jakarta.servlet.http.HttpServletRequest | ||||
| import org.apache.poi.ss.usermodel.Workbook | import org.apache.poi.ss.usermodel.Workbook | ||||
| import org.apache.poi.xssf.usermodel.XSSFWorkbook | import org.apache.poi.xssf.usermodel.XSSFWorkbook | ||||
| import org.springframework.http.ContentDisposition | |||||
| import org.springframework.http.HttpHeaders | |||||
| import org.springframework.http.MediaType | |||||
| import org.springframework.http.ResponseEntity | import org.springframework.http.ResponseEntity | ||||
| import java.nio.charset.StandardCharsets | |||||
| import org.springframework.web.bind.annotation.* | import org.springframework.web.bind.annotation.* | ||||
| import org.springframework.web.multipart.MultipartHttpServletRequest | import org.springframework.web.multipart.MultipartHttpServletRequest | ||||
| @@ -17,12 +21,19 @@ import com.ffii.fpsms.modules.pickOrder.web.models.UpdateTruckShopDetailsRequest | |||||
| import com.ffii.fpsms.modules.pickOrder.service.TruckService | import com.ffii.fpsms.modules.pickOrder.service.TruckService | ||||
| import com.ffii.fpsms.modules.pickOrder.entity.TruckRepository | import com.ffii.fpsms.modules.pickOrder.entity.TruckRepository | ||||
| import com.ffii.fpsms.modules.pickOrder.web.models.SaveTruckLane | import com.ffii.fpsms.modules.pickOrder.web.models.SaveTruckLane | ||||
| import com.ffii.fpsms.modules.pickOrder.web.models.TruckLaneCombinationResponse | |||||
| import com.ffii.fpsms.modules.pickOrder.web.models.ExportRouteLanesRequest | |||||
| import com.ffii.fpsms.modules.pickOrder.web.models.ExportRouteReportRequest | |||||
| import com.ffii.fpsms.modules.pickOrder.web.models.ExportTruckLaneVersionReportExcelRequest | |||||
| import com.ffii.fpsms.modules.pickOrder.web.models.UpdateLaneLogisticRequest | |||||
| import com.ffii.fpsms.modules.pickOrder.web.models.ParseRouteLanesExcelResponse | |||||
| import com.ffii.fpsms.modules.pickOrder.web.models.deleteTruckLane | import com.ffii.fpsms.modules.pickOrder.web.models.deleteTruckLane | ||||
| import com.ffii.fpsms.modules.pickOrder.web.models.toLaneCombinationResponse | |||||
| import jakarta.validation.Valid | import jakarta.validation.Valid | ||||
| @RestController | @RestController | ||||
| @RequestMapping("/truck") | @RequestMapping("/truck") | ||||
| class TruckController( | |||||
| open class TruckController( | |||||
| private val truckService: TruckService, | private val truckService: TruckService, | ||||
| private val truckRepository: TruckRepository, | private val truckRepository: TruckRepository, | ||||
| ) { | ) { | ||||
| @@ -80,6 +91,142 @@ class TruckController( | |||||
| } | } | ||||
| } | } | ||||
| /** | |||||
| * PDF 圖1:多車線匯出;每個 laneId(encodeLaneId)一個 worksheet,格式 MTMS_ROUTE_V1。 | |||||
| */ | |||||
| @PostMapping( | |||||
| "/exportRouteLanesExcel", | |||||
| consumes = [MediaType.APPLICATION_JSON_VALUE], | |||||
| produces = ["application/vnd.openxmlformats-officedocument.spreadsheetml.sheet"], | |||||
| ) | |||||
| fun exportRouteLanesExcel(@RequestBody req: ExportRouteLanesRequest): ResponseEntity<ByteArray> { | |||||
| val bytes = truckService.exportRouteLanesExcelBytes(req.laneIds) | |||||
| val filename = "MTMS_車線_${System.currentTimeMillis()}.xlsx" | |||||
| val disposition = ContentDisposition.attachment() | |||||
| .filename(filename, StandardCharsets.UTF_8) | |||||
| .build() | |||||
| return ResponseEntity.ok() | |||||
| .header(HttpHeaders.CONTENT_DISPOSITION, disposition.toString()) | |||||
| .contentType(MediaType.parseMediaType("application/vnd.openxmlformats-officedocument.spreadsheetml.sheet")) | |||||
| .body(bytes) | |||||
| } | |||||
| /** | |||||
| * 圖2:車線 Report(單一 sheet;每間物流公司一個水平區塊) | |||||
| */ | |||||
| @PostMapping( | |||||
| "/exportRouteReportExcel", | |||||
| consumes = [MediaType.APPLICATION_JSON_VALUE], | |||||
| produces = ["application/vnd.openxmlformats-officedocument.spreadsheetml.sheet"], | |||||
| ) | |||||
| fun exportRouteReportExcel( | |||||
| request: HttpServletRequest, | |||||
| @RequestBody req: ExportRouteReportRequest, | |||||
| ): ResponseEntity<ByteArray> { | |||||
| val preparedBy = request.userPrincipal?.name?.trim().takeUnless { it.isNullOrBlank() } ?: "current user" | |||||
| val bytes = truckService.exportRouteReportExcelBytes(req.laneIds, preparedBy) | |||||
| val filename = truckService.buildRouteReportFilename() | |||||
| val disposition = ContentDisposition.attachment() | |||||
| .filename(filename, StandardCharsets.UTF_8) | |||||
| .build() | |||||
| return ResponseEntity.ok() | |||||
| .header(HttpHeaders.CONTENT_DISPOSITION, disposition.toString()) | |||||
| .contentType(MediaType.parseMediaType("application/vnd.openxmlformats-officedocument.spreadsheetml.sheet")) | |||||
| .body(bytes) | |||||
| } | |||||
| @PostMapping( | |||||
| "/exportTruckLaneVersionReportExcel", | |||||
| consumes = [MediaType.APPLICATION_JSON_VALUE], | |||||
| produces = ["application/vnd.openxmlformats-officedocument.spreadsheetml.sheet"], | |||||
| ) | |||||
| open fun exportTruckLaneVersionReportExcel( | |||||
| request: HttpServletRequest, | |||||
| @RequestBody req: ExportTruckLaneVersionReportExcelRequest, | |||||
| ): ResponseEntity<ByteArray> { | |||||
| val preparedBy = request.userPrincipal?.name?.trim().takeUnless { it.isNullOrBlank() } ?: "current user" | |||||
| val bytes = truckService.exportTruckLaneVersionReportExcelBytes( | |||||
| TruckService.ExportTruckLaneVersionReportInput( | |||||
| fromVersionId = req.fromVersionId, | |||||
| toVersionId = req.toVersionId, | |||||
| preparedBy = preparedBy, | |||||
| ), | |||||
| ) | |||||
| val filename = "車線版本報告_${System.currentTimeMillis()}.xlsx" | |||||
| val disposition = ContentDisposition.attachment() | |||||
| .filename(filename, StandardCharsets.UTF_8) | |||||
| .build() | |||||
| return ResponseEntity.ok() | |||||
| .header(HttpHeaders.CONTENT_DISPOSITION, disposition.toString()) | |||||
| .contentType(MediaType.parseMediaType("application/vnd.openxmlformats-officedocument.spreadsheetml.sheet")) | |||||
| .body(bytes) | |||||
| } | |||||
| /** 與 [importRouteLanesExcel] 同一格式;僅解析、不寫入 DB(看板 staged import 預覽)。 */ | |||||
| @PostMapping("/parseRouteLanesExcel") | |||||
| @Throws(ServletRequestBindingException::class) | |||||
| fun parseRouteLanesExcel(request: HttpServletRequest): ResponseEntity<ParseRouteLanesExcelResponse> { | |||||
| var workbook: Workbook? = null | |||||
| try { | |||||
| val multipartFile = (request as MultipartHttpServletRequest).getFile("multipartFileList") | |||||
| workbook = XSSFWorkbook(multipartFile?.inputStream) | |||||
| return ResponseEntity.ok(truckService.parseRouteLanesExcel(workbook)) | |||||
| } catch (e: Exception) { | |||||
| println("Error reading Excel file: ${e.message}") | |||||
| return ResponseEntity.badRequest().body( | |||||
| ParseRouteLanesExcelResponse(0, 0, emptyList()), | |||||
| ) | |||||
| } finally { | |||||
| try { | |||||
| workbook?.close() | |||||
| } catch (_: Exception) { | |||||
| } | |||||
| } | |||||
| } | |||||
| /** 與 [exportRouteLanesExcel] 同一格式;一個檔案內多 sheet,每 sheet 一條車線。 */ | |||||
| @PostMapping("/importRouteLanesExcel") | |||||
| @Throws(ServletRequestBindingException::class) | |||||
| fun importRouteLanesExcel(request: HttpServletRequest): ResponseEntity<*> { | |||||
| var workbook: Workbook? = null | |||||
| try { | |||||
| val multipartFile = (request as MultipartHttpServletRequest).getFile("multipartFileList") | |||||
| workbook = XSSFWorkbook(multipartFile?.inputStream) | |||||
| } catch (e: Exception) { | |||||
| println("Error reading Excel file: ${e.message}") | |||||
| return ResponseEntity.badRequest().body( | |||||
| MessageResponse( | |||||
| id = null, | |||||
| name = null, | |||||
| code = null, | |||||
| type = "truck", | |||||
| message = "Error reading Excel file: ${e.message}", | |||||
| errorPosition = null, | |||||
| entity = null, | |||||
| ), | |||||
| ) | |||||
| } | |||||
| try { | |||||
| val result = truckService.importRouteLanesExcel(workbook) | |||||
| return ResponseEntity.ok( | |||||
| MessageResponse( | |||||
| id = null, | |||||
| name = null, | |||||
| code = null, | |||||
| type = "truck", | |||||
| message = result, | |||||
| errorPosition = null, | |||||
| entity = null, | |||||
| ), | |||||
| ) | |||||
| } finally { | |||||
| try { | |||||
| workbook?.close() | |||||
| } catch (_: Exception) { | |||||
| } | |||||
| } | |||||
| } | |||||
| @PostMapping("/importExcel") | @PostMapping("/importExcel") | ||||
| @Throws(ServletRequestBindingException::class) | @Throws(ServletRequestBindingException::class) | ||||
| fun importExcel(request: HttpServletRequest): ResponseEntity<*> { | fun importExcel(request: HttpServletRequest): ResponseEntity<*> { | ||||
| @@ -103,18 +250,25 @@ class TruckController( | |||||
| ) | ) | ||||
| } | } | ||||
| val result = truckService.importExcel(workbook) | |||||
| return ResponseEntity.ok( | |||||
| MessageResponse( | |||||
| id = null, | |||||
| name = null, | |||||
| code = null, | |||||
| type = "truck", | |||||
| message = result, | |||||
| errorPosition = null, | |||||
| entity = null | |||||
| try { | |||||
| val result = truckService.importExcel(workbook) | |||||
| return ResponseEntity.ok( | |||||
| MessageResponse( | |||||
| id = null, | |||||
| name = null, | |||||
| code = null, | |||||
| type = "truck", | |||||
| message = result, | |||||
| errorPosition = null, | |||||
| entity = null | |||||
| ) | |||||
| ) | ) | ||||
| ) | |||||
| } finally { | |||||
| try { | |||||
| workbook?.close() | |||||
| } catch (_: Exception) { | |||||
| } | |||||
| } | |||||
| } | } | ||||
| @GetMapping("/findTruckLane/{shopId}") | @GetMapping("/findTruckLane/{shopId}") | ||||
| @@ -136,7 +290,7 @@ class TruckController( | |||||
| type = "truck", | type = "truck", | ||||
| message = if (truck != null) "Truck lane updated successfully" else "Truck lane not found", | message = if (truck != null) "Truck lane updated successfully" else "Truck lane not found", | ||||
| errorPosition = null, | errorPosition = null, | ||||
| entity = truck | |||||
| entity = null | |||||
| ) | ) | ||||
| } catch (e: Exception) { | } catch (e: Exception) { | ||||
| return MessageResponse( | return MessageResponse( | ||||
| @@ -151,6 +305,32 @@ class TruckController( | |||||
| } | } | ||||
| } | } | ||||
| @PostMapping("/updateLaneLogistic") | |||||
| fun updateLaneLogistic(@Valid @RequestBody request: UpdateLaneLogisticRequest): MessageResponse { | |||||
| try { | |||||
| val n = truckService.updateLogisticForEntireLane(request) | |||||
| return MessageResponse( | |||||
| id = null, | |||||
| name = null, | |||||
| code = request.truckLanceCode, | |||||
| type = "truck", | |||||
| message = "Updated logistic for $n truck row(s)", | |||||
| errorPosition = null, | |||||
| entity = null, | |||||
| ) | |||||
| } catch (e: Exception) { | |||||
| return MessageResponse( | |||||
| id = null, | |||||
| name = null, | |||||
| code = null, | |||||
| type = "truck", | |||||
| message = "Error: ${e.message}", | |||||
| errorPosition = null, | |||||
| entity = null, | |||||
| ) | |||||
| } | |||||
| } | |||||
| @PostMapping("/deleteTruckLane") | @PostMapping("/deleteTruckLane") | ||||
| fun deleteTruckLane(@Valid @RequestBody request: deleteTruckLane): MessageResponse { | fun deleteTruckLane(@Valid @RequestBody request: deleteTruckLane): MessageResponse { | ||||
| try { | try { | ||||
| @@ -178,8 +358,10 @@ class TruckController( | |||||
| } | } | ||||
| @GetMapping("/findAllUniqueTruckLanceCodeAndRemarkCombinations") | @GetMapping("/findAllUniqueTruckLanceCodeAndRemarkCombinations") | ||||
| fun findAllUniqueTruckLanceCodeAndRemarkCombinations(): List<Truck> { | |||||
| return truckService.findAllUniqueTruckLanceCodeAndRemarkCombinations() | |||||
| fun findAllUniqueTruckLanceCodeAndRemarkCombinations(): List<TruckLaneCombinationResponse> { | |||||
| return truckService | |||||
| .findAllUniqueTruckLanceCodeAndRemarkCombinations() | |||||
| .map { it.toLaneCombinationResponse() } | |||||
| } | } | ||||
| @@ -193,6 +375,27 @@ class TruckController( | |||||
| return truckService.findAllByTruckLanceCodeAndDeletedFalse(truckLanceCode) | return truckService.findAllByTruckLanceCodeAndDeletedFalse(truckLanceCode) | ||||
| } | } | ||||
| /** | |||||
| * Filter trucks by the same (truckLanceCode, remark) group as the unique-combinations query. | |||||
| * Omit `remark` or pass empty for rows with NULL/empty remark. | |||||
| */ | |||||
| @GetMapping("/findAllByTruckLanceCodeAndRemarkAndDeletedFalse") | |||||
| fun findAllByTruckLanceCodeAndRemarkAndDeletedFalse( | |||||
| @RequestParam truckLanceCode: String, | |||||
| @RequestParam(required = false) remark: String?, | |||||
| ): List<Truck> { | |||||
| return truckService.findAllByTruckLanceCodeAndRemarkAndDeletedFalse(truckLanceCode, remark) | |||||
| } | |||||
| /** | |||||
| * RouteBoard O(1) load: return all truck rows (deleted=false) once. | |||||
| * Frontend groups by (truckLanceCode, normalizedRemark). | |||||
| */ | |||||
| @GetMapping("/findAllForRouteBoard") | |||||
| fun findAllForRouteBoard(): List<Truck> { | |||||
| return truckService.findAllForRouteBoard() | |||||
| } | |||||
| @GetMapping("/findAllUniqueShopNamesAndCodesFromTrucks") | @GetMapping("/findAllUniqueShopNamesAndCodesFromTrucks") | ||||
| fun findAllUniqueShopNamesAndCodesFromTrucks(): List<Map<String, String>> { | fun findAllUniqueShopNamesAndCodesFromTrucks(): List<Map<String, String>> { | ||||
| return truckService.findAllUniqueShopNamesAndCodesFromTrucks() | return truckService.findAllUniqueShopNamesAndCodesFromTrucks() | ||||
| @@ -0,0 +1,73 @@ | |||||
| package com.ffii.fpsms.modules.pickOrder.web | |||||
| import com.ffii.fpsms.modules.master.web.models.MessageResponse | |||||
| import com.ffii.fpsms.modules.pickOrder.service.TruckLaneVersionService | |||||
| import com.ffii.fpsms.modules.pickOrder.web.models.CreateTruckLaneSnapshotRequest | |||||
| import com.ffii.fpsms.modules.pickOrder.web.models.TruckLaneVersionDiffResponse | |||||
| import com.ffii.fpsms.modules.pickOrder.web.models.TruckLaneVersionLineResponse | |||||
| import com.ffii.fpsms.modules.pickOrder.web.models.TruckLaneVersionResponse | |||||
| import com.ffii.fpsms.modules.pickOrder.web.models.UpdateTruckLaneVersionNoteRequest | |||||
| import jakarta.validation.Valid | |||||
| import org.springframework.http.ResponseEntity | |||||
| import org.springframework.web.bind.annotation.* | |||||
| @RestController | |||||
| @RequestMapping("/truckLaneVersion") | |||||
| class TruckLaneVersionController( | |||||
| private val truckLaneVersionService: TruckLaneVersionService, | |||||
| ) { | |||||
| @PostMapping("/snapshot") | |||||
| fun createSnapshot(@Valid @RequestBody request: CreateTruckLaneSnapshotRequest): TruckLaneVersionResponse { | |||||
| return truckLaneVersionService.createSnapshot(request) | |||||
| } | |||||
| @GetMapping | |||||
| fun listVersions( | |||||
| @RequestParam(required = false) truckLanceCode: String?, | |||||
| ): List<TruckLaneVersionResponse> { | |||||
| val lane = truckLanceCode?.trim()?.takeIf { it.isNotEmpty() } | |||||
| return if (lane != null) { | |||||
| truckLaneVersionService.listVersionsByLane(lane) | |||||
| } else { | |||||
| truckLaneVersionService.listAllVersions() | |||||
| } | |||||
| } | |||||
| @GetMapping("/{versionId}/lines") | |||||
| fun getLines(@PathVariable versionId: Long): List<TruckLaneVersionLineResponse> { | |||||
| return truckLaneVersionService.getVersionLines(versionId) | |||||
| } | |||||
| @PatchMapping("/{versionId}/note") | |||||
| fun updateNote( | |||||
| @PathVariable versionId: Long, | |||||
| @Valid @RequestBody request: UpdateTruckLaneVersionNoteRequest, | |||||
| ): TruckLaneVersionResponse { | |||||
| return truckLaneVersionService.updateNote(versionId, request.note) | |||||
| } | |||||
| @GetMapping("/diff") | |||||
| fun diff( | |||||
| @RequestParam fromVersionId: Long, | |||||
| @RequestParam toVersionId: Long, | |||||
| ): TruckLaneVersionDiffResponse { | |||||
| return truckLaneVersionService.diff(fromVersionId, toVersionId) | |||||
| } | |||||
| @PostMapping("/{versionId}/restore") | |||||
| fun restore(@PathVariable versionId: Long): ResponseEntity<MessageResponse> { | |||||
| val msg = truckLaneVersionService.restore(versionId) | |||||
| return ResponseEntity.ok( | |||||
| MessageResponse( | |||||
| id = null, | |||||
| name = null, | |||||
| code = null, | |||||
| type = "OK", | |||||
| message = msg, | |||||
| errorPosition = null, | |||||
| entity = null, | |||||
| ) | |||||
| ) | |||||
| } | |||||
| } | |||||
| @@ -0,0 +1,5 @@ | |||||
| package com.ffii.fpsms.modules.pickOrder.web.models | |||||
| data class ExportRouteLanesRequest( | |||||
| val laneIds: List<String>, | |||||
| ) | |||||
| @@ -0,0 +1,11 @@ | |||||
| package com.ffii.fpsms.modules.pickOrder.web.models | |||||
| /** | |||||
| * 匯出「車線 Report」(圖2):單一 workbook/單 sheet。 | |||||
| * laneIds 與前端 encodeLaneId 一致:encodeURIComponent(code)|encodeURIComponent(remark)。 | |||||
| * 若 laneIds 為空,視為匯出 RouteBoard 全部車線。 | |||||
| */ | |||||
| data class ExportRouteReportRequest( | |||||
| val laneIds: List<String> = emptyList(), | |||||
| ) | |||||
| @@ -0,0 +1,7 @@ | |||||
| package com.ffii.fpsms.modules.pickOrder.web.models | |||||
| data class ExportTruckLaneVersionReportExcelRequest( | |||||
| val fromVersionId: Long, | |||||
| val toVersionId: Long, | |||||
| ) | |||||
| @@ -0,0 +1,22 @@ | |||||
| package com.ffii.fpsms.modules.pickOrder.web.models | |||||
| /** Preview row for staged route Excel import (no DB write). */ | |||||
| data class RouteLaneImportPreviewRow( | |||||
| val truckRowId: Long?, | |||||
| val truckLanceCode: String, | |||||
| val remark: String?, | |||||
| val storeId: String, | |||||
| val departureTime: String, | |||||
| val shopId: Long, | |||||
| val shopName: String, | |||||
| val shopCode: String, | |||||
| val loadingSequence: Int, | |||||
| val districtReference: String?, | |||||
| val logisticId: Long?, | |||||
| ) | |||||
| data class ParseRouteLanesExcelResponse( | |||||
| val sheetCount: Int, | |||||
| val rowCount: Int, | |||||
| val rows: List<RouteLaneImportPreviewRow>, | |||||
| ) | |||||
| @@ -1,4 +1,5 @@ | |||||
| package com.ffii.fpsms.modules.pickOrder.web.models | package com.ffii.fpsms.modules.pickOrder.web.models | ||||
| import jakarta.validation.constraints.NotBlank | |||||
| import java.time.LocalTime | import java.time.LocalTime | ||||
| data class SaveTruckRequest( | data class SaveTruckRequest( | ||||
| val id: Long? = null, | val id: Long? = null, | ||||
| @@ -11,6 +12,7 @@ data class SaveTruckRequest( | |||||
| val loadingSequence: Int, | val loadingSequence: Int, | ||||
| val remark: String? = null, | val remark: String? = null, | ||||
| val districtReference: String? = null, | val districtReference: String? = null, | ||||
| val logisticId: Long? = null, | |||||
| ) | ) | ||||
| data class SaveTruckLane( | data class SaveTruckLane( | ||||
| val id: Long, | val id: Long, | ||||
| @@ -19,7 +21,10 @@ data class SaveTruckLane( | |||||
| val loadingSequence: Long, | val loadingSequence: Long, | ||||
| val districtReference: String?, | val districtReference: String?, | ||||
| val storeId: String, | val storeId: String, | ||||
| val remark: String? = null | |||||
| val remark: String? = null, | |||||
| val logisticId: Long? = null, | |||||
| /** When true, apply [logisticId] (including null to clear); when false, leave truck.logistic unchanged. */ | |||||
| val updateLogistic: Boolean = false, | |||||
| ) | ) | ||||
| data class deleteTruckLane( | data class deleteTruckLane( | ||||
| val id: Long | val id: Long | ||||
| @@ -39,4 +44,13 @@ data class CreateTruckWithoutShopRequest( | |||||
| val loadingSequence: Int = 0, | val loadingSequence: Int = 0, | ||||
| val districtReference: String? = null, | val districtReference: String? = null, | ||||
| val remark: String? = null, | val remark: String? = null, | ||||
| val logisticId: Long? = null, | |||||
| ) | |||||
| /** 單一 transaction 更新同 (truckLanceCode, remark) 桶內所有 truck 的 logistic。 */ | |||||
| data class UpdateLaneLogisticRequest( | |||||
| @field:NotBlank | |||||
| val truckLanceCode: String, | |||||
| val remark: String? = null, | |||||
| val logisticId: Long? = null, | |||||
| ) | ) | ||||
| @@ -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, | |||||
| ) | |||||
| } | |||||
| @@ -0,0 +1,68 @@ | |||||
| package com.ffii.fpsms.modules.pickOrder.web.models | |||||
| import jakarta.validation.constraints.Size | |||||
| data class CreateTruckLaneSnapshotRequest( | |||||
| @field:Size(max = 100) | |||||
| val truckLanceCode: String? = null, | |||||
| @field:Size(max = 500) | |||||
| val note: String? = null, | |||||
| ) | |||||
| data class RestoreTruckLaneSnapshotRequest( | |||||
| val versionId: Long, | |||||
| ) | |||||
| data class TruckLaneVersionResponse( | |||||
| val id: Long, | |||||
| val truckLanceCode: String, | |||||
| val note: String?, | |||||
| val created: String?, | |||||
| /** BaseEntity `modifiedBy`:最後寫入此快照的使用者(通常為 JWT name / staffNo) */ | |||||
| val modifiedBy: String?, | |||||
| ) | |||||
| data class TruckLaneVersionLineResponse( | |||||
| val truckRowId: Long, | |||||
| val truckLanceCode: String?, | |||||
| val shopCode: String?, | |||||
| val branchName: String?, | |||||
| val districtReference: String?, | |||||
| val loadingSequence: Int?, | |||||
| val departureTime: String?, | |||||
| val storeId: String, | |||||
| val remark: String?, | |||||
| val logisticId: Long?, | |||||
| ) | |||||
| data class DiffFieldChange( | |||||
| val field: String, | |||||
| val from: String?, | |||||
| val to: String?, | |||||
| ) | |||||
| data class TruckLaneVersionDiffLine( | |||||
| val truckRowId: Long, | |||||
| val shopCode: String?, | |||||
| val changes: List<DiffFieldChange>, | |||||
| /** 快照中的車線代碼(優先 to 版,刪除列時 fallback from)— 供僅欄位異動時顯示車線 */ | |||||
| val truckLanceCode: String? = null, | |||||
| val remark: String? = null, | |||||
| ) | |||||
| /** 物流主檔異動(版本區間內新增/修改;不依賴 truck 列是否已指派) */ | |||||
| data class LogisticMasterDiffLine( | |||||
| val logisticId: Long, | |||||
| val type: String, | |||||
| val logisticName: String, | |||||
| val carPlate: String, | |||||
| val changeText: String, | |||||
| ) | |||||
| data class TruckLaneVersionDiffResponse( | |||||
| val fromVersionId: Long, | |||||
| val toVersionId: Long, | |||||
| val changed: List<TruckLaneVersionDiffLine>, | |||||
| val logisticMasterChanges: List<LogisticMasterDiffLine> = emptyList(), | |||||
| ) | |||||
| @@ -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, | |||||
| ) | |||||
| @@ -2385,11 +2385,11 @@ open class ProductProcessService( | |||||
| val allLines = productProcessLineRepository.findByProductProcess_Id(productProcessId) | val allLines = productProcessLineRepository.findByProductProcess_Id(productProcessId) | ||||
| .sortedBy { it.seqNo ?: 0L } | .sortedBy { it.seqNo ?: 0L } | ||||
| println("=== findNewCreatedLineIds DEBUG START ===") | |||||
| println("BOM bomProcessMap: $bomProcessMap") | |||||
| println("All lines (sorted by seqNo):") | |||||
| //println("=== findNewCreatedLineIds DEBUG START ===") | |||||
| //println("BOM bomProcessMap: $bomProcessMap") | |||||
| //println("All lines (sorted by seqNo):") | |||||
| allLines.forEach { line -> | allLines.forEach { line -> | ||||
| println(" id=${line.id}, seqNo=${line.seqNo}, bomProcessId=${line.bomProcess?.id}") | |||||
| //println(" id=${line.id}, seqNo=${line.seqNo}, bomProcessId=${line.bomProcess?.id}") | |||||
| } | } | ||||
| // 创建一个集合来跟踪哪些 line 是新创建的 | // 创建一个集合来跟踪哪些 line 是新创建的 | ||||
| @@ -2402,18 +2402,18 @@ open class ProductProcessService( | |||||
| iteration++ | iteration++ | ||||
| hasChanges = false | hasChanges = false | ||||
| println("\n--- Iteration $iteration ---") | |||||
| //println("\n--- Iteration $iteration ---") | |||||
| // 获取剩余的 line(排除已标记为新创建的),按 seqNo 排序 | // 获取剩余的 line(排除已标记为新创建的),按 seqNo 排序 | ||||
| val remainingLines = allLines.filter { it.id !in newCreatedLineIds } | val remainingLines = allLines.filter { it.id !in newCreatedLineIds } | ||||
| .sortedBy { it.seqNo ?: 0L } | .sortedBy { it.seqNo ?: 0L } | ||||
| println("Remaining lines (excluding new created):") | |||||
| //println("Remaining lines (excluding new created):") | |||||
| remainingLines.forEach { line -> | remainingLines.forEach { line -> | ||||
| println(" id=${line.id}, seqNo=${line.seqNo}, bomProcessId=${line.bomProcess?.id}") | |||||
| //println(" id=${line.id}, seqNo=${line.seqNo}, bomProcessId=${line.bomProcess?.id}") | |||||
| } | } | ||||
| println("New created line IDs so far: $newCreatedLineIds") | |||||
| //println("New created line IDs so far: $newCreatedLineIds") | |||||
| // 计算每个剩余 line 的期望 seqNo(应该是连续的 1, 2, 3...) | // 计算每个剩余 line 的期望 seqNo(应该是连续的 1, 2, 3...) | ||||
| val expectedSeqNoMap = remainingLines.mapIndexed { index, line -> | val expectedSeqNoMap = remainingLines.mapIndexed { index, line -> | ||||
| @@ -2430,7 +2430,7 @@ open class ProductProcessService( | |||||
| val bomProcessId = line.bomProcess?.id | val bomProcessId = line.bomProcess?.id | ||||
| val expectedSeqNo = expectedSeqNoMap[line.id] ?: continue | val expectedSeqNo = expectedSeqNoMap[line.id] ?: continue | ||||
| println("\nChecking line id=${line.id}, seqNo=${line.seqNo}, bomProcessId=$bomProcessId, expectedSeqNo=$expectedSeqNo") | |||||
| //println("\nChecking line id=${line.id}, seqNo=${line.seqNo}, bomProcessId=$bomProcessId, expectedSeqNo=$expectedSeqNo") | |||||
| if (bomProcessId == null) { | if (bomProcessId == null) { | ||||
| println(" -> No bomProcessId, marking as new created") | println(" -> No bomProcessId, marking as new created") | ||||
| @@ -2442,7 +2442,7 @@ open class ProductProcessService( | |||||
| // 查找这个 bomProcessId 在 BOM 中的实际 seqNo | // 查找这个 bomProcessId 在 BOM 中的实际 seqNo | ||||
| val bomProcessSeqNo = bomProcessMap[bomProcessId] | val bomProcessSeqNo = bomProcessMap[bomProcessId] | ||||
| println(" -> BOM bomProcessId=$bomProcessId has seqNo=$bomProcessSeqNo in BOM") | |||||
| //println(" -> BOM bomProcessId=$bomProcessId has seqNo=$bomProcessSeqNo in BOM") | |||||
| if (bomProcessSeqNo == null) { | if (bomProcessSeqNo == null) { | ||||
| println(" -> bomProcessId not found in BOM, marking as new created") | println(" -> bomProcessId not found in BOM, marking as new created") | ||||
| @@ -2461,8 +2461,8 @@ open class ProductProcessService( | |||||
| } | } | ||||
| } | } | ||||
| } | } | ||||
| println("\n=== Final Result ===") | |||||
| println("New created line IDs: $newCreatedLineIds") | |||||
| //println("\n=== Final Result ===") | |||||
| //println("New created line IDs: $newCreatedLineIds") | |||||
| println("=== findNewCreatedLineIds DEBUG END ===\n") | println("=== findNewCreatedLineIds DEBUG END ===\n") | ||||
| return newCreatedLineIds | return newCreatedLineIds | ||||
| @@ -3,6 +3,7 @@ package com.ffii.fpsms.modules.purchaseOrder.entity | |||||
| import com.ffii.core.support.AbstractRepository | import com.ffii.core.support.AbstractRepository | ||||
| import com.ffii.fpsms.modules.purchaseOrder.entity.projections.PurchaseOrderLineInfo | import com.ffii.fpsms.modules.purchaseOrder.entity.projections.PurchaseOrderLineInfo | ||||
| import com.ffii.fpsms.modules.purchaseOrder.enums.PurchaseOrderLineStatus | import com.ffii.fpsms.modules.purchaseOrder.enums.PurchaseOrderLineStatus | ||||
| import org.springframework.data.domain.Pageable | |||||
| import org.springframework.data.jpa.repository.Query | import org.springframework.data.jpa.repository.Query | ||||
| import org.springframework.data.repository.query.Param | import org.springframework.data.repository.query.Param | ||||
| import org.springframework.stereotype.Repository | import org.springframework.stereotype.Repository | ||||
| @@ -10,6 +11,37 @@ import java.io.Serializable | |||||
| @Repository | @Repository | ||||
| interface PurchaseOrderLineRepository : AbstractRepository<PurchaseOrderLine, Long> { | interface PurchaseOrderLineRepository : AbstractRepository<PurchaseOrderLine, Long> { | ||||
| @Query( | |||||
| "SELECT pol FROM PurchaseOrderLine pol " + | |||||
| "LEFT JOIN FETCH pol.purchaseOrder po " + | |||||
| "LEFT JOIN FETCH po.supplier " + | |||||
| "JOIN FETCH pol.uom " + | |||||
| "LEFT JOIN FETCH pol.uomM18 " + | |||||
| "WHERE pol.deleted = false AND pol.item.id = :itemId AND pol.m18DataLog IS NOT NULL " + | |||||
| "ORDER BY pol.created DESC", | |||||
| ) | |||||
| fun findLatestLinesForBomM18ByItemId( | |||||
| @Param("itemId") itemId: Long, | |||||
| pageable: Pageable, | |||||
| ): List<PurchaseOrderLine> | |||||
| /** | |||||
| * Latest PO (by header `purchase_order.created`) for a material item code: supplier `shop.m18Id` from `purchase_order.supplierId`. | |||||
| * Mirrors manual SQL: pol → items (code), po, shop on supplier, uom_conversion; order by po.created desc limit 1. | |||||
| */ | |||||
| @Query( | |||||
| value = | |||||
| "SELECT sh.m18Id FROM purchase_order_line pol " + | |||||
| "LEFT JOIN items it ON pol.itemId = it.id " + | |||||
| "LEFT JOIN purchase_order po ON pol.purchaseOrderId = po.id " + | |||||
| "LEFT JOIN shop sh ON po.supplierId = sh.id " + | |||||
| "LEFT JOIN uom_conversion um ON pol.uomIdM18 = um.id " + | |||||
| "WHERE pol.deleted = false AND it.deleted = false AND it.code = :itemCode " + | |||||
| "ORDER BY po.created DESC LIMIT 1", | |||||
| nativeQuery = true, | |||||
| ) | |||||
| fun findLatestPoSupplierM18IdByItemCodeNative(@Param("itemCode") itemCode: String): List<Long> | |||||
| fun findByM18DataLogIdAndDeletedIsFalse(m18datalogId: Serializable): PurchaseOrderLine? | fun findByM18DataLogIdAndDeletedIsFalse(m18datalogId: Serializable): PurchaseOrderLine? | ||||
| fun findAllPurchaseOrderLineInfoByPurchaseOrderIdAndDeletedIsFalse(purchaseOrderId: Long): List<PurchaseOrderLineInfo> | fun findAllPurchaseOrderLineInfoByPurchaseOrderIdAndDeletedIsFalse(purchaseOrderId: Long): List<PurchaseOrderLineInfo> | ||||
| fun findAllByPurchaseOrderIdAndDeletedIsFalse(purchaseOrderId: Long): List<PurchaseOrderLine> | fun findAllByPurchaseOrderIdAndDeletedIsFalse(purchaseOrderId: Long): List<PurchaseOrderLine> | ||||
| @@ -9,13 +9,23 @@ class FGStockOutTraceabilityReportService( | |||||
| ) { | ) { | ||||
| fun getDistinctHandlersForFGStockOutTraceability(): List<String> { | fun getDistinctHandlersForFGStockOutTraceability(): List<String> { | ||||
| val sql = """ | val sql = """ | ||||
| SELECT DISTINCT COALESCE(picker_user.name, modified_user.name, '') AS handler | |||||
| FROM stock_out_line sol | |||||
| INNER JOIN stock_out so ON sol.stockOutId = so.id AND so.deleted = 0 AND so.type = 'do' | |||||
| LEFT JOIN user picker_user ON sol.handled_by = picker_user.id AND picker_user.deleted = 0 | |||||
| LEFT JOIN user modified_user ON sol.modifiedBy = modified_user.staffNo AND modified_user.deleted = 0 AND sol.handled_by IS NULL | |||||
| WHERE sol.deleted = 0 | |||||
| ORDER BY handler | |||||
| SELECT DISTINCT h.handler | |||||
| FROM ( | |||||
| SELECT TRIM(COALESCE(picker_user.name, modified_user.name, '')) AS handler | |||||
| FROM stock_out_line sol | |||||
| INNER JOIN stock_out so ON sol.stockOutId = so.id AND so.deleted = 0 AND so.type = 'do' | |||||
| LEFT JOIN user picker_user ON sol.handled_by = picker_user.id AND picker_user.deleted = 0 | |||||
| LEFT JOIN user modified_user ON sol.modifiedBy = modified_user.staffNo AND modified_user.deleted = 0 AND sol.handled_by IS NULL | |||||
| WHERE sol.deleted = 0 | |||||
| UNION | |||||
| SELECT TRIM(IFNULL(handlerName, '')) AS handler | |||||
| FROM delivery_order_pick_order | |||||
| WHERE deleted = 0 | |||||
| AND ticketStatus = 'completed' | |||||
| AND IFNULL(handlerName, '') <> '' | |||||
| ) h | |||||
| WHERE TRIM(IFNULL(h.handler, '')) <> '' | |||||
| ORDER BY h.handler | |||||
| """.trimIndent() | """.trimIndent() | ||||
| return jdbcDao | return jdbcDao | ||||
| @@ -54,7 +64,7 @@ class FGStockOutTraceabilityReportService( | |||||
| val yearSql = if (!year.isNullOrBlank()) { | val yearSql = if (!year.isNullOrBlank()) { | ||||
| args["year"] = year | args["year"] = year | ||||
| "AND YEAR(IFNULL(dpor.RequiredDeliveryDate, do.estimatedArrivalDate)) = :year" | |||||
| "AND YEAR(IFNULL(dopo.requiredDeliveryDate, do.estimatedArrivalDate)) = :year" | |||||
| } else { | } else { | ||||
| "" | "" | ||||
| } | } | ||||
| @@ -62,7 +72,7 @@ class FGStockOutTraceabilityReportService( | |||||
| val lastOutDateStartSql = if (!lastOutDateStart.isNullOrBlank()) { | val lastOutDateStartSql = if (!lastOutDateStart.isNullOrBlank()) { | ||||
| val formattedDate = lastOutDateStart.replace("/", "-") | val formattedDate = lastOutDateStart.replace("/", "-") | ||||
| args["lastOutDateStart"] = formattedDate | args["lastOutDateStart"] = formattedDate | ||||
| "AND DATE(IFNULL(dpor.RequiredDeliveryDate, do.estimatedArrivalDate)) >= DATE(:lastOutDateStart)" | |||||
| "AND DATE(IFNULL(dopo.requiredDeliveryDate, do.estimatedArrivalDate)) >= DATE(:lastOutDateStart)" | |||||
| } else { | } else { | ||||
| "" | "" | ||||
| } | } | ||||
| @@ -70,14 +80,14 @@ class FGStockOutTraceabilityReportService( | |||||
| val lastOutDateEndSql = if (!lastOutDateEnd.isNullOrBlank()) { | val lastOutDateEndSql = if (!lastOutDateEnd.isNullOrBlank()) { | ||||
| val formattedDate = lastOutDateEnd.replace("/", "-") | val formattedDate = lastOutDateEnd.replace("/", "-") | ||||
| args["lastOutDateEnd"] = formattedDate | args["lastOutDateEnd"] = formattedDate | ||||
| "AND DATE(IFNULL(dpor.RequiredDeliveryDate, do.estimatedArrivalDate)) <= DATE(:lastOutDateEnd)" | |||||
| "AND DATE(IFNULL(dopo.requiredDeliveryDate, do.estimatedArrivalDate)) <= DATE(:lastOutDateEnd)" | |||||
| } else { | } else { | ||||
| "" | "" | ||||
| } | } | ||||
| val handlerSql = buildMultiValueExactClause( | val handlerSql = buildMultiValueExactClause( | ||||
| handler, | handler, | ||||
| "COALESCE(picker_user.name, modified_user.name, '')", | |||||
| "COALESCE(picker_user.name, modified_user.name, IFNULL(dopo.handlerName, ''))", | |||||
| "handler", | "handler", | ||||
| args, | args, | ||||
| ) | ) | ||||
| @@ -85,13 +95,13 @@ class FGStockOutTraceabilityReportService( | |||||
| val sql = """ | val sql = """ | ||||
| SELECT | SELECT | ||||
| IFNULL(DATE_FORMAT( | IFNULL(DATE_FORMAT( | ||||
| IFNULL(dpor.RequiredDeliveryDate, do.estimatedArrivalDate), | |||||
| IFNULL(dopo.requiredDeliveryDate, do.estimatedArrivalDate), | |||||
| '%Y-%m-%d' | '%Y-%m-%d' | ||||
| ), '') AS deliveryDate, | ), '') AS deliveryDate, | ||||
| IFNULL(it.code, '') AS itemNo, | IFNULL(it.code, '') AS itemNo, | ||||
| IFNULL(it.name, '') AS itemName, | IFNULL(it.name, '') AS itemName, | ||||
| IFNULL(uc.udfudesc, '') AS unitOfMeasure, | IFNULL(uc.udfudesc, '') AS unitOfMeasure, | ||||
| IFNULL(dpor.deliveryNoteCode, '') AS dnNo, | |||||
| IFNULL(dopo.deliveryNoteCode, '') AS dnNo, | |||||
| CAST(IFNULL(sp.id, 0) AS CHAR) AS customerId, | CAST(IFNULL(sp.id, 0) AS CHAR) AS customerId, | ||||
| IFNULL(sp.name, '') AS customerName, | IFNULL(sp.name, '') AS customerName, | ||||
| FORMAT( | FORMAT( | ||||
| @@ -109,11 +119,13 @@ class FGStockOutTraceabilityReportService( | |||||
| COALESCE( | COALESCE( | ||||
| picker_user.name, | picker_user.name, | ||||
| modified_user.name, | modified_user.name, | ||||
| dopo.handlerName, | |||||
| '' | '' | ||||
| ) AS handler, | ) AS handler, | ||||
| COALESCE( | COALESCE( | ||||
| picker_user.name, | picker_user.name, | ||||
| modified_user.name, | modified_user.name, | ||||
| dopo.handlerName, | |||||
| '' | '' | ||||
| ) AS pickedBy, | ) AS pickedBy, | ||||
| GROUP_CONCAT(DISTINCT wh.code ORDER BY wh.code SEPARATOR ', ') AS storeLocation, | GROUP_CONCAT(DISTINCT wh.code ORDER BY wh.code SEPARATOR ', ') AS storeLocation, | ||||
| @@ -122,19 +134,22 @@ class FGStockOutTraceabilityReportService( | |||||
| ROUND(SUM(IFNULL(sol.qty, 0)) OVER (PARTITION BY it.code), 0), 0 | ROUND(SUM(IFNULL(sol.qty, 0)) OVER (PARTITION BY it.code), 0), 0 | ||||
| ) AS totalStockOutQty, | ) AS totalStockOutQty, | ||||
| 0 AS stockSubCategory | 0 AS stockSubCategory | ||||
| FROM do_pick_order_line_record dpolr | |||||
| LEFT JOIN do_pick_order_record dpor | |||||
| ON dpolr.record_id = dpor.id | |||||
| AND dpor.deleted = 0 | |||||
| AND dpor.ticket_status = 'completed' | |||||
| FROM delivery_order_pick_order dopo | |||||
| INNER JOIN pick_order po | |||||
| ON po.deliveryOrderPickOrderId = dopo.id | |||||
| AND po.deleted = 0 | |||||
| INNER JOIN delivery_order do | INNER JOIN delivery_order do | ||||
| ON dpolr.do_order_id = do.id | |||||
| ON po.doId = do.id | |||||
| AND do.deleted = 0 | AND do.deleted = 0 | ||||
| LEFT JOIN shop sp | LEFT JOIN shop sp | ||||
| ON do.shopId = sp.id | ON do.shopId = sp.id | ||||
| AND sp.deleted = 0 | AND sp.deleted = 0 | ||||
| LEFT JOIN pick_order_line pol | |||||
| ON pol.poId = po.id | |||||
| AND pol.deleted = 0 | |||||
| LEFT JOIN delivery_order_line dol | LEFT JOIN delivery_order_line dol | ||||
| ON do.id = dol.deliveryOrderId | |||||
| ON dol.deliveryOrderId = do.id | |||||
| AND dol.itemId = pol.itemId | |||||
| AND dol.deleted = 0 | AND dol.deleted = 0 | ||||
| LEFT JOIN items it | LEFT JOIN items it | ||||
| ON dol.itemId = it.id | ON dol.itemId = it.id | ||||
| @@ -144,13 +159,6 @@ class FGStockOutTraceabilityReportService( | |||||
| AND iu.stockUnit = 1 | AND iu.stockUnit = 1 | ||||
| LEFT JOIN uom_conversion uc | LEFT JOIN uom_conversion uc | ||||
| ON iu.uomId = uc.id | ON iu.uomId = uc.id | ||||
| LEFT JOIN pick_order_line pol | |||||
| ON dpolr.pick_order_id = pol.poId | |||||
| AND pol.itemId = it.id | |||||
| AND pol.deleted = 0 | |||||
| LEFT JOIN pick_order po | |||||
| ON pol.poId = po.id | |||||
| AND po.deleted = 0 | |||||
| LEFT JOIN stock_out_line sol | LEFT JOIN stock_out_line sol | ||||
| ON pol.id = sol.pickOrderLineId | ON pol.id = sol.pickOrderLineId | ||||
| AND sol.itemId = it.id | AND sol.itemId = it.id | ||||
| @@ -176,7 +184,8 @@ class FGStockOutTraceabilityReportService( | |||||
| AND modified_user.deleted = 0 | AND modified_user.deleted = 0 | ||||
| AND sol.handled_by IS NULL | AND sol.handled_by IS NULL | ||||
| WHERE | WHERE | ||||
| dpolr.deleted = 0 | |||||
| dopo.deleted = 0 | |||||
| AND dopo.ticketStatus = 'completed' | |||||
| $stockCategorySql | $stockCategorySql | ||||
| $stockSubCategorySql | $stockSubCategorySql | ||||
| $itemCodeSql | $itemCodeSql | ||||
| @@ -186,12 +195,13 @@ class FGStockOutTraceabilityReportService( | |||||
| $handlerSql | $handlerSql | ||||
| GROUP BY | GROUP BY | ||||
| sol.id, | sol.id, | ||||
| dpor.RequiredDeliveryDate, | |||||
| dopo.requiredDeliveryDate, | |||||
| dopo.handlerName, | |||||
| do.estimatedArrivalDate, | do.estimatedArrivalDate, | ||||
| it.code, | it.code, | ||||
| it.name, | it.name, | ||||
| uc.udfudesc, | uc.udfudesc, | ||||
| dpor.deliveryNoteCode, | |||||
| dopo.deliveryNoteCode, | |||||
| sp.id, | sp.id, | ||||
| sp.name, | sp.name, | ||||
| sol.qty, | sol.qty, | ||||
| @@ -10,6 +10,7 @@ import com.ffii.fpsms.m18.M18GrnRules | |||||
| import com.ffii.fpsms.modules.master.entity.ShopRepository | import com.ffii.fpsms.modules.master.entity.ShopRepository | ||||
| import com.ffii.fpsms.modules.master.enums.ShopType | import com.ffii.fpsms.modules.master.enums.ShopType | ||||
| import com.ffii.fpsms.modules.master.service.ItemUomService | import com.ffii.fpsms.modules.master.service.ItemUomService | ||||
| import com.ffii.fpsms.modules.deliveryOrder.service.DoFloorSupplierSettingsService | |||||
| import java.math.BigDecimal | import java.math.BigDecimal | ||||
| import net.sf.jasperreports.engine.export.ooxml.JRXlsxExporter | import net.sf.jasperreports.engine.export.ooxml.JRXlsxExporter | ||||
| import net.sf.jasperreports.export.SimpleExporterInput | import net.sf.jasperreports.export.SimpleExporterInput | ||||
| @@ -20,6 +21,7 @@ open class ReportService( | |||||
| private val jdbcDao: JdbcDao, | private val jdbcDao: JdbcDao, | ||||
| private val itemUomService: ItemUomService, | private val itemUomService: ItemUomService, | ||||
| private val shopRepository: ShopRepository, | private val shopRepository: ShopRepository, | ||||
| private val doFloorSupplierSettingsService: DoFloorSupplierSettingsService, | |||||
| ) { | ) { | ||||
| /** | /** | ||||
| * Queries the database for inventory data based on dates and optional item type. | * Queries the database for inventory data based on dates and optional item type. | ||||
| @@ -101,7 +103,7 @@ open class ReportService( | |||||
| val yearSql = if (!year.isNullOrBlank()) { | val yearSql = if (!year.isNullOrBlank()) { | ||||
| args["year"] = year | args["year"] = year | ||||
| "AND YEAR(IFNULL(dpor.RequiredDeliveryDate, do.estimatedArrivalDate)) = :year" | |||||
| "AND YEAR(IFNULL(dopo.requiredDeliveryDate, do.estimatedArrivalDate)) = :year" | |||||
| } else { | } else { | ||||
| "" | "" | ||||
| } | } | ||||
| @@ -109,25 +111,27 @@ open class ReportService( | |||||
| val lastOutDateStartSql = if (!lastOutDateStart.isNullOrBlank()) { | val lastOutDateStartSql = if (!lastOutDateStart.isNullOrBlank()) { | ||||
| val formattedDate = lastOutDateStart.replace("/", "-") | val formattedDate = lastOutDateStart.replace("/", "-") | ||||
| args["lastOutDateStart"] = formattedDate | args["lastOutDateStart"] = formattedDate | ||||
| "AND DATE(IFNULL(dpor.RequiredDeliveryDate, do.estimatedArrivalDate)) >= DATE(:lastOutDateStart)" | |||||
| "AND DATE(IFNULL(dopo.requiredDeliveryDate, do.estimatedArrivalDate)) >= DATE(:lastOutDateStart)" | |||||
| } else "" | } else "" | ||||
| val lastOutDateEndSql = if (!lastOutDateEnd.isNullOrBlank()) { | val lastOutDateEndSql = if (!lastOutDateEnd.isNullOrBlank()) { | ||||
| val formattedDate = lastOutDateEnd.replace("/", "-") | val formattedDate = lastOutDateEnd.replace("/", "-") | ||||
| args["lastOutDateEnd"] = formattedDate | args["lastOutDateEnd"] = formattedDate | ||||
| "AND DATE(IFNULL(dpor.RequiredDeliveryDate, do.estimatedArrivalDate)) <= DATE(:lastOutDateEnd)" | |||||
| "AND DATE(IFNULL(dopo.requiredDeliveryDate, do.estimatedArrivalDate)) <= DATE(:lastOutDateEnd)" | |||||
| } else "" | } else "" | ||||
| val supplierFloorSqlCases = doFloorSupplierSettingsService.sqlPreferredFloorCases("supplier.code") | |||||
| val sql = """ | val sql = """ | ||||
| SELECT | SELECT | ||||
| IFNULL(DATE_FORMAT( | IFNULL(DATE_FORMAT( | ||||
| IFNULL(dpor.RequiredDeliveryDate, do.estimatedArrivalDate), | |||||
| IFNULL(dopo.requiredDeliveryDate, do.estimatedArrivalDate), | |||||
| '%Y-%m-%d' | '%Y-%m-%d' | ||||
| ), '') AS deliveryDate, | ), '') AS deliveryDate, | ||||
| IFNULL(it.code, '') AS itemNo, | IFNULL(it.code, '') AS itemNo, | ||||
| IFNULL(it.name, '') AS itemName, | IFNULL(it.name, '') AS itemName, | ||||
| IFNULL(uc.udfudesc, '') AS unitOfMeasure, | IFNULL(uc.udfudesc, '') AS unitOfMeasure, | ||||
| IFNULL(dpor.deliveryNoteCode, '') AS dnNo, | |||||
| IFNULL(dopo.deliveryNoteCode, '') AS dnNo, | |||||
| CAST(IFNULL(sp.id, 0) AS CHAR) AS customerId, | CAST(IFNULL(sp.id, 0) AS CHAR) AS customerId, | ||||
| IFNULL(sp.name, '') AS customerName, | IFNULL(sp.name, '') AS customerName, | ||||
| CAST( | CAST( | ||||
| @@ -138,28 +142,20 @@ open class ReportService( | |||||
| FORMAT(ROUND(IFNULL(IFNULL(sol.qty, dol.qty), 0), 0), 0) AS qty, | FORMAT(ROUND(IFNULL(IFNULL(sol.qty, dol.qty), 0), 0), 0) AS qty, | ||||
| COALESCE( | COALESCE( | ||||
| dpor.TruckLanceCode, | |||||
| dopo.truckLanceCode, | |||||
| (SELECT t2.TruckLanceCode | (SELECT t2.TruckLanceCode | ||||
| FROM truck t2 | FROM truck t2 | ||||
| WHERE t2.shopId = do.shopId | WHERE t2.shopId = do.shopId | ||||
| AND t2.deleted = 0 | AND t2.deleted = 0 | ||||
| AND t2.Store_id = CASE | |||||
| WHEN supplier.code = 'P06B' THEN '4F' | |||||
| WHEN supplier.code IN ('P07', 'P06D') THEN '2F' | |||||
| ELSE NULL | |||||
| END | |||||
| AND t2.Store_id = ${supplierFloorSqlCases.floorStringCase} | |||||
| AND ( | AND ( | ||||
| (CASE | |||||
| WHEN supplier.code = 'P06B' THEN '4F' | |||||
| WHEN supplier.code IN ('P07', 'P06D') THEN '2F' | |||||
| ELSE NULL | |||||
| END | |||||
| (${supplierFloorSqlCases.floorStringCase} | |||||
| AND (SELECT COUNT(*) FROM truck t3 | AND (SELECT COUNT(*) FROM truck t3 | ||||
| WHERE t3.shopId = do.shopId AND t3.deleted = 0 | WHERE t3.shopId = do.shopId AND t3.deleted = 0 | ||||
| AND t3.Store_id = '4F') > 1 | AND t3.Store_id = '4F') > 1 | ||||
| AND IFNULL(dpor.RequiredDeliveryDate, do.estimatedArrivalDate) IS NOT NULL | |||||
| AND IFNULL(dopo.requiredDeliveryDate, do.estimatedArrivalDate) IS NOT NULL | |||||
| AND t2.TruckLanceCode LIKE CONCAT('%', | AND t2.TruckLanceCode LIKE CONCAT('%', | ||||
| CASE DAYNAME(IFNULL(dpor.RequiredDeliveryDate, do.estimatedArrivalDate)) | |||||
| CASE DAYNAME(IFNULL(dopo.requiredDeliveryDate, do.estimatedArrivalDate)) | |||||
| WHEN 'Monday' THEN 'Mon' | WHEN 'Monday' THEN 'Mon' | ||||
| WHEN 'Tuesday' THEN 'Tue' | WHEN 'Tuesday' THEN 'Tue' | ||||
| WHEN 'Wednesday' THEN 'Wed' | WHEN 'Wednesday' THEN 'Wed' | ||||
| @@ -170,11 +166,7 @@ FORMAT(ROUND(IFNULL(IFNULL(sol.qty, dol.qty), 0), 0), 0) AS qty, | |||||
| ELSE '' | ELSE '' | ||||
| END, '%')) | END, '%')) | ||||
| OR | OR | ||||
| t2.Store_id = CASE | |||||
| WHEN supplier.code = 'P06B' THEN '4F' | |||||
| WHEN supplier.code IN ('P07', 'P06D') THEN '2F' | |||||
| ELSE NULL | |||||
| END | |||||
| t2.Store_id = ${supplierFloorSqlCases.floorStringCase} | |||||
| ) | ) | ||||
| ORDER BY t2.DepartureTime ASC | ORDER BY t2.DepartureTime ASC | ||||
| LIMIT 1), | LIMIT 1), | ||||
| @@ -183,13 +175,12 @@ FORMAT(ROUND(IFNULL(IFNULL(sol.qty, dol.qty), 0), 0), 0) AS qty, | |||||
| '' AS driver, | '' AS driver, | ||||
| IFNULL(do.code, '') AS deliveryOrderNo, | IFNULL(do.code, '') AS deliveryOrderNo, | ||||
| IFNULL(qc.name, '') AS stockSubCategory | IFNULL(qc.name, '') AS stockSubCategory | ||||
| FROM do_pick_order_line_record dpolr | |||||
| LEFT JOIN do_pick_order_record dpor | |||||
| ON dpolr.do_pick_order_id = dpor.record_id | |||||
| AND dpor.deleted = 0 | |||||
| AND dpor.ticket_status = 'completed' | |||||
| FROM delivery_order_pick_order dopo | |||||
| INNER JOIN pick_order po | |||||
| ON po.deliveryOrderPickOrderId = dopo.id | |||||
| AND po.deleted = 0 | |||||
| INNER JOIN delivery_order do | INNER JOIN delivery_order do | ||||
| ON dpolr.do_order_id = do.id | |||||
| ON po.doId = do.id | |||||
| AND do.deleted = 0 | AND do.deleted = 0 | ||||
| LEFT JOIN shop supplier | LEFT JOIN shop supplier | ||||
| ON do.supplierId = supplier.id | ON do.supplierId = supplier.id | ||||
| @@ -197,8 +188,12 @@ FORMAT(ROUND(IFNULL(IFNULL(sol.qty, dol.qty), 0), 0), 0) AS qty, | |||||
| LEFT JOIN shop sp | LEFT JOIN shop sp | ||||
| ON do.shopId = sp.id | ON do.shopId = sp.id | ||||
| AND sp.deleted = 0 | AND sp.deleted = 0 | ||||
| LEFT JOIN pick_order_line pol | |||||
| ON pol.poId = po.id | |||||
| AND pol.deleted = 0 | |||||
| LEFT JOIN delivery_order_line dol | LEFT JOIN delivery_order_line dol | ||||
| ON do.id = dol.deliveryOrderId | |||||
| ON dol.deliveryOrderId = do.id | |||||
| AND dol.itemId = pol.itemId | |||||
| AND dol.deleted = 0 | AND dol.deleted = 0 | ||||
| LEFT JOIN items it | LEFT JOIN items it | ||||
| ON dol.itemId = it.id | ON dol.itemId = it.id | ||||
| @@ -215,10 +210,6 @@ FORMAT(ROUND(IFNULL(IFNULL(sol.qty, dol.qty), 0), 0), 0) AS qty, | |||||
| AND iu.stockUnit = 1 | AND iu.stockUnit = 1 | ||||
| LEFT JOIN uom_conversion uc | LEFT JOIN uom_conversion uc | ||||
| ON iu.uomId = uc.id | ON iu.uomId = uc.id | ||||
| LEFT JOIN pick_order_line pol | |||||
| ON dpolr.pick_order_id = pol.poId | |||||
| AND pol.itemId = it.id | |||||
| AND pol.deleted = 0 | |||||
| LEFT JOIN stock_out_line sol | LEFT JOIN stock_out_line sol | ||||
| ON pol.id = sol.pickOrderLineId | ON pol.id = sol.pickOrderLineId | ||||
| AND sol.itemId = it.id | AND sol.itemId = it.id | ||||
| @@ -234,8 +225,8 @@ FORMAT(ROUND(IFNULL(IFNULL(sol.qty, dol.qty), 0), 0), 0) AS qty, | |||||
| ON il.stockInLineId = sil.id | ON il.stockInLineId = sil.id | ||||
| AND sil.deleted = 0 | AND sil.deleted = 0 | ||||
| WHERE | WHERE | ||||
| dpolr.deleted = 0 | |||||
| AND (dpor.id IS NULL OR dpor.ticket_status = 'completed') | |||||
| dopo.deleted = 0 | |||||
| AND dopo.ticketStatus = 'completed' | |||||
| AND COALESCE(sol.qty, dol.qty, 0) <> 0 | AND COALESCE(sol.qty, dol.qty, 0) <> 0 | ||||
| $stockCategorySql | $stockCategorySql | ||||
| $stockSubCategorySql | $stockSubCategorySql | ||||
| @@ -258,13 +249,23 @@ return result | |||||
| fun getDistinctHandlersForFGStockOutTraceability(): List<String> { | fun getDistinctHandlersForFGStockOutTraceability(): List<String> { | ||||
| val sql = """ | val sql = """ | ||||
| SELECT DISTINCT COALESCE(picker_user.name, modified_user.name, '') AS handler | |||||
| FROM stock_out_line sol | |||||
| INNER JOIN stock_out so ON sol.stockOutId = so.id AND so.deleted = 0 AND so.type = 'do' | |||||
| LEFT JOIN user picker_user ON sol.handled_by = picker_user.id AND picker_user.deleted = 0 | |||||
| LEFT JOIN user modified_user ON sol.modifiedBy = modified_user.staffNo AND modified_user.deleted = 0 AND sol.handled_by IS NULL | |||||
| WHERE sol.deleted = 0 | |||||
| ORDER BY handler | |||||
| SELECT DISTINCT h.handler | |||||
| FROM ( | |||||
| SELECT TRIM(COALESCE(picker_user.name, modified_user.name, '')) AS handler | |||||
| FROM stock_out_line sol | |||||
| INNER JOIN stock_out so ON sol.stockOutId = so.id AND so.deleted = 0 AND so.type = 'do' | |||||
| LEFT JOIN user picker_user ON sol.handled_by = picker_user.id AND picker_user.deleted = 0 | |||||
| LEFT JOIN user modified_user ON sol.modifiedBy = modified_user.staffNo AND modified_user.deleted = 0 AND sol.handled_by IS NULL | |||||
| WHERE sol.deleted = 0 | |||||
| UNION | |||||
| SELECT TRIM(IFNULL(handlerName, '')) AS handler | |||||
| FROM delivery_order_pick_order | |||||
| WHERE deleted = 0 | |||||
| AND ticketStatus = 'completed' | |||||
| AND IFNULL(handlerName, '') <> '' | |||||
| ) h | |||||
| WHERE TRIM(IFNULL(h.handler, '')) <> '' | |||||
| ORDER BY h.handler | |||||
| """.trimIndent() | """.trimIndent() | ||||
| return jdbcDao.queryForList(sql, emptyMap<String, Any>()).map { row -> (row["handler"]?.toString() ?: "").trim() }.filter { it.isNotBlank() } | return jdbcDao.queryForList(sql, emptyMap<String, Any>()).map { row -> (row["handler"]?.toString() ?: "").trim() }.filter { it.isNotBlank() } | ||||
| } | } | ||||
| @@ -5,6 +5,7 @@ import java.util.List; | |||||
| import org.springframework.http.HttpStatus; | import org.springframework.http.HttpStatus; | ||||
| import org.springframework.web.bind.annotation.GetMapping; | import org.springframework.web.bind.annotation.GetMapping; | ||||
| import org.springframework.web.bind.annotation.PatchMapping; | import org.springframework.web.bind.annotation.PatchMapping; | ||||
| import org.springframework.web.bind.annotation.PostMapping; | |||||
| import org.springframework.web.bind.annotation.PathVariable; | import org.springframework.web.bind.annotation.PathVariable; | ||||
| import org.springframework.web.bind.annotation.RequestBody; | import org.springframework.web.bind.annotation.RequestBody; | ||||
| import org.springframework.web.bind.annotation.RequestMapping; | import org.springframework.web.bind.annotation.RequestMapping; | ||||
| @@ -41,13 +42,24 @@ public class SettingsController{ | |||||
| // @PreAuthorize("hasAuthority('ADMIN')") | // @PreAuthorize("hasAuthority('ADMIN')") | ||||
| @ResponseStatus(HttpStatus.NO_CONTENT) | @ResponseStatus(HttpStatus.NO_CONTENT) | ||||
| public void update(@PathVariable String name, @RequestBody @Valid UpdateReq body) { | public void update(@PathVariable String name, @RequestBody @Valid UpdateReq body) { | ||||
| applyUpdate(name, body); | |||||
| } | |||||
| /** Same as PATCH; use from browsers where CORS preflight for PATCH is blocked. */ | |||||
| @PostMapping("/{name}") | |||||
| @ResponseStatus(HttpStatus.NO_CONTENT) | |||||
| public void updatePost(@PathVariable String name, @RequestBody @Valid UpdateReq body) { | |||||
| applyUpdate(name, body); | |||||
| } | |||||
| private void applyUpdate(String name, UpdateReq body) { | |||||
| Settings entity = this.settingsService.findByName(name) | Settings entity = this.settingsService.findByName(name) | ||||
| .orElseThrow(NotFoundException::new); | .orElseThrow(NotFoundException::new); | ||||
| if (!this.settingsService.validateType(entity.getType(), body.value)) { | |||||
| if (!this.settingsService.validateType(entity.getType(), body.getValue())) { | |||||
| throw new BadRequestException(); | throw new BadRequestException(); | ||||
| } | } | ||||
| entity.setValue(body.value); | |||||
| entity.setValue(body.getValue()); | |||||
| this.settingsService.save(entity); | this.settingsService.save(entity); | ||||
| } | } | ||||
| @@ -14,11 +14,22 @@ import java.util.Optional | |||||
| interface InventoryRepository: AbstractRepository<Inventory, Long> { | interface InventoryRepository: AbstractRepository<Inventory, Long> { | ||||
| fun findInventoryInfoByDeletedIsFalse(): List<InventoryInfo> | fun findInventoryInfoByDeletedIsFalse(): List<InventoryInfo> | ||||
| @Query("SELECT i FROM Inventory i " + | |||||
| "WHERE (:code IS NULL OR i.item.code LIKE CONCAT('%', :code, '%')) " + | |||||
| "AND (:name IS NULL OR i.item.name LIKE CONCAT('%', :name, '%')) " + | |||||
| "AND (:type IS NULL OR :type = '' OR i.item.type = :type) " + | |||||
| "AND i.deleted = false") | |||||
| @Query( | |||||
| """ | |||||
| SELECT i FROM Inventory i | |||||
| WHERE (:code IS NULL OR i.item.code LIKE CONCAT('%', :code, '%')) | |||||
| AND (:name IS NULL OR i.item.name LIKE CONCAT('%', :name, '%')) | |||||
| AND (:type IS NULL OR :type = '' OR i.item.type = :type) | |||||
| AND i.deleted = false | |||||
| AND EXISTS ( | |||||
| SELECT 1 FROM ItemUom iu | |||||
| WHERE iu.item.id = i.item.id | |||||
| AND iu.deleted = false | |||||
| AND iu.baseUnit = true | |||||
| AND iu.uom.id = i.uom.id | |||||
| ) | |||||
| """ | |||||
| ) | |||||
| fun findInventoryInfoByItemCodeContainsAndItemNameContainsAndItemTypeAndDeletedIsFalse(code: String, name: String, type: String, pageable: Pageable): Page<InventoryInfo> | fun findInventoryInfoByItemCodeContainsAndItemNameContainsAndItemTypeAndDeletedIsFalse(code: String, name: String, type: String, pageable: Pageable): Page<InventoryInfo> | ||||
| @Query("SELECT i FROM Inventory i " + | @Query("SELECT i FROM Inventory i " + | ||||
| @@ -1,11 +1,12 @@ | |||||
| package com.ffii.fpsms.modules.stock.entity | package com.ffii.fpsms.modules.stock.entity | ||||
| import com.ffii.core.support.AbstractRepository | import com.ffii.core.support.AbstractRepository | ||||
| import org.springframework.data.domain.Page | |||||
| import org.springframework.data.domain.Pageable | |||||
| import org.springframework.data.jpa.repository.Query | import org.springframework.data.jpa.repository.Query | ||||
| import org.springframework.data.repository.query.Param | import org.springframework.data.repository.query.Param | ||||
| import org.springframework.stereotype.Repository | import org.springframework.stereotype.Repository | ||||
| import java.time.LocalDate | |||||
| import java.util.Optional | |||||
| import java.time.LocalDateTime | |||||
| @Repository | @Repository | ||||
| interface StockLedgerRepository: AbstractRepository<StockLedger, Long> { | interface StockLedgerRepository: AbstractRepository<StockLedger, Long> { | ||||
| @@ -19,17 +20,17 @@ interface StockLedgerRepository: AbstractRepository<StockLedger, Long> { | |||||
| AND (:itemCode IS NULL OR sl.itemCode LIKE CONCAT('%', :itemCode, '%')) | AND (:itemCode IS NULL OR sl.itemCode LIKE CONCAT('%', :itemCode, '%')) | ||||
| AND (:itemName IS NULL OR i.name LIKE CONCAT('%', :itemName, '%')) | AND (:itemName IS NULL OR i.name LIKE CONCAT('%', :itemName, '%')) | ||||
| AND (:type IS NULL OR sl.type = :type) | AND (:type IS NULL OR sl.type = :type) | ||||
| AND (:startDate IS NULL OR DATE(sl.created) >= :startDate) | |||||
| AND (:endDate IS NULL OR DATE(sl.created) <= :endDate) | |||||
| ORDER BY sl.created ASC, sl.itemId | |||||
| AND (:startDateTime IS NULL OR sl.created >= :startDateTime) | |||||
| AND (:endDateExclusive IS NULL OR sl.created < :endDateExclusive) | |||||
| """) | """) | ||||
| fun findStockTransactions( | fun findStockTransactions( | ||||
| @Param("itemCode") itemCode: String?, | @Param("itemCode") itemCode: String?, | ||||
| @Param("itemName") itemName: String?, | @Param("itemName") itemName: String?, | ||||
| @Param("type") type: String?, | @Param("type") type: String?, | ||||
| @Param("startDate") startDate: LocalDate?, | |||||
| @Param("endDate") endDate: LocalDate? | |||||
| ): List<StockLedger> | |||||
| @Param("startDateTime") startDateTime: LocalDateTime?, | |||||
| @Param("endDateExclusive") endDateExclusive: LocalDateTime?, | |||||
| pageable: Pageable | |||||
| ): Page<StockLedger> | |||||
| @Query(""" | @Query(""" | ||||
| SELECT COUNT(sl) FROM StockLedger sl | SELECT COUNT(sl) FROM StockLedger sl | ||||
| @@ -39,15 +40,15 @@ interface StockLedgerRepository: AbstractRepository<StockLedger, Long> { | |||||
| AND (:itemCode IS NULL OR sl.itemCode LIKE CONCAT('%', :itemCode, '%')) | AND (:itemCode IS NULL OR sl.itemCode LIKE CONCAT('%', :itemCode, '%')) | ||||
| AND (:itemName IS NULL OR i.name LIKE CONCAT('%', :itemName, '%')) | AND (:itemName IS NULL OR i.name LIKE CONCAT('%', :itemName, '%')) | ||||
| AND (:type IS NULL OR sl.type = :type) | AND (:type IS NULL OR sl.type = :type) | ||||
| AND (:startDate IS NULL OR DATE(sl.created) >= :startDate) | |||||
| AND (:endDate IS NULL OR DATE(sl.created) <= :endDate) | |||||
| AND (:startDateTime IS NULL OR sl.created >= :startDateTime) | |||||
| AND (:endDateExclusive IS NULL OR sl.created < :endDateExclusive) | |||||
| """) | """) | ||||
| fun countStockTransactions( | fun countStockTransactions( | ||||
| @Param("itemCode") itemCode: String?, | @Param("itemCode") itemCode: String?, | ||||
| @Param("itemName") itemName: String?, | @Param("itemName") itemName: String?, | ||||
| @Param("type") type: String?, | @Param("type") type: String?, | ||||
| @Param("startDate") startDate: LocalDate?, | |||||
| @Param("endDate") endDate: LocalDate? | |||||
| @Param("startDateTime") startDateTime: LocalDateTime?, | |||||
| @Param("endDateExclusive") endDateExclusive: LocalDateTime? | |||||
| ): Long | ): Long | ||||
| @@ -16,6 +16,7 @@ import java.time.LocalDateTime | |||||
| import java.math.BigDecimal | import java.math.BigDecimal | ||||
| import com.ffii.fpsms.modules.user.entity.UserRepository | import com.ffii.fpsms.modules.user.entity.UserRepository | ||||
| import org.springframework.data.domain.PageRequest | import org.springframework.data.domain.PageRequest | ||||
| import org.springframework.data.domain.Sort | |||||
| import com.ffii.core.response.RecordsRes | import com.ffii.core.response.RecordsRes | ||||
| import com.ffii.fpsms.modules.stock.service.InventoryLotLineService | import com.ffii.fpsms.modules.stock.service.InventoryLotLineService | ||||
| import com.ffii.fpsms.modules.stock.entity.StockTakeLine | import com.ffii.fpsms.modules.stock.entity.StockTakeLine | ||||
| @@ -2741,40 +2742,32 @@ open fun searchStockTransactions(request: SearchStockTransactionRequest): Record | |||||
| return RecordsRes(emptyList(), 0) | return RecordsRes(emptyList(), 0) | ||||
| } | } | ||||
| val startDate = request.startDate | |||||
| val endDate = request.endDate | |||||
| val startDateTime = request.startDate?.atStartOfDay() | |||||
| val endDateExclusive = request.endDate?.plusDays(1)?.atStartOfDay() | |||||
| println("Processed params: itemCode=$itemCode, itemName=$itemName, startDate=$startDate, endDate=$endDate") | |||||
| val total = stockLedgerRepository.countStockTransactions( | |||||
| itemCode = itemCode, | |||||
| itemName = itemName, | |||||
| type = request.type, | |||||
| startDate = startDate, | |||||
| endDate = endDate | |||||
| println( | |||||
| "Processed params: itemCode=$itemCode, itemName=$itemName, " + | |||||
| "startDateTime=$startDateTime, endDateExclusive=$endDateExclusive" | |||||
| ) | ) | ||||
| println("Total count: $total") | |||||
| val actualPageSize = if (request.pageSize == 100) { | |||||
| total.toInt().coerceAtLeast(1) | |||||
| } else { | |||||
| request.pageSize | |||||
| } | |||||
| val offset = request.pageNum * actualPageSize | |||||
| val pageable = PageRequest.of( | |||||
| request.pageNum.coerceAtLeast(0), | |||||
| request.pageSize.coerceAtLeast(1), | |||||
| Sort.by(Sort.Order.asc("created"), Sort.Order.asc("itemId")) | |||||
| ) | |||||
| val ledgers = stockLedgerRepository.findStockTransactions( | |||||
| val ledgerPage = stockLedgerRepository.findStockTransactions( | |||||
| itemCode = itemCode, | itemCode = itemCode, | ||||
| itemName = itemName, | itemName = itemName, | ||||
| type = request.type, | type = request.type, | ||||
| startDate = startDate, | |||||
| endDate = endDate | |||||
| startDateTime = startDateTime, | |||||
| endDateExclusive = endDateExclusive, | |||||
| pageable = pageable | |||||
| ) | ) | ||||
| println("Found ${ledgers.size} ledgers") | |||||
| println("Found ${ledgerPage.numberOfElements} ledgers in current page, total=${ledgerPage.totalElements}") | |||||
| val transactions = ledgers.map { ledger -> | |||||
| val transactions = ledgerPage.content.map { ledger -> | |||||
| val stockInLine = ledger.stockInLine | val stockInLine = ledger.stockInLine | ||||
| val stockOutLine = ledger.stockOutLine | val stockOutLine = ledger.stockOutLine | ||||
| @@ -2805,17 +2798,9 @@ open fun searchStockTransactions(request: SearchStockTransactionRequest): Record | |||||
| ) | ) | ||||
| } | } | ||||
| val sortedTransactions = transactions.sortedWith( | |||||
| compareBy<StockTransactionResponse>( | |||||
| { it.date ?: it.transactionDate?.toLocalDate() }, | |||||
| { it.transactionDate } | |||||
| ) | |||||
| ) | |||||
| val paginatedTransactions = sortedTransactions.drop(offset).take(actualPageSize) | |||||
| val totalTime = System.currentTimeMillis() - startTime | val totalTime = System.currentTimeMillis() - startTime | ||||
| println("Total time (Repository query): ${totalTime}ms, count: ${paginatedTransactions.size}, total: $total") | |||||
| println("Total time (Repository query): ${totalTime}ms, count: ${transactions.size}, total: ${ledgerPage.totalElements}") | |||||
| return RecordsRes(paginatedTransactions, total.toInt()) | |||||
| return RecordsRes(transactions, ledgerPage.totalElements.toInt()) | |||||
| } | } | ||||
| } | } | ||||
| @@ -42,6 +42,7 @@ import com.ffii.fpsms.modules.stock.entity.projection.StockOutLineInfo | |||||
| import com.ffii.fpsms.modules.stock.entity.StockOutLIneRepository | import com.ffii.fpsms.modules.stock.entity.StockOutLIneRepository | ||||
| import com.ffii.fpsms.modules.stock.web.model.StockOutStatus | import com.ffii.fpsms.modules.stock.web.model.StockOutStatus | ||||
| import com.ffii.fpsms.modules.common.SecurityUtils | import com.ffii.fpsms.modules.common.SecurityUtils | ||||
| import com.ffii.fpsms.modules.deliveryOrder.service.DoFloorSupplierSettingsService | |||||
| @Service | @Service | ||||
| open class SuggestedPickLotService( | open class SuggestedPickLotService( | ||||
| val suggestedPickLotRepository: SuggestPickLotRepository, | val suggestedPickLotRepository: SuggestPickLotRepository, | ||||
| @@ -57,7 +58,8 @@ open class SuggestedPickLotService( | |||||
| val failInventoryLotLineRepository: FailInventoryLotLineRepository, | val failInventoryLotLineRepository: FailInventoryLotLineRepository, | ||||
| val stockOutRepository: StockOutRepository, | val stockOutRepository: StockOutRepository, | ||||
| val itemRepository: ItemsRepository, | val itemRepository: ItemsRepository, | ||||
| val stockOutLineRepository: StockOutLIneRepository | |||||
| val stockOutLineRepository: StockOutLIneRepository, | |||||
| private val doFloorSupplierSettingsService: DoFloorSupplierSettingsService, | |||||
| ) { | ) { | ||||
| // Calculation Available Qty / Remaining Qty | // Calculation Available Qty / Remaining Qty | ||||
| @@ -114,6 +116,8 @@ open class SuggestedPickLotService( | |||||
| .filter { it.expiryDate.isAfter(today) || it.expiryDate.isEqual(today)} | .filter { it.expiryDate.isAfter(today) || it.expiryDate.isEqual(today)} | ||||
| .sortedBy { it.expiryDate } | .sortedBy { it.expiryDate } | ||||
| .groupBy { it.item?.id } | .groupBy { it.item?.id } | ||||
| val (floorSuppliers2F, floorSuppliers4F) = doFloorSupplierSettingsService.loadDoFloorSupplierLists() | |||||
| // loop for suggest pick lot line | // loop for suggest pick lot line | ||||
| pols.forEach { line -> | pols.forEach { line -> | ||||
| @@ -126,11 +130,11 @@ open class SuggestedPickLotService( | |||||
| val doPreferredFloor: String? = if (isDoPickOrder) { | val doPreferredFloor: String? = if (isDoPickOrder) { | ||||
| val supplierCode = pickOrder?.deliveryOrder?.supplier?.code | val supplierCode = pickOrder?.deliveryOrder?.supplier?.code | ||||
| when (supplierCode) { | |||||
| "P06B" -> "4F" | |||||
| "P07", "P06D" -> "2F" | |||||
| else -> null // 其他供应商不限定 2F/4F | |||||
| } | |||||
| doFloorSupplierSettingsService.preferredFloorForPickLotOrNull( | |||||
| supplierCode, | |||||
| floorSuppliers2F, | |||||
| floorSuppliers4F, | |||||
| ) | |||||
| } else { | } else { | ||||
| null | null | ||||
| } | } | ||||
| @@ -32,7 +32,8 @@ class StockTakeRecordController( | |||||
| @RequestParam(required = false) stockTakeSections: String?, | @RequestParam(required = false) stockTakeSections: String?, | ||||
| @RequestParam(required = false) status: String?, | @RequestParam(required = false) status: String?, | ||||
| @RequestParam(required = false) area: String?, | @RequestParam(required = false) area: String?, | ||||
| @RequestParam(required = false) storeId: String? | |||||
| @RequestParam(required = false) storeId: String?, | |||||
| @RequestParam(required = false, defaultValue = "false") onlyLatestRound: Boolean | |||||
| ): RecordsRes<AllPickedStockTakeListReponse> { | ): RecordsRes<AllPickedStockTakeListReponse> { | ||||
| var all = stockOutRecordService.AllPickedStockTakeList() | var all = stockOutRecordService.AllPickedStockTakeList() | ||||
| if (sectionDescription != null && sectionDescription != "All") { | if (sectionDescription != null && sectionDescription != "All") { | ||||
| @@ -71,6 +72,18 @@ class StockTakeRecordController( | |||||
| it.storeId?.contains(storeIdKeyword, ignoreCase = true) == true | it.storeId?.contains(storeIdKeyword, ignoreCase = true) == true | ||||
| } | } | ||||
| } | } | ||||
| if (onlyLatestRound) { | |||||
| val latestRoundKey = all | |||||
| .mapNotNull { item -> | |||||
| item.stockTakeRoundId ?: item.stockTakeId.takeIf { it > 0 } | |||||
| } | |||||
| .maxOrNull() | |||||
| all = if (latestRoundKey == null) { | |||||
| emptyList() | |||||
| } else { | |||||
| all.filter { (it.stockTakeRoundId ?: it.stockTakeId) == latestRoundKey } | |||||
| } | |||||
| } | |||||
| val total = all.size | val total = all.size | ||||
| val fromIndex = pageNum * pageSize | val fromIndex = pageNum * pageSize | ||||
| val toIndex = minOf(fromIndex + pageSize, total) | val toIndex = minOf(fromIndex + pageSize, total) | ||||
| @@ -0,0 +1,8 @@ | |||||
| package com.ffii.fpsms.modules.stock.web.model | |||||
| import com.fasterxml.jackson.annotation.JsonIgnoreProperties | |||||
| @JsonIgnoreProperties(ignoreUnknown = true) | |||||
| data class CreateStockTakeForSectionsRequest( | |||||
| val sections: List<String>? = null, | |||||
| ) | |||||
| @@ -1,6 +1,7 @@ | |||||
| package com.ffii.fpsms.modules.user.service; | package com.ffii.fpsms.modules.user.service; | ||||
| import java.util.Date; | import java.util.Date; | ||||
| import java.util.LinkedHashMap; | |||||
| import java.util.List; | import java.util.List; | ||||
| import java.util.Map; | import java.util.Map; | ||||
| import java.util.stream.Collectors; | import java.util.stream.Collectors; | ||||
| @@ -209,4 +210,13 @@ public class GroupService extends AbstractBaseEntityService<Group, Long, GroupRe | |||||
| return jdbcDao.queryForList(sql.toString(), args); | return jdbcDao.queryForList(sql.toString(), args); | ||||
| } | } | ||||
| @Transactional(rollbackFor = Exception.class) | |||||
| public Map<Integer, List<Map<String, Object>>> listAuthForUsers(List<Integer> userIds) { | |||||
| Map<Integer, List<Map<String, Object>>> result = new LinkedHashMap<>(); | |||||
| for (Integer userId : userIds) { | |||||
| result.put(userId, listAuth(Map.of("userId", userId))); | |||||
| } | |||||
| return result; | |||||
| } | |||||
| } | } | ||||
| @@ -185,8 +185,8 @@ public class UserService extends AbstractBaseEntityService<User, Long, UserRepos | |||||
| if (!authBatchDeleteValues.isEmpty()) { | if (!authBatchDeleteValues.isEmpty()) { | ||||
| jdbcDao.batchUpdate( | jdbcDao.batchUpdate( | ||||
| "DELETE FROM user_authority" | "DELETE FROM user_authority" | ||||
| + " WHERE userId = :userId ", | |||||
| // + "AND authId = :authId", | |||||
| + " WHERE userId = :userId " | |||||
| + " AND authId = :authId", | |||||
| authBatchDeleteValues); | authBatchDeleteValues); | ||||
| } | } | ||||
| if (!authBatchInsertValues.isEmpty()) { | if (!authBatchInsertValues.isEmpty()) { | ||||
| @@ -228,8 +228,8 @@ public class UserService extends AbstractBaseEntityService<User, Long, UserRepos | |||||
| if (!authBatchDeleteValues.isEmpty()) { | if (!authBatchDeleteValues.isEmpty()) { | ||||
| jdbcDao.batchUpdate( | jdbcDao.batchUpdate( | ||||
| "DELETE FROM user_authority" | "DELETE FROM user_authority" | ||||
| + " WHERE userId = :userId ", | |||||
| // + "AND authId = :authId", | |||||
| + " WHERE userId = :userId " | |||||
| + " AND authId = :authId", | |||||
| authBatchDeleteValues); | authBatchDeleteValues); | ||||
| } | } | ||||
| if (!authBatchInsertValues.isEmpty()) { | if (!authBatchInsertValues.isEmpty()) { | ||||
| @@ -1,6 +1,7 @@ | |||||
| package com.ffii.fpsms.modules.user.web; | package com.ffii.fpsms.modules.user.web; | ||||
| import java.util.HashMap; | import java.util.HashMap; | ||||
| import java.util.List; | |||||
| import java.util.Map; | import java.util.Map; | ||||
| import org.apache.commons.logging.Log; | import org.apache.commons.logging.Log; | ||||
| @@ -11,6 +12,7 @@ import org.springframework.web.bind.annotation.DeleteMapping; | |||||
| import org.springframework.web.bind.annotation.GetMapping; | import org.springframework.web.bind.annotation.GetMapping; | ||||
| import org.springframework.web.bind.annotation.PathVariable; | import org.springframework.web.bind.annotation.PathVariable; | ||||
| import org.springframework.web.bind.annotation.PostMapping; | import org.springframework.web.bind.annotation.PostMapping; | ||||
| import org.springframework.web.bind.annotation.RequestParam; | |||||
| import org.springframework.web.bind.annotation.RequestBody; | import org.springframework.web.bind.annotation.RequestBody; | ||||
| import org.springframework.web.bind.annotation.RequestMapping; | import org.springframework.web.bind.annotation.RequestMapping; | ||||
| import org.springframework.web.bind.annotation.ResponseStatus; | import org.springframework.web.bind.annotation.ResponseStatus; | ||||
| @@ -80,7 +82,6 @@ public class GroupController{ | |||||
| @GetMapping("/auth/{target}/{id}") | @GetMapping("/auth/{target}/{id}") | ||||
| public RecordsRes<Map<String, Object>> authComboJson(HttpServletRequest request, @PathVariable("id") int id, @PathVariable("target") String target) throws ServletRequestBindingException { | public RecordsRes<Map<String, Object>> authComboJson(HttpServletRequest request, @PathVariable("id") int id, @PathVariable("target") String target) throws ServletRequestBindingException { | ||||
| System.out.println(request); | |||||
| Map<String, Object> args = new HashMap<>(); | Map<String, Object> args = new HashMap<>(); | ||||
| if (id != 0){ | if (id != 0){ | ||||
| if (target.equals("group")){ | if (target.equals("group")){ | ||||
| @@ -94,4 +95,11 @@ public class GroupController{ | |||||
| return new RecordsRes<>(groupService.listAuth(args)); | return new RecordsRes<>(groupService.listAuth(args)); | ||||
| } | } | ||||
| @GetMapping("/auth/user-batch") | |||||
| public Map<Integer, List<Map<String, Object>>> authBatchByUserIds( | |||||
| @RequestParam("userIds") List<Integer> userIds | |||||
| ) { | |||||
| return groupService.listAuthForUsers(userIds); | |||||
| } | |||||
| } | } | ||||
| @@ -78,7 +78,6 @@ public class UserController{ | |||||
| @GetMapping | @GetMapping | ||||
| // @PreAuthorize("hasAuthority('VIEW_USER')") | // @PreAuthorize("hasAuthority('VIEW_USER')") | ||||
| public ResponseEntity<List<UserRecord>> list(@ModelAttribute @Valid SearchUserReq req) { | public ResponseEntity<List<UserRecord>> list(@ModelAttribute @Valid SearchUserReq req) { | ||||
| logger.info("Test List user"); | |||||
| return ResponseEntity.ok(userService.search(req)); | return ResponseEntity.ok(userService.search(req)); | ||||
| } | } | ||||
| @@ -120,13 +119,10 @@ public class UserController{ | |||||
| @GetMapping("/{id}") | @GetMapping("/{id}") | ||||
| @PreAuthorize("hasAuthority('VIEW_USER')") | @PreAuthorize("hasAuthority('VIEW_USER')") | ||||
| public LoadUserRes load(@PathVariable long id) { | public LoadUserRes load(@PathVariable long id) { | ||||
| LoadUserRes test = new LoadUserRes( | |||||
| return new LoadUserRes( | |||||
| userService.find(id).orElseThrow(NotFoundException::new), | userService.find(id).orElseThrow(NotFoundException::new), | ||||
| userService.listUserAuthId(id), | userService.listUserAuthId(id), | ||||
| userService.listUserGroupId(id)); | userService.listUserGroupId(id)); | ||||
| logger.info("Test List user2"); | |||||
| logger.info(test); | |||||
| return test; | |||||
| } | } | ||||
| @GetMapping("/user-info/{id}") | @GetMapping("/user-info/{id}") | ||||
| // @PreAuthorize("hasAuthority('VIEW_USER')") | // @PreAuthorize("hasAuthority('VIEW_USER')") | ||||
| @@ -147,7 +143,6 @@ public class UserController{ | |||||
| // @ResponseStatus(HttpStatus.CREATED) | // @ResponseStatus(HttpStatus.CREATED) | ||||
| // @PreAuthorize("hasAuthority('MAINTAIN_USER')") | // @PreAuthorize("hasAuthority('MAINTAIN_USER')") | ||||
| public IdRes newRecord(@RequestBody @Valid NewUserReq req) throws UnsupportedEncodingException { | public IdRes newRecord(@RequestBody @Valid NewUserReq req) throws UnsupportedEncodingException { | ||||
| System.out.println(req.getUsername()); | |||||
| return new IdRes(userService.newRecord(req).getId()); | return new IdRes(userService.newRecord(req).getId()); | ||||
| } | } | ||||
| @@ -27,6 +27,10 @@ scheduler: | |||||
| syncOffsetDays: 0 | syncOffsetDays: 0 | ||||
| inventoryLotExpiry: | inventoryLotExpiry: | ||||
| enabled: true | enabled: true | ||||
| # Job order: at 00:00:15 daily, process JOs whose planStart was yesterday (hide or reschedule). | |||||
| jo: | |||||
| planStart: | |||||
| enabled: true | |||||
| # Nav: PO stock_in_line pending/receiving within last N days (see ProductProcessService for 工單 QC/上架:今日+昨日). | # Nav: PO stock_in_line pending/receiving within last N days (see ProductProcessService for 工單 QC/上架:今日+昨日). | ||||
| fpsms: | fpsms: | ||||
| @@ -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; | |||||
| @@ -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; | |||||
| @@ -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`); | |||||
| @@ -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; | |||||
| @@ -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; | |||||
| @@ -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' | |||||
| ); | |||||
| @@ -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; | |||||
| @@ -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'; | |||||
| @@ -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' | |||||
| ); | |||||
| @@ -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; | |||||
| @@ -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`; | |||||
| @@ -11,6 +11,7 @@ Configutation: | |||||
| filePattern: ${log_location}fpsms-all.log.%i.gz | filePattern: ${log_location}fpsms-all.log.%i.gz | ||||
| PatternLayout: | PatternLayout: | ||||
| Pattern: "%d %p [%l] - %m%n" | Pattern: "%d %p [%l] - %m%n" | ||||
| charset: UTF-8 | |||||
| Policies: | Policies: | ||||
| SizeBasedTriggeringPolicy: | SizeBasedTriggeringPolicy: | ||||
| size: 4096KB | size: 4096KB | ||||
| @@ -11,6 +11,7 @@ Configutation: | |||||
| filePattern: ${log_location}fpsms-all.log.%i.gz | filePattern: ${log_location}fpsms-all.log.%i.gz | ||||
| PatternLayout: | PatternLayout: | ||||
| Pattern: "%d %p [%l] - %m%n" | Pattern: "%d %p [%l] - %m%n" | ||||
| charset: UTF-8 | |||||
| Policies: | Policies: | ||||
| SizeBasedTriggeringPolicy: | SizeBasedTriggeringPolicy: | ||||
| size: 4096KB | size: 4096KB | ||||
| @@ -10,6 +10,7 @@ Configutation: | |||||
| target: SYSTEM_OUT | target: SYSTEM_OUT | ||||
| PatternLayout: | PatternLayout: | ||||
| pattern: ${log_pattern} | pattern: ${log_pattern} | ||||
| charset: UTF-8 | |||||
| Loggers: | Loggers: | ||||
| Root: | Root: | ||||
| level: info | level: info | ||||