# Conflicts: # src/main/java/com/ffii/fpsms/modules/deliveryOrder/service/DoWorkbenchMainService.ktproduction
| @@ -37,4 +37,5 @@ out/ | |||
| .vscode/ | |||
| package-lock.json | |||
| 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 | |||
| """ | |||
| import errno | |||
| import json | |||
| import os | |||
| import select | |||
| @@ -344,6 +345,25 @@ DATAFLEX_UI_PROGRESS_EVERY = max( | |||
| DATAFLEX_SINGLE_TCP_JOB = _dataflex_bool_env( | |||
| "FPSMS_DATAFLEX_SINGLE_TCP_JOB", False | |||
| ) | |||
| # Link-OS SGD: raw-ZPL job shows this host id instead of a generic name (e.g. "ZPL. EMULATION"). | |||
| # Same id when the same job order is printed again. Disable: FPSMS_DATAFLEX_HOST_IDENTIFICATION_SGD=0 | |||
| DATAFLEX_HOST_IDENTIFICATION_SGD = _dataflex_bool_env( | |||
| "FPSMS_DATAFLEX_HOST_IDENTIFICATION_SGD", True | |||
| ) | |||
| # Bag ZPL size (dots). ^PW700 matched little content (mostly vertical ^A@R), so previews showed a wide strip with empty right margin. | |||
| DATAFLEX_LABEL_PW = max( | |||
| 280, | |||
| _dataflex_int_env("FPSMS_DATAFLEX_LABEL_PW", 400), | |||
| ) | |||
| DATAFLEX_LABEL_LL = max( | |||
| 200, | |||
| _dataflex_int_env("FPSMS_DATAFLEX_LABEL_LL", 500), | |||
| ) | |||
| # Some Zebra/DataFlex units RST the socket on host half-close; Windows surfaces WinError 10054. | |||
| # Set FPSMS_DATAFLEX_SKIP_SHUTDOWN_WR=1 to omit shutdown(SHUT_WR) and only close() (often avoids RST). | |||
| DATAFLEX_SKIP_SHUTDOWN_WR = _dataflex_bool_env( | |||
| "FPSMS_DATAFLEX_SKIP_SHUTDOWN_WR", False | |||
| ) | |||
| # Full recovery (~JR soft reset) — used by「打袋重設」only; longer delay for firmware | |||
| DATAFLEX_POST_FULL_RECOVERY_DELAY_SEC = 1.2 | |||
| # Zebra ~RO only (used when FPSMS_DATAFLEX_NO_JR is set for full recovery) | |||
| @@ -364,12 +384,56 @@ def _zpl_escape(s: str) -> str: | |||
| return s.replace("\\", "\\\\").replace("^", "\\^") | |||
| def _dataflex_host_identification_sgd_prefix(job_order_id: Optional[int]) -> str: | |||
| """ | |||
| Optional ASCII prefix before ^XA: set zpl.host_identification so the printer lists the job | |||
| under the job order id instead of a generic raw-ZPL label. | |||
| """ | |||
| if not DATAFLEX_HOST_IDENTIFICATION_SGD or job_order_id is None: | |||
| return "" | |||
| try: | |||
| jid = str(int(job_order_id)) | |||
| except (TypeError, ValueError): | |||
| return "" | |||
| if not jid.isdigit(): | |||
| return "" | |||
| return f'! U1 setvar "zpl.host_identification" "{jid}"\r\n' | |||
| def _dataflex_zpl_bytes(zpl: str) -> bytes: | |||
| """UTF-8 ZPL with one trailing CRLF so the printer sees a clear job boundary.""" | |||
| s = (zpl or "").rstrip("\r\n") | |||
| return (s + "\r\n").encode("utf-8") | |||
| def _dataflex_is_benign_tcp_reset(err: BaseException) -> bool: | |||
| """True when peer closed with RST/FIN in a way that is normal for raw printer TCP (Windows 10054).""" | |||
| if isinstance(err, (BrokenPipeError, ConnectionResetError, ConnectionAbortedError)): | |||
| return True | |||
| if isinstance(err, OSError): | |||
| if getattr(err, "winerror", None) == 10054: # WSAECONNRESET | |||
| return True | |||
| if err.errno in ( | |||
| errno.ECONNRESET, | |||
| errno.EPIPE, | |||
| errno.ECONNABORTED, | |||
| ): | |||
| return True | |||
| return False | |||
| def _dataflex_shutdown_write_maybe(sock: socket.socket) -> None: | |||
| """Half-close write side; ignore printer RST (common after ZPL on port 9100-style links).""" | |||
| if DATAFLEX_SKIP_SHUTDOWN_WR: | |||
| return | |||
| try: | |||
| sock.shutdown(socket.SHUT_WR) | |||
| except OSError as e: | |||
| if _dataflex_is_benign_tcp_reset(e): | |||
| return | |||
| raise | |||
| def generate_zpl_dataflex( | |||
| batch_no: str, | |||
| item_code: str, | |||
| @@ -377,6 +441,7 @@ def generate_zpl_dataflex( | |||
| item_id: Optional[int] = None, | |||
| stock_in_line_id: Optional[int] = None, | |||
| lot_no: Optional[str] = None, | |||
| job_order_id: Optional[int] = None, | |||
| font_regular: str = "E:STXihei.ttf", | |||
| font_bold: str = "E:STXihei.ttf", | |||
| ) -> str: | |||
| @@ -398,11 +463,12 @@ def generate_zpl_dataflex( | |||
| qr_value = _zpl_escape(qr_payload) | |||
| # Explicit ^PQ1: each ^XA…^XZ is exactly one bag. Avoids E1005 "over quantity" on some Zebra/DataFlex | |||
| # firmware when many labels are sent on one TCP session without a per-job quantity. | |||
| return f"""^XA | |||
| host_id = _dataflex_host_identification_sgd_prefix(job_order_id) | |||
| return host_id + f"""^XA | |||
| ^PQ1,0,1,N | |||
| ^CI28 | |||
| ^PW700 | |||
| ^LL500 | |||
| ^PW{DATAFLEX_LABEL_PW} | |||
| ^LL{DATAFLEX_LABEL_LL} | |||
| ^PO N | |||
| ^FO10,20 | |||
| ^BQN,2,4^FDQA,{qr_value}^FS | |||
| @@ -447,10 +513,7 @@ def send_dataflex_preprint_reset(ip: str, port: int, *, force: bool = False) -> | |||
| sock.connect((ip, port)) | |||
| sock.sendall(DATAFLEX_PREPRINT_BYTES) | |||
| time.sleep(DATAFLEX_POST_PREPRINT_DELAY_SEC) | |||
| try: | |||
| sock.shutdown(socket.SHUT_WR) | |||
| except OSError: | |||
| pass | |||
| _dataflex_shutdown_write_maybe(sock) | |||
| finally: | |||
| sock.close() | |||
| @@ -472,10 +535,7 @@ def send_dataflex_job_counter_reset(ip: str, port: int, *, force: bool = False) | |||
| sock.connect((ip, port)) | |||
| sock.sendall(_dataflex_full_recovery_payload()) | |||
| time.sleep(DATAFLEX_POST_FULL_RECOVERY_DELAY_SEC) | |||
| try: | |||
| sock.shutdown(socket.SHUT_WR) | |||
| except OSError: | |||
| pass | |||
| _dataflex_shutdown_write_maybe(sock) | |||
| finally: | |||
| sock.close() | |||
| @@ -527,10 +587,7 @@ def send_dataflex_reset_and_labels( | |||
| time.sleep(DATAFLEX_POST_LABEL_SETTLE_SEC) | |||
| if i < copies - 1: | |||
| time.sleep(delay_sec) | |||
| try: | |||
| sock.shutdown(socket.SHUT_WR) | |||
| except OSError: | |||
| pass | |||
| _dataflex_shutdown_write_maybe(sock) | |||
| finally: | |||
| sock.close() | |||
| @@ -879,10 +936,7 @@ def send_zpl_to_dataflex(ip: str, port: int, zpl: str) -> None: | |||
| sock.connect((ip, port)) | |||
| sock.sendall(_dataflex_zpl_bytes(zpl)) | |||
| time.sleep(DATAFLEX_POST_LABEL_SETTLE_SEC) | |||
| try: | |||
| sock.shutdown(socket.SHUT_WR) | |||
| except OSError: | |||
| pass | |||
| _dataflex_shutdown_write_maybe(sock) | |||
| finally: | |||
| sock.close() | |||
| @@ -907,6 +961,10 @@ def query_dataflex_host_status(ip: str, port: int) -> str: | |||
| data = sock.recv(4096) | |||
| except socket.timeout: | |||
| break | |||
| except OSError as ex: | |||
| if _dataflex_is_benign_tcp_reset(ex): | |||
| break | |||
| raise | |||
| if not data: | |||
| break | |||
| chunks.append(data) | |||
| @@ -2451,6 +2509,7 @@ def main() -> None: | |||
| item_id=item_id, | |||
| stock_in_line_id=stock_in_line_id, | |||
| lot_no=lot_no, | |||
| job_order_id=j.get("id"), | |||
| ) | |||
| label_text = (lot_no or b).strip() | |||
| if continuous: | |||
| @@ -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 { | |||
| val deliveryOrdersWithType = getDeliveryOrdersWithType(request) | |||
| return saveDeliveryOrdersWithPreparedList(deliveryOrdersWithType, syncIsEtra = false) | |||
| return saveDeliveryOrdersWithPreparedList(deliveryOrdersWithType, syncisExtra = false) | |||
| } | |||
| /** | |||
| * Sync a single M18 shop PO / delivery order by document [code], same search pattern as | |||
| * [com.ffii.fpsms.m18.service.M18PurchaseOrderService.savePurchaseOrderByCode]. | |||
| * | |||
| * @param isEtraSync when true, persist local `delivery_order.isEtra=true` (manual DO(加單) sync). | |||
| * @param isExtraSync when true, persist local `delivery_order.isExtra=true` (manual DO(加單) sync). | |||
| * No M18-side "加單" filtering is used. | |||
| * @param newOnly when true, skip if a non-deleted local DO already exists with the same `code`. | |||
| */ | |||
| open fun saveDeliveryOrderByCode( | |||
| code: String, | |||
| isEtraSync: Boolean = false, | |||
| isExtraSync: Boolean = false, | |||
| newOnly: Boolean = false, | |||
| ): SyncResult { | |||
| if (newOnly && deliveryOrderRepository.existsByCodeAndDeletedIsFalse(code)) { | |||
| @@ -210,12 +210,12 @@ open class M18DeliveryOrderService( | |||
| query = conds | |||
| ) | |||
| return saveDeliveryOrdersWithPreparedList(prepared, syncIsEtra = isEtraSync) | |||
| return saveDeliveryOrdersWithPreparedList(prepared, syncisExtra = isExtraSync) | |||
| } | |||
| private fun saveDeliveryOrdersWithPreparedList( | |||
| deliveryOrdersWithType: M18PurchaseOrderListResponseWithType?, | |||
| syncIsEtra: Boolean = false, | |||
| syncisExtra: Boolean = false, | |||
| ): SyncResult { | |||
| logger.info("--------------------------------------------Start - Saving M18 Delivery Order--------------------------------------------") | |||
| @@ -303,7 +303,7 @@ open class M18DeliveryOrderService( | |||
| handlerId = null, | |||
| m18BeId = mainpo.beId, | |||
| deleted = mainpo.udfIsVoid == true, | |||
| isEtra = syncIsEtra, | |||
| isExtra = syncisExtra, | |||
| ) | |||
| val saveDeliveryOrderResponse = | |||
| @@ -4,8 +4,9 @@ import com.ffii.core.utils.JwtTokenUtil | |||
| import com.ffii.fpsms.m18.M18Config | |||
| import com.ffii.fpsms.m18.model.SyncResult | |||
| import com.ffii.fpsms.m18.service.* | |||
| import com.ffii.fpsms.m18.model.M18BomShopSyncTriggerResult | |||
| import com.ffii.fpsms.m18.web.models.M18CommonRequest | |||
| import com.ffii.fpsms.modules.common.SettingNames | |||
| import com.ffii.fpsms.modules.master.service.BomService | |||
| import com.ffii.fpsms.modules.common.scheduler.service.SchedulerService | |||
| import com.ffii.fpsms.modules.master.entity.ItemUom | |||
| import com.ffii.fpsms.modules.master.entity.Items | |||
| @@ -35,6 +36,7 @@ class M18TestController ( | |||
| private val m18DeliveryOrderService: M18DeliveryOrderService, | |||
| val schedulerService: SchedulerService, | |||
| private val settingsService: SettingsService, | |||
| private val bomService: BomService, | |||
| ) { | |||
| var logger: Logger = LoggerFactory.getLogger(JwtTokenUtil::class.java) | |||
| @@ -65,6 +67,14 @@ class M18TestController ( | |||
| return schedulerService.getM18Pos(); | |||
| } | |||
| @PostMapping("/test/bom-shop-sync/{bomId}") | |||
| fun testBomShopSync( | |||
| @PathVariable bomId: Long, | |||
| @RequestParam(required = false) m18HeaderId: Long?, | |||
| ): M18BomShopSyncTriggerResult { | |||
| return bomService.pushBomToM18ShopIfAllowed(bomId, m18HeaderId) | |||
| } | |||
| @GetMapping("/test/po-by-code") | |||
| fun testSyncPoByCode(@RequestParam code: String): SyncResult { | |||
| return m18PurchaseOrderService.savePurchaseOrderByCode(code) | |||
| @@ -72,14 +82,14 @@ class M18TestController ( | |||
| @GetMapping("/test/do-by-code") | |||
| fun testSyncDoByCode(@RequestParam code: String): SyncResult { | |||
| return m18DeliveryOrderService.saveDeliveryOrderByCode(code, isEtraSync = false) | |||
| return m18DeliveryOrderService.saveDeliveryOrderByCode(code, isExtraSync = false) | |||
| } | |||
| /** DO(加單):手動按 code 同步,並寫入本地 [DeliveryOrder.isEtra]=true(不做 M18 端加單條件過濾) */ | |||
| /** DO(加單):手動按 code 同步,並寫入本地 [DeliveryOrder.isExtra]=true(不做 M18 端加單條件過濾) */ | |||
| @GetMapping("/test/do-by-code-extra") | |||
| fun testSyncDoByCodeExtra(@RequestParam code: String): SyncResult { | |||
| // 加單 tab: only sync when it's a NEW order (not existing in local system) | |||
| return m18DeliveryOrderService.saveDeliveryOrderByCode(code, isEtraSync = true, newOnly = true) | |||
| return m18DeliveryOrderService.saveDeliveryOrderByCode(code, isExtraSync = true, newOnly = true) | |||
| } | |||
| @GetMapping("/test/product-by-code") | |||
| @@ -29,7 +29,7 @@ open class BagService( | |||
| ) { | |||
| open fun createBagLotLinesByBagId(request: CreateBagLotLineRequest): MessageResponse { | |||
| val bag = bagRepository.findById(request.bagId).orElse(null) | |||
| val lot = inventoryLotRepository.findByLotNoAndItemId(request.lotNo, request.itemId) | |||
| val lot = inventoryLotRepository.findByIdAndDeletedFalse(request.lotId) | |||
| val BaseUnitOfMeasure= itemUomRepository.findByItemIdAndStockUnitIsTrueAndDeletedIsFalse(request.itemId) | |||
| val baseRatioN = BaseUnitOfMeasure?.ratioN ?: BigDecimal.ONE | |||
| println("baseRatioN: $baseRatioN") | |||
| @@ -40,27 +40,28 @@ open class ChartService( | |||
| /** | |||
| * Delivery orders: order count and total line qty by date. | |||
| * Uses delivery_order.completeDate or estimatedArrivalDate for date. | |||
| * X-axis date: [delivery_order.estimatedArrivalDate] only (no completeDate/orderDate fallback). | |||
| * Rows without estimatedArrivalDate are excluded. | |||
| */ | |||
| fun getDeliveryOrderByDate(startDate: LocalDate?, endDate: LocalDate?): List<Map<String, Any>> { | |||
| val args = mutableMapOf<String, Any>() | |||
| val startSql = if (startDate != null) { | |||
| args["startDate"] = startDate.toString() | |||
| "AND DATE(COALESCE(do.completeDate, do.estimatedArrivalDate, do.orderDate)) >= :startDate" | |||
| "AND DATE(do.estimatedArrivalDate) >= :startDate" | |||
| } else "" | |||
| val endSql = if (endDate != null) { | |||
| args["endDate"] = endDate.toString() | |||
| "AND DATE(COALESCE(do.completeDate, do.estimatedArrivalDate, do.orderDate)) <= :endDate" | |||
| "AND DATE(do.estimatedArrivalDate) <= :endDate" | |||
| } else "" | |||
| val sql = """ | |||
| SELECT | |||
| DATE_FORMAT(COALESCE(do.completeDate, do.estimatedArrivalDate, do.orderDate), '%Y-%m-%d') AS date, | |||
| DATE_FORMAT(do.estimatedArrivalDate, '%Y-%m-%d') AS date, | |||
| COUNT(DISTINCT do.id) AS orderCount, | |||
| COALESCE(SUM(dol.qty), 0) AS totalQty | |||
| FROM delivery_order do | |||
| LEFT JOIN delivery_order_line dol ON dol.deliveryOrderId = do.id AND dol.deleted = 0 | |||
| WHERE do.deleted = 0 $startSql $endSql | |||
| GROUP BY DATE(COALESCE(do.completeDate, do.estimatedArrivalDate, do.orderDate)) | |||
| WHERE do.deleted = 0 AND do.estimatedArrivalDate IS NOT NULL $startSql $endSql | |||
| GROUP BY DATE(do.estimatedArrivalDate) | |||
| ORDER BY date | |||
| """.trimIndent() | |||
| return jdbcDao.queryForList(sql, args) | |||
| @@ -529,17 +530,32 @@ open class ChartService( | |||
| * Stock in vs stock out by date. | |||
| * Stock in: stock_in_line.acceptedQty, date from stock_in.completeDate or receiptDate/created. | |||
| * Stock out: stock_out_line.qty, date from stock_out.completeDate or created. | |||
| * | |||
| * Date range is applied inside each UNION branch (predicate pushdown) so we do not aggregate | |||
| * all history before filtering. Reads filtered headers first via STRAIGHT_JOIN (si/so then lines). | |||
| */ | |||
| fun getStockInOutByDate(startDate: LocalDate?, endDate: LocalDate?): List<Map<String, Any>> { | |||
| val args = mutableMapOf<String, Any>() | |||
| val startSql = if (startDate != null) { | |||
| args["startDate"] = startDate.toString() | |||
| "AND u.dt >= :startDate" | |||
| } else "" | |||
| val endSql = if (endDate != null) { | |||
| args["endDate"] = endDate.toString() | |||
| "AND u.dt <= :endDate" | |||
| } else "" | |||
| if (startDate != null) args["startDate"] = startDate.toString() | |||
| if (endDate != null) args["endDate"] = endDate.toString() | |||
| val inDateFilter = buildString { | |||
| if (startDate != null) { | |||
| append(" AND DATE(COALESCE(si.completeDate, sil.receiptDate, si.created)) >= :startDate") | |||
| } | |||
| if (endDate != null) { | |||
| append(" AND DATE(COALESCE(si.completeDate, sil.receiptDate, si.created)) <= :endDate") | |||
| } | |||
| } | |||
| val outDateFilter = buildString { | |||
| if (startDate != null) { | |||
| append(" AND DATE(COALESCE(so.completeDate, so.created)) >= :startDate") | |||
| } | |||
| if (endDate != null) { | |||
| append(" AND DATE(COALESCE(so.completeDate, so.created)) <= :endDate") | |||
| } | |||
| } | |||
| val startSql = if (startDate != null) "AND u.dt >= :startDate" else "" | |||
| val endSql = if (endDate != null) "AND u.dt <= :endDate" else "" | |||
| val sql = """ | |||
| SELECT DATE_FORMAT(u.dt, '%Y-%m-%d') AS date, | |||
| COALESCE(SUM(u.inQty), 0) AS inQty, | |||
| @@ -547,16 +563,16 @@ open class ChartService( | |||
| FROM ( | |||
| SELECT DATE(COALESCE(si.completeDate, sil.receiptDate, si.created)) AS dt, | |||
| SUM(COALESCE(sil.acceptedQty, 0)) AS inQty, 0 AS outQty | |||
| FROM stock_in_line sil | |||
| INNER JOIN stock_in si ON sil.stockInId = si.id AND si.deleted = 0 | |||
| WHERE sil.deleted = 0 | |||
| FROM stock_in si | |||
| STRAIGHT_JOIN stock_in_line sil ON sil.stockInId = si.id AND sil.deleted = 0 | |||
| WHERE si.deleted = 0$inDateFilter | |||
| GROUP BY DATE(COALESCE(si.completeDate, sil.receiptDate, si.created)) | |||
| UNION ALL | |||
| SELECT DATE(COALESCE(so.completeDate, so.created)) AS dt, | |||
| 0 AS inQty, SUM(COALESCE(sol.qty, 0)) AS outQty | |||
| FROM stock_out_line sol | |||
| INNER JOIN stock_out so ON sol.stockOutId = so.id AND so.deleted = 0 | |||
| WHERE sol.deleted = 0 | |||
| FROM stock_out so | |||
| STRAIGHT_JOIN stock_out_line sol ON sol.stockOutId = so.id AND sol.deleted = 0 | |||
| WHERE so.deleted = 0$outDateFilter | |||
| GROUP BY DATE(COALESCE(so.completeDate, so.created)) | |||
| ) u | |||
| WHERE 1=1 $startSql $endSql | |||
| @@ -568,23 +584,25 @@ open class ChartService( | |||
| /** | |||
| * Distinct items that appear in delivery_order_line in the period (for multi-select options). | |||
| * Period filter: [delivery_order.estimatedArrivalDate] only; null ETA excluded. | |||
| * Uses STRAIGHT_JOIN so MySQL reads filtered `delivery_order` first (avoids full scan on `delivery_order_line`). | |||
| */ | |||
| fun getTopDeliveryItemsItemOptions(startDate: LocalDate?, endDate: LocalDate?): List<Map<String, Any>> { | |||
| val args = mutableMapOf<String, Any>() | |||
| val startSql = if (startDate != null) { | |||
| args["startDate"] = startDate.toString() | |||
| "AND DATE(COALESCE(do.completeDate, do.estimatedArrivalDate, do.orderDate)) >= :startDate" | |||
| "AND DATE(do.estimatedArrivalDate) >= :startDate" | |||
| } else "" | |||
| val endSql = if (endDate != null) { | |||
| args["endDate"] = endDate.toString() | |||
| "AND DATE(COALESCE(do.completeDate, do.estimatedArrivalDate, do.orderDate)) <= :endDate" | |||
| "AND DATE(do.estimatedArrivalDate) <= :endDate" | |||
| } else "" | |||
| val sql = """ | |||
| SELECT DISTINCT it.code AS itemCode, COALESCE(it.name, '') AS itemName | |||
| FROM delivery_order_line dol | |||
| INNER JOIN delivery_order do ON dol.deliveryOrderId = do.id AND do.deleted = 0 | |||
| INNER JOIN items it ON dol.itemId = it.id AND it.deleted = 0 | |||
| WHERE dol.deleted = 0 $startSql $endSql | |||
| FROM delivery_order do | |||
| STRAIGHT_JOIN delivery_order_line dol ON dol.deliveryOrderId = do.id AND dol.deleted = 0 | |||
| STRAIGHT_JOIN items it ON it.id = dol.itemId AND it.deleted = 0 | |||
| WHERE do.deleted = 0 AND do.estimatedArrivalDate IS NOT NULL $startSql $endSql | |||
| ORDER BY it.code | |||
| """.trimIndent() | |||
| return jdbcDao.queryForList(sql, args) | |||
| @@ -592,6 +610,8 @@ open class ChartService( | |||
| /** | |||
| * Top delivery items by total qty in the period. When itemCodes is non-empty, only those items (still ordered by totalQty, limit applied). | |||
| * Period filter: [delivery_order.estimatedArrivalDate] only; null ETA excluded. | |||
| * Uses STRAIGHT_JOIN so MySQL reads filtered `delivery_order` first (avoids full scan on `delivery_order_line`). | |||
| */ | |||
| fun getTopDeliveryItems( | |||
| startDate: LocalDate?, | |||
| @@ -602,11 +622,11 @@ open class ChartService( | |||
| val args = mutableMapOf<String, Any>("limit" to limit) | |||
| val startSql = if (startDate != null) { | |||
| args["startDate"] = startDate.toString() | |||
| "AND DATE(COALESCE(do.completeDate, do.estimatedArrivalDate, do.orderDate)) >= :startDate" | |||
| "AND DATE(do.estimatedArrivalDate) >= :startDate" | |||
| } else "" | |||
| val endSql = if (endDate != null) { | |||
| args["endDate"] = endDate.toString() | |||
| "AND DATE(COALESCE(do.completeDate, do.estimatedArrivalDate, do.orderDate)) <= :endDate" | |||
| "AND DATE(do.estimatedArrivalDate) <= :endDate" | |||
| } else "" | |||
| val itemSql = if (!itemCodes.isNullOrEmpty()) { | |||
| val codes = itemCodes.map { it.trim() }.filter { it.isNotBlank() } | |||
| @@ -620,10 +640,10 @@ open class ChartService( | |||
| it.code AS itemCode, | |||
| it.name AS itemName, | |||
| SUM(COALESCE(dol.qty, 0)) AS totalQty | |||
| FROM delivery_order_line dol | |||
| INNER JOIN delivery_order do ON dol.deliveryOrderId = do.id AND do.deleted = 0 | |||
| INNER JOIN items it ON dol.itemId = it.id AND it.deleted = 0 | |||
| WHERE dol.deleted = 0 $startSql $endSql $itemSql | |||
| FROM delivery_order do | |||
| STRAIGHT_JOIN delivery_order_line dol ON dol.deliveryOrderId = do.id AND dol.deleted = 0 | |||
| STRAIGHT_JOIN items it ON it.id = dol.itemId AND it.deleted = 0 | |||
| WHERE do.deleted = 0 AND do.estimatedArrivalDate IS NOT NULL $startSql $endSql $itemSql | |||
| GROUP BY dol.itemId, it.code, it.name | |||
| ORDER BY totalQty DESC | |||
| LIMIT :limit | |||
| @@ -721,23 +741,27 @@ open class ChartService( | |||
| /** | |||
| * Staff delivery performance: daily pick ticket count and total time per staff. | |||
| * Uses do_pick_order_record (handler = handledBy); time = sum of (ticketCompleteDateTime - ticketReleaseTime) per record. | |||
| * Optionally use do_pick_order_line_record for line count; here orderCount = number of completed pick tickets. | |||
| * Uses delivery_order_pick_order (handler = handledBy); time = sum of | |||
| * (ticketCompleteDateTime - ticketReleaseTime) per completed ticket. | |||
| * staffNos: when non-empty, filter to these staff by user.staffNo (multi-select). | |||
| * storeIdNull: when true, only rows with dop.storeId IS NULL (takes precedence over storeId). | |||
| * storeId: when non-blank and storeIdNull is not true, filter dop.storeId equality (trimmed). | |||
| */ | |||
| fun getStaffDeliveryPerformance( | |||
| startDate: LocalDate?, | |||
| endDate: LocalDate?, | |||
| staffNos: List<String>? | |||
| staffNos: List<String>?, | |||
| storeId: String?, | |||
| storeIdNull: Boolean?, | |||
| ): List<Map<String, Any>> { | |||
| val args = mutableMapOf<String, Any>() | |||
| val startSql = if (startDate != null) { | |||
| args["startDate"] = startDate.toString() | |||
| "AND DATE(dpor.ticketCompleteDateTime) >= :startDate" | |||
| "AND DATE(dop.ticketCompleteDateTime) >= :startDate" | |||
| } else "" | |||
| val endSql = if (endDate != null) { | |||
| args["endDate"] = endDate.toString() | |||
| "AND DATE(dpor.ticketCompleteDateTime) <= :endDate" | |||
| "AND DATE(dop.ticketCompleteDateTime) <= :endDate" | |||
| } else "" | |||
| val staffSql = if (!staffNos.isNullOrEmpty()) { | |||
| val nos = staffNos.map { it.trim() }.filter { it.isNotBlank() } | |||
| @@ -746,25 +770,33 @@ open class ChartService( | |||
| "AND u.staffNo IN (:staffNos)" | |||
| } | |||
| } else "" | |||
| val storeSql = when { | |||
| storeIdNull == true -> "AND dop.storeId IS NULL" | |||
| !storeId.isNullOrBlank() -> { | |||
| args["filterStoreId"] = storeId.trim() | |||
| "AND dop.storeId = :filterStoreId" | |||
| } | |||
| else -> "" | |||
| } | |||
| val sql = """ | |||
| SELECT | |||
| DATE_FORMAT(dpor.ticketCompleteDateTime, '%Y-%m-%d') AS date, | |||
| COALESCE(u.name, dpor.handler_name, 'Unknown') AS staffName, | |||
| COUNT(dpor.id) AS orderCount, | |||
| DATE_FORMAT(dop.ticketCompleteDateTime, '%Y-%m-%d') AS date, | |||
| COALESCE(NULLIF(TRIM(COALESCE(u.name, '')), ''), dop.handlerName, 'Unknown') AS staffName, | |||
| COUNT(dop.id) AS orderCount, | |||
| COALESCE(SUM( | |||
| CASE | |||
| WHEN dpor.ticket_release_time IS NOT NULL AND dpor.ticketCompleteDateTime IS NOT NULL | |||
| THEN GREATEST(0, TIMESTAMPDIFF(MINUTE, dpor.ticket_release_time, dpor.ticketCompleteDateTime)) | |||
| WHEN dop.ticketReleaseTime IS NOT NULL AND dop.ticketCompleteDateTime IS NOT NULL | |||
| THEN GREATEST(0, TIMESTAMPDIFF(MINUTE, dop.ticketReleaseTime, dop.ticketCompleteDateTime)) | |||
| ELSE 0 | |||
| END | |||
| ), 0) AS totalMinutes | |||
| FROM do_pick_order_record dpor | |||
| LEFT JOIN user u ON dpor.handled_by = u.id AND u.deleted = 0 | |||
| WHERE dpor.deleted = 0 | |||
| AND dpor.ticket_status = 'completed' | |||
| AND dpor.ticketCompleteDateTime IS NOT NULL | |||
| $startSql $endSql $staffSql | |||
| GROUP BY DATE(dpor.ticketCompleteDateTime), dpor.handled_by, u.name, dpor.handler_name | |||
| FROM delivery_order_pick_order dop | |||
| LEFT JOIN user u ON dop.handledBy = u.id AND u.deleted = 0 | |||
| WHERE dop.deleted = 0 | |||
| AND LOWER(COALESCE(dop.ticketStatus, '')) = 'completed' | |||
| AND dop.ticketCompleteDateTime IS NOT NULL | |||
| $startSql $endSql $staffSql $storeSql | |||
| GROUP BY DATE(dop.ticketCompleteDateTime), dop.handledBy, u.name, dop.handlerName | |||
| ORDER BY date, orderCount DESC | |||
| """.trimIndent() | |||
| return jdbcDao.queryForList(sql, args) | |||
| @@ -26,7 +26,7 @@ class ChartController( | |||
| /** | |||
| * GET /chart/delivery-order-by-date?startDate=&endDate= | |||
| * Returns [{ date, orderCount, totalQty }] | |||
| * Returns [{ date, orderCount, totalQty }]. Date axis: delivery_order.estimatedArrivalDate only (null ETA excluded). | |||
| */ | |||
| @GetMapping("/delivery-order-by-date") | |||
| fun getDeliveryOrderByDate( | |||
| @@ -129,7 +129,7 @@ class ChartController( | |||
| /** | |||
| * GET /chart/stock-in-out-by-date?startDate=&endDate= | |||
| * Returns [{ date, inQty, outQty }] | |||
| * Returns [{ date, inQty, outQty }]. Date range pushed into each UNION branch; si/so read before lines. | |||
| */ | |||
| @GetMapping("/stock-in-out-by-date") | |||
| fun getStockInOutByDate( | |||
| @@ -140,6 +140,7 @@ class ChartController( | |||
| /** | |||
| * GET /chart/top-delivery-items-item-options?startDate=&endDate= | |||
| * Returns [{ itemCode, itemName }] — distinct items in delivery lines in the period (for multi-select). | |||
| * Period: delivery_order.estimatedArrivalDate only (null ETA excluded). | |||
| */ | |||
| @GetMapping("/top-delivery-items-item-options") | |||
| fun getTopDeliveryItemsItemOptions( | |||
| @@ -150,6 +151,7 @@ class ChartController( | |||
| /** | |||
| * GET /chart/top-delivery-items?startDate=&endDate=&limit=20&itemCode=A&itemCode=B | |||
| * Returns [{ itemCode, itemName, totalQty }]. When itemCode present, only those items (still by totalQty, limit). | |||
| * Period: delivery_order.estimatedArrivalDate only (null ETA excluded). | |||
| */ | |||
| @GetMapping("/top-delivery-items") | |||
| fun getTopDeliveryItems( | |||
| @@ -192,16 +194,20 @@ class ChartController( | |||
| chartService.getStaffDeliveryPerformanceHandlers() | |||
| /** | |||
| * GET /chart/staff-delivery-performance?startDate=&endDate=&staffNo=A001&staffNo=A002 | |||
| * Returns [{ date, staffName, orderCount, totalMinutes }]. Data from do_pick_order_record (handled_by), orderCount = completed pick tickets, totalMinutes = sum(ticketCompleteDateTime - ticketReleaseTime). | |||
| * GET /chart/staff-delivery-performance?startDate=&endDate=&staffNo=A001&staffNo=A002&storeId=2/F&storeIdNull=true | |||
| * Returns [{ date, staffName, orderCount, totalMinutes }]. Data from delivery_order_pick_order | |||
| * (handledBy), orderCount = completed pick tickets, totalMinutes = sum(ticketCompleteDateTime - ticketReleaseTime). | |||
| * Optional storeId filters delivery_order_pick_order.storeId; storeIdNull=true means IS NULL (overrides storeId). | |||
| */ | |||
| @GetMapping("/staff-delivery-performance") | |||
| fun getStaffDeliveryPerformance( | |||
| @RequestParam(required = false) @DateTimeFormat(iso = DateTimeFormat.ISO.DATE) startDate: LocalDate?, | |||
| @RequestParam(required = false) @DateTimeFormat(iso = DateTimeFormat.ISO.DATE) endDate: LocalDate?, | |||
| @RequestParam(required = false) staffNo: List<String>?, | |||
| @RequestParam(required = false) storeId: String?, | |||
| @RequestParam(required = false) storeIdNull: Boolean?, | |||
| ): List<Map<String, Any>> = | |||
| chartService.getStaffDeliveryPerformance(startDate, endDate, staffNo) | |||
| chartService.getStaffDeliveryPerformance(startDate, endDate, staffNo, storeId, storeIdNull) | |||
| // ---------- Job order reports ---------- | |||
| @@ -41,6 +41,11 @@ public abstract class SettingNames { | |||
| */ | |||
| public static final String M18_UNITS_SYNC_INITIAL_FULL_SYNC_DONE = "M18.units.sync.initialFullSyncDone"; | |||
| /** | |||
| * When "true", FPSMS may push BOM header + materials to M18 udfBomForShop. | |||
| */ | |||
| public static final String M18_BOM_SHOP_SYNC_ENABLED = "M18.bom.shop.sync.enabled"; | |||
| /** Post completed DN and process M18 GRN (cron, e.g. "0 40 23 * * *" for 23:40 daily) */ | |||
| public static final String SCHEDULE_POST_COMPLETED_DN_GRN = "SCHEDULE.postCompletedDn.grn"; | |||
| @@ -52,6 +57,11 @@ public abstract class SettingNames { | |||
| public static final String SCHEDULE_PROD_ROUGH = "SCHEDULE.prod.rough"; | |||
| public static final String SCHEDULE_PROD_DETAILED = "SCHEDULE.prod.detailed"; | |||
| /** | |||
| * Job order plan-start overdue batch (default 00:00:15 daily): hide or reschedule JOs whose plan day was yesterday. | |||
| */ | |||
| public static final String SCHEDULE_JO_PLAN_START = "SCHEDULE.jo.planStart"; | |||
| /* | |||
| * Mail settings | |||
| */ | |||
| @@ -10,6 +10,7 @@ import com.ffii.fpsms.m18.entity.SchedulerSyncLog | |||
| import com.ffii.fpsms.m18.entity.SchedulerSyncLogRepository | |||
| import com.ffii.fpsms.m18.model.SyncResult | |||
| import com.ffii.fpsms.modules.common.SettingNames | |||
| import com.ffii.fpsms.modules.jobOrder.service.JobOrderPlanStartAutoService | |||
| import com.ffii.fpsms.modules.master.service.ProductionScheduleService | |||
| import com.ffii.fpsms.modules.stock.service.SearchCompletedDnService | |||
| import com.ffii.fpsms.modules.stock.service.InventoryLotLineService | |||
| @@ -42,6 +43,7 @@ open class SchedulerService( | |||
| @Value("\${scheduler.inventoryLotExpiry.enabled:true}") val inventoryLotExpiryEnabled: Boolean, | |||
| /** When false (default), M18 PO / DO1 / DO2 / master-data cron jobs are not registered — use true in production only. */ | |||
| @Value("\${scheduler.m18Sync.enabled:false}") val m18SyncEnabled: Boolean, | |||
| @Value("\${scheduler.jo.planStart.enabled:true}") val jobOrderPlanStartAutoEnabled: Boolean, | |||
| val settingsService: SettingsService, | |||
| /** | |||
| * Lookback window for GRN code sync: rows with `created` from **start of (today − N days)** through **now**, | |||
| @@ -56,7 +58,16 @@ open class SchedulerService( | |||
| val searchCompletedDnService: SearchCompletedDnService, | |||
| val m18GrnCodeSyncService: M18GrnCodeSyncService, | |||
| val inventoryLotLineService: InventoryLotLineService, | |||
| val jobOrderPlanStartAutoService: JobOrderPlanStartAutoService, | |||
| ) { | |||
| companion object { | |||
| /** DO2: Spring 6-field cron default and M18 `lastModifyDate` upper bound hour (1pm local). */ | |||
| const val DO2_MODIFIED_TO_HOUR: Int = 13 | |||
| const val DO2_DEFAULT_CRON: String = "0 0 13 * * *" | |||
| /** Daily 00:00:15 — process job orders whose planStart was yesterday. */ | |||
| const val JO_PLAN_START_DEFAULT_CRON: String = "15 0 0 * * *" | |||
| } | |||
| var logger: Logger = LoggerFactory.getLogger(JwtTokenUtil::class.java) | |||
| val dataStringFormat = DateTimeFormatter.ofPattern("yyyy-MM-dd") | |||
| val dateTimeStringFormat = DateTimeFormatter.ofPattern("yyyy-MM-dd HH:mm:ss") | |||
| @@ -80,6 +91,8 @@ open class SchedulerService( | |||
| var scheduledGrnCodeSync: ScheduledFuture<*>? = null | |||
| var scheduledInventoryLotExpiry: ScheduledFuture<*>? = null | |||
| var scheduledJobOrderPlanStart: ScheduledFuture<*>? = null | |||
| //@Volatile | |||
| //var scheduledRoughProd: ScheduledFuture<*>? = null | |||
| @@ -169,6 +182,7 @@ open class SchedulerService( | |||
| schedulePostCompletedDnGrn(); | |||
| scheduleGrnCodeSync(); | |||
| scheduleInventoryLotExpiry(); | |||
| scheduleJobOrderPlanStartAuto(); | |||
| //scheduleRoughProd(); | |||
| //scheduleDetailedProd(); | |||
| } | |||
| @@ -206,7 +220,7 @@ open class SchedulerService( | |||
| logger.info("M18 DO2 scheduler disabled (scheduler.m18Sync.enabled=false)") | |||
| return | |||
| } | |||
| scheduledM18Do2 = commonSchedule(scheduledM18Do2, SettingNames.SCHEDULE_M18_DO2, ::getM18Dos2) | |||
| scheduledM18Do2 = commonSchedule(scheduledM18Do2, SettingNames.SCHEDULE_M18_DO2, DO2_DEFAULT_CRON, ::getM18Dos2) | |||
| } | |||
| fun scheduleM18MasterData() { | |||
| @@ -286,6 +300,42 @@ open class SchedulerService( | |||
| ) | |||
| } | |||
| /** | |||
| * Job order plan-start batch at 00:00:15 daily (yesterday plan day). | |||
| * Set scheduler.jo.planStart.enabled=false to disable. | |||
| */ | |||
| fun scheduleJobOrderPlanStartAuto() { | |||
| if (!jobOrderPlanStartAutoEnabled) { | |||
| scheduledJobOrderPlanStart?.cancel(false) | |||
| scheduledJobOrderPlanStart = null | |||
| logger.info("Job order plan-start auto scheduler disabled (scheduler.jo.planStart.enabled=false)") | |||
| return | |||
| } | |||
| scheduledJobOrderPlanStart = commonSchedule( | |||
| scheduledJobOrderPlanStart, | |||
| SettingNames.SCHEDULE_JO_PLAN_START, | |||
| JO_PLAN_START_DEFAULT_CRON, | |||
| ::runJobOrderPlanStartAuto, | |||
| ) | |||
| logger.info("Scheduled job order plan-start auto (default cron={})", JO_PLAN_START_DEFAULT_CRON) | |||
| } | |||
| open fun runJobOrderPlanStartAuto() { | |||
| try { | |||
| val report = jobOrderPlanStartAutoService.runAutoProcess(LocalDateTime.now()) | |||
| logger.info( | |||
| "Scheduler - Job order plan-start auto: candidates={}, hidden={}, rescheduled={}, skipped={}, errors={}", | |||
| report.candidates, | |||
| report.hidden, | |||
| report.rescheduled, | |||
| report.skipped, | |||
| report.errors, | |||
| ) | |||
| } catch (e: Exception) { | |||
| logger.error("Scheduler - Job order plan-start auto failed: ${e.message}", e) | |||
| } | |||
| } | |||
| /** Mark expired inventory lot lines as unavailable daily. Set scheduler.inventoryLotExpiry.enabled=false to disable. */ | |||
| fun scheduleInventoryLotExpiry() { | |||
| if (!inventoryLotExpiryEnabled) { | |||
| @@ -455,7 +505,7 @@ open class SchedulerService( | |||
| val ysd = today.minusDays(1L) | |||
| val tmr = today.plusDays(1L) | |||
| // Default: lastModified from yesterday 19:00 (aligns with nightly DO2 expectation). | |||
| // Default: lastModified from yesterday 19:00 through today's DO2 run hour (1pm; aligns with SCHEDULE.m18.do2). | |||
| // On Sunday, yesterday is Saturday: use 03:00 instead so we include DO changed after Sat 03:10 DO1 | |||
| // (otherwise Sat 03:00–18:59 would be skipped until a much later sync). | |||
| val isSundayDo2 = runDate.dayOfWeek == DayOfWeek.SUNDAY | |||
| @@ -465,21 +515,21 @@ open class SchedulerService( | |||
| ysd.withHour(19).withMinute(0).withSecond(0) | |||
| } | |||
| // Set to 11:00:00 of today | |||
| val todayEleven = today.withHour(11).withMinute(0).withSecond(0) | |||
| val modifiedDateToEnd = | |||
| today.withHour(DO2_MODIFIED_TO_HOUR).withMinute(0).withSecond(0) | |||
| logger.info( | |||
| "DO2 modifiedDateFrom={} ({}), modifiedDateTo={}", | |||
| modifiedFromStart.format(dateTimeStringFormat), | |||
| if (isSundayDo2) "Sunday window from Sat 03:00" else "from yesterday 19:00", | |||
| todayEleven.format(dateTimeStringFormat), | |||
| modifiedDateToEnd.format(dateTimeStringFormat), | |||
| ) | |||
| val requestDO = M18CommonRequest( | |||
| // These will now produce "yyyy-MM-dd HH:mm:ss" | |||
| dDateTo = tmr.format(dateTimeStringFormat), // e.g. 2026-01-19 00:00:00 | |||
| dDateFrom = tmr.format(dateTimeStringFormat), // e.g. 2026-01-19 00:00:00 | |||
| modifiedDateTo = todayEleven.format(dateTimeStringFormat), // 2026-01-18 11:00:00 | |||
| modifiedDateTo = modifiedDateToEnd.format(dateTimeStringFormat), | |||
| modifiedDateFrom = modifiedFromStart.format(dateTimeStringFormat), | |||
| ) | |||
| @@ -491,30 +541,6 @@ open class SchedulerService( | |||
| result = result, | |||
| start = currentTime | |||
| ) | |||
| // Extra DO sync window: after DO2, also sync ETA = today or tomorrow (normal sync; does NOT set isEtra). | |||
| try { | |||
| val extraStart = LocalDateTime.now() | |||
| val requestExtra = M18CommonRequest( | |||
| dDateFrom = today.format(dateTimeStringFormat), | |||
| dDateTo = tmr.format(dateTimeStringFormat), | |||
| ) | |||
| val extraResult = m18DeliveryOrderService.saveDeliveryOrders(requestExtra) | |||
| saveSyncLog( | |||
| type = "DO2_EXTRA", | |||
| status = "SUCCESS", | |||
| result = extraResult, | |||
| start = extraStart, | |||
| ) | |||
| } catch (e: Exception) { | |||
| logger.error("DO2_EXTRA sync failed: ${e.message}", e) | |||
| saveSyncLog( | |||
| type = "DO2_EXTRA", | |||
| status = "FAIL", | |||
| error = e.message, | |||
| start = LocalDateTime.now(), | |||
| ) | |||
| } | |||
| } | |||
| open fun getPostCompletedDnAndProcessGrn( | |||
| @@ -88,4 +88,9 @@ class SchedulerController( | |||
| schedulerService.init() | |||
| return "Cron Schedules Refreshed from Database" | |||
| } | |||
| @GetMapping("/trigger/jo-plan-start") | |||
| fun triggerJoPlanStart(): String { | |||
| schedulerService.runJobOrderPlanStartAuto() | |||
| return "Job order plan-start auto triggered" | |||
| } | |||
| } | |||
| @@ -64,6 +64,6 @@ open class DeliveryOrder: BaseEntity<Long>() { | |||
| open var m18BeId: Long? = null | |||
| /** 加單:由 M18「加單」專用同步標記;一般 DO 為 false */ | |||
| @Column(name = "isEtra", nullable = false) | |||
| open var isEtra: Boolean = false | |||
| @Column(name = "isExtra", nullable = false) | |||
| open var isExtra: Boolean = false | |||
| } | |||
| @@ -111,7 +111,7 @@ fun searchDoLite( | |||
| and (:status is null or d.status = :status) | |||
| and (:etaStart is null or d.estimatedArrivalDate >= :etaStart) | |||
| and (:etaEnd is null or d.estimatedArrivalDate < :etaEnd) | |||
| and (:isEtra is null or d.isEtra = :isEtra) | |||
| and (:isExtra is null or d.isExtra = :isExtra) | |||
| order by d.id desc | |||
| """) | |||
| fun searchDoLitePage( | |||
| @@ -120,7 +120,7 @@ fun searchDoLitePage( | |||
| @Param("status") status: DeliveryOrderStatus?, | |||
| @Param("etaStart") etaStart: LocalDateTime?, | |||
| @Param("etaEnd") etaEnd: LocalDateTime?, | |||
| @Param("isEtra") isEtra: Boolean?, | |||
| @Param("isExtra") isExtra: Boolean?, | |||
| pageable: Pageable | |||
| ): Page<DeliveryOrderInfoLite> | |||
| @@ -136,7 +136,7 @@ fun searchDoLitePage( | |||
| and (:status is null or d.status = :status) | |||
| and (:etaStart is null or d.estimatedArrivalDate >= :etaStart) | |||
| and (:etaEnd is null or d.estimatedArrivalDate < :etaEnd) | |||
| and (:isEtra is null or d.isEtra = :isEtra) | |||
| and (:isExtra is null or d.isExtra = :isExtra) | |||
| and d.supplier is not null | |||
| and d.supplier.code in :allowedSupplierCodes | |||
| order by d.id desc | |||
| @@ -148,7 +148,7 @@ fun searchDoLitePageWithSupplierCodes( | |||
| @Param("status") status: DeliveryOrderStatus?, | |||
| @Param("etaStart") etaStart: LocalDateTime?, | |||
| @Param("etaEnd") etaEnd: LocalDateTime?, | |||
| @Param("isEtra") isEtra: Boolean?, | |||
| @Param("isExtra") isExtra: Boolean?, | |||
| @Param("allowedSupplierCodes") allowedSupplierCodes: List<String>, | |||
| pageable: Pageable, | |||
| ): Page<DeliveryOrderInfoLite> | |||
| @@ -48,8 +48,8 @@ interface DeliveryOrderInfoLite { | |||
| @get:Value("#{target.shop?.addr3}") | |||
| val shopAddress: String? | |||
| @get:Value("#{target.isEtra}") | |||
| val isEtra: Boolean | |||
| @get:Value("#{target.isExtra}") | |||
| val isExtra: Boolean | |||
| } | |||
| data class DeliveryOrderInfoLiteDto( | |||
| val id: Long, | |||
| @@ -61,5 +61,5 @@ data class DeliveryOrderInfoLiteDto( | |||
| val supplierName: String?, | |||
| val shopAddress: String?, | |||
| val truckLanceCode: String?, | |||
| val isEtra: Boolean = false, | |||
| val isExtra: Boolean = false, | |||
| ) | |||
| @@ -90,7 +90,6 @@ import com.ffii.fpsms.modules.stock.entity.InventoryLotLine | |||
| import com.ffii.fpsms.modules.stock.entity.projection.StockOutLineInfo | |||
| import java.util.Locale | |||
| import org.slf4j.Logger | |||
| @Service | |||
| open class DeliveryOrderService( | |||
| private val deliveryOrderRepository: DeliveryOrderRepository, | |||
| @@ -121,23 +120,23 @@ open class DeliveryOrderService( | |||
| private val doPickOrderLineRepository: DoPickOrderLineRepository, | |||
| private val doPickOrderLineRecordRepository: DoPickOrderLineRecordRepository, | |||
| private val itemsRepository: ItemsRepository, | |||
| private val doFloorSupplierSettingsService: DoFloorSupplierSettingsService, | |||
| ) { | |||
| /** | |||
| * 樓層篩選:2F → P07/P06D(車線- 族);4F → P06B(P06B_ 族);全部 → 三者。 | |||
| * 車線-X 仍屬該 DO 的 supplier,故 P06B+車線-X 不會出現在 2F,P06D+車線-X 不會出現在 4F。 | |||
| * 樓層篩選:2F/4F/ALL 由 [DoFloorSupplierSettingsService] 讀 `settings`。 | |||
| * 車線-X 仍依 DO supplier 所屬樓層出現在對應 tab。 | |||
| */ | |||
| private fun allowedSupplierCodesForFloor(floor: String?): List<String> { | |||
| val f = floor?.trim()?.uppercase(Locale.ROOT).orEmpty() | |||
| if (f.isEmpty() || f == "ALL" || f == "All") { | |||
| return listOf("P06B", "P07", "P06D") | |||
| } | |||
| return when (f) { | |||
| "2F" -> listOf("P07", "P06D") | |||
| "4F" -> listOf("P06B") | |||
| else -> listOf("P06B", "P07", "P06D") | |||
| } | |||
| } | |||
| private fun allowedSupplierCodesForFloor(floor: String?): List<String> = | |||
| doFloorSupplierSettingsService.allowedSupplierCodesForFloor(floor) | |||
| private fun loadDoFloorSupplierLists(): Pair<List<String>, List<String>> = | |||
| doFloorSupplierSettingsService.loadDoFloorSupplierLists() | |||
| private fun preferredStoreFloorForSupplier( | |||
| supplierCode: String?, | |||
| suppliers2F: List<String>, | |||
| suppliers4F: List<String>, | |||
| ): String = doFloorSupplierSettingsService.preferredStoreFloorForSupplier(supplierCode, suppliers2F, suppliers4F) | |||
| open fun searchDoLiteByPage( | |||
| code: String?, | |||
| shopName: String?, | |||
| @@ -147,7 +146,7 @@ open class DeliveryOrderService( | |||
| pageSize: Int?, | |||
| truckLanceCode: String?, | |||
| floor: String? = null, | |||
| isEtra: Boolean? = null, | |||
| isExtra: Boolean? = null, | |||
| ): RecordsRes<DeliveryOrderInfoLiteDto> { | |||
| val page = (pageNum ?: 1) - 1 | |||
| @@ -169,7 +168,7 @@ open class DeliveryOrderService( | |||
| status = statusEnum, | |||
| etaStart = etaStart, | |||
| etaEnd = etaEnd, | |||
| isEtra = isEtra, | |||
| isExtra = isExtra, | |||
| allowedSupplierCodes = allowedForFloor, | |||
| pageable = PageRequest.of(0, 100_000), | |||
| ) | |||
| @@ -181,6 +180,7 @@ open class DeliveryOrderService( | |||
| .associateBy { it.id } | |||
| val preFilteredContent = allResult.content | |||
| val (floorSuppliers2F, floorSuppliers4F) = loadDoFloorSupplierLists() | |||
| // ✅ 优化3: 收集所有需要查询的 shopId 和日期组合(只处理预过滤后的记录) | |||
| val shopIdAndDatePairs = preFilteredContent.mapNotNull { info -> | |||
| @@ -191,11 +191,7 @@ open class DeliveryOrderService( | |||
| val targetDate = estimatedArrivalDate.toLocalDate() | |||
| val dayAbbr = getDayOfWeekAbbr(targetDate) | |||
| val supplierCode = deliveryOrder.supplier?.code | |||
| val preferredFloor = when (supplierCode) { | |||
| "P06B" -> "4F" | |||
| "P07", "P06D" -> "2F" | |||
| else -> "2F" // 或者改成 null / 其他默认值,看你业务需要 | |||
| } | |||
| val preferredFloor = preferredStoreFloorForSupplier(supplierCode, floorSuppliers2F, floorSuppliers4F) | |||
| Triple(shopId, preferredFloor, dayAbbr) | |||
| } else { | |||
| null | |||
| @@ -217,11 +213,7 @@ open class DeliveryOrderService( | |||
| val processedRecords = preFilteredContent.map { info -> | |||
| val deliveryOrder = deliveryOrdersMap[info.id] | |||
| val supplierCode = deliveryOrder?.supplier?.code | |||
| val preferredFloor = when (supplierCode) { | |||
| "P06B" -> "4F" | |||
| "P07", "P06D" -> "2F" | |||
| else -> "2F" | |||
| } | |||
| val preferredFloor = preferredStoreFloorForSupplier(supplierCode, floorSuppliers2F, floorSuppliers4F) | |||
| val shop = deliveryOrder?.shop | |||
| val shopId = shop?.id | |||
| val estimatedArrivalDate = info.estimatedArrivalDate | |||
| @@ -248,7 +240,7 @@ open class DeliveryOrderService( | |||
| supplierName = info.supplierName, | |||
| shopAddress = info.shopAddress, | |||
| truckLanceCode = calculatedTruckLanceCode, | |||
| isEtra = deliveryOrdersMap[info.id]?.isEtra ?: info.isEtra, | |||
| isExtra = deliveryOrdersMap[info.id]?.isExtra ?: info.isExtra, | |||
| ) | |||
| }.filter { dto -> | |||
| val dtoTruckLanceCode = dto.truckLanceCode?.lowercase() ?: "" | |||
| @@ -279,19 +271,16 @@ open class DeliveryOrderService( | |||
| status = statusEnum, | |||
| etaStart = etaStart, | |||
| etaEnd = etaEnd, | |||
| isEtra = isEtra, | |||
| isExtra = isExtra, | |||
| allowedSupplierCodes = allowedSupplierCodes, | |||
| pageable = PageRequest.of(page.coerceAtLeast(0), size), | |||
| ) | |||
| val (floorSuppliers2F, floorSuppliers4F) = loadDoFloorSupplierLists() | |||
| val records = result.content.map { info -> | |||
| val deliveryOrder = deliveryOrderRepository.findByIdAndDeletedIsFalse(info.id) | |||
| val supplierCode = deliveryOrder?.supplier?.code | |||
| val preferredFloor = when (supplierCode) { | |||
| "P06B" -> "4F" | |||
| "P07", "P06D" -> "2F" | |||
| else -> "2F" | |||
| } | |||
| val preferredFloor = preferredStoreFloorForSupplier(supplierCode, floorSuppliers2F, floorSuppliers4F) | |||
| val shop = deliveryOrder?.shop | |||
| val shopId = shop?.id | |||
| val estimatedArrivalDate = info.estimatedArrivalDate | |||
| @@ -315,7 +304,7 @@ open class DeliveryOrderService( | |||
| supplierName = info.supplierName, | |||
| shopAddress = info.shopAddress, | |||
| truckLanceCode = calculatedTruckLanceCode, | |||
| isEtra = deliveryOrder?.isEtra ?: info.isEtra, | |||
| isExtra = deliveryOrder?.isExtra ?: info.isExtra, | |||
| ) | |||
| } | |||
| @@ -338,7 +327,7 @@ open class DeliveryOrderService( | |||
| pageSize: Int?, | |||
| truckLanceCode: String?, | |||
| floor: String? = null, | |||
| isEtra: Boolean? = null, | |||
| isExtra: Boolean? = null, | |||
| ): RecordsRes<DeliveryOrderInfoLiteDto> { | |||
| val mode = TruckLaneSearchSpec.parse(truckLanceCode) | |||
| if (mode is TruckLaneSearchSpec.Mode.NoFilter) { | |||
| @@ -351,7 +340,7 @@ open class DeliveryOrderService( | |||
| pageSize, | |||
| null, | |||
| floor, | |||
| isEtra, | |||
| isExtra, | |||
| ) | |||
| } | |||
| val pageIdx = (pageNum ?: 1).coerceAtLeast(1) - 1 | |||
| @@ -367,7 +356,7 @@ open class DeliveryOrderService( | |||
| statusEnum = statusEnum, | |||
| etaStart = etaStart, | |||
| etaEnd = etaEnd, | |||
| isEtra = isEtra, | |||
| isExtra = isExtra, | |||
| allowedSupplierCodes = allowedSupplierCodesForFloor(floor), | |||
| lanePredicate = lanePredicate, | |||
| ) | |||
| @@ -391,7 +380,7 @@ open class DeliveryOrderService( | |||
| pageNum: Int?, | |||
| pageSize: Int?, | |||
| floor: String? = null, | |||
| isEtra: Boolean? = null, | |||
| isExtra: Boolean? = null, | |||
| ): RecordsRes<DeliveryOrderInfoLiteDto> { | |||
| val page = (pageNum ?: 1) - 1 | |||
| val size = pageSize ?: 10 | |||
| @@ -406,22 +395,19 @@ open class DeliveryOrderService( | |||
| status = statusEnum, | |||
| etaStart = etaStart, | |||
| etaEnd = etaEnd, | |||
| isEtra = isEtra, | |||
| isExtra = isExtra, | |||
| allowedSupplierCodes = allowedSupplierCodes, | |||
| pageable = PageRequest.of(0, 100_000), | |||
| ) | |||
| val deliveryOrderIds = allResult.content.mapNotNull { it.id } | |||
| val deliveryOrdersMap = deliveryOrderRepository.findAllById(deliveryOrderIds).associateBy { it.id } | |||
| val (floorSuppliers2F, floorSuppliers4F) = loadDoFloorSupplierLists() | |||
| val processedRecords = allResult.content.map { info -> | |||
| val deliveryOrder = deliveryOrdersMap[info.id] | |||
| val supplierCode = deliveryOrder?.supplier?.code | |||
| val preferredFloor = when (supplierCode) { | |||
| "P06B" -> "4F" | |||
| "P07", "P06D" -> "2F" | |||
| else -> "2F" | |||
| } | |||
| val preferredFloor = preferredStoreFloorForSupplier(supplierCode, floorSuppliers2F, floorSuppliers4F) | |||
| val shop = deliveryOrder?.shop | |||
| val shopId = shop?.id | |||
| val infoEta = info.estimatedArrivalDate | |||
| @@ -445,7 +431,7 @@ open class DeliveryOrderService( | |||
| supplierName = info.supplierName, | |||
| shopAddress = info.shopAddress, | |||
| truckLanceCode = calculatedTruckLanceCode, | |||
| isEtra = deliveryOrdersMap[info.id]?.isEtra ?: info.isEtra, | |||
| isExtra = deliveryOrdersMap[info.id]?.isExtra ?: info.isExtra, | |||
| ) | |||
| }.filter { dto -> TruckLaneSearchSpec.isUnassignedResolvedLane(dto.truckLanceCode) } | |||
| @@ -487,7 +473,7 @@ open class DeliveryOrderService( | |||
| estimatedArrivalDate = deliveryOrder.estimatedArrivalDate, | |||
| completeDate = deliveryOrder.completeDate, | |||
| status = deliveryOrder.status?.value, | |||
| isEtra = deliveryOrder.isEtra, | |||
| isExtra = deliveryOrder.isExtra, | |||
| deliveryOrderLines = deliveryOrder.deliveryOrderLines.map { line -> | |||
| DoDetailLineResponse( | |||
| id = line.id!!, | |||
| @@ -808,7 +794,7 @@ open class DeliveryOrderService( | |||
| this.handler = handler | |||
| m18BeId = request.m18BeId | |||
| this.deleted = request.deleted | |||
| isEtra = request.isEtra ?: false | |||
| isExtra = request.isExtra ?: false | |||
| } | |||
| val savedDeliveryOrder = deliveryOrderRepository.saveAndFlush(deliveryOrder).let { | |||
| @@ -948,14 +934,10 @@ open class DeliveryOrderService( | |||
| println(" DEBUG: Target date: $targetDate, Date prefix: $datePrefix") | |||
| // 新逻辑:根据 supplier code 决定楼层 | |||
| // 如果 supplier code 是 "P06B",使用 4F,否则使用 2F | |||
| // 新逻辑:根据 supplier code 决定楼层(清單來自 settings) | |||
| val supplierCode = deliveryOrder.supplier?.code | |||
| val preferredFloor = when (supplierCode) { | |||
| "P06B" -> "4F" | |||
| "P07", "P06D" -> "2F" | |||
| else -> "2F" // 或者改成 null / 其他默认值,看你业务需要 | |||
| } | |||
| val (floorSuppliers2F, floorSuppliers4F) = loadDoFloorSupplierLists() | |||
| val preferredFloor = preferredStoreFloorForSupplier(supplierCode, floorSuppliers2F, floorSuppliers4F) | |||
| println(" DEBUG: Supplier code: $supplierCode, Preferred floor: $preferredFloor") | |||
| @@ -1839,15 +1821,11 @@ val inventoryLotLine = illId?.let { inventoryLotLineMap[it] } | |||
| } | |||
| } | |||
| // 新逻辑:根据 supplier code 决定楼层 | |||
| // 如果 supplier code 是 "P06B",使用 4F,否则使用 2F | |||
| // 新逻辑:根据 supplier code 决定楼层(清單來自 settings) | |||
| val targetDate = deliveryOrder.estimatedArrivalDate?.toLocalDate() ?: LocalDate.now() | |||
| val supplierCode = deliveryOrder.supplier?.code | |||
| val preferredFloor = when (supplierCode) { | |||
| "P06B" -> "4F" | |||
| "P07", "P06D" -> "2F" | |||
| else -> "2F" // 或者改成 null / 其他默认值,看你业务需要 | |||
| } | |||
| val (floorSuppliers2F, floorSuppliers4F) = loadDoFloorSupplierLists() | |||
| val preferredFloor = preferredStoreFloorForSupplier(supplierCode, floorSuppliers2F, floorSuppliers4F) | |||
| println(" DEBUG: Floor calculation for DO ${deliveryOrder.id}") | |||
| println(" - Supplier code: $supplierCode") | |||
| @@ -1936,7 +1914,8 @@ val inventoryLotLine = illId?.let { inventoryLotLineMap[it] } | |||
| truckDepartureTime = effectiveTruck.departureTime, | |||
| truckLanceCode = effectiveTruck.truckLanceCode, | |||
| loadingSequence = effectiveTruck.loadingSequence, | |||
| usedDefaultTruck = usedDefaultTruck | |||
| usedDefaultTruck = usedDefaultTruck, | |||
| isExtra = deliveryOrder.isExtra ?: false, | |||
| ) | |||
| } | |||
| @@ -2022,11 +2001,8 @@ val inventoryLotLine = illId?.let { inventoryLotLineMap[it] } | |||
| // Truck selection (reuse normal logic) | |||
| val targetDate = deliveryOrder.estimatedArrivalDate?.toLocalDate() ?: LocalDate.now() | |||
| val supplierCode = deliveryOrder.supplier?.code | |||
| val preferredFloor = when (supplierCode) { | |||
| "P06B" -> "4F" | |||
| "P07", "P06D" -> "2F" | |||
| else -> "2F" | |||
| } | |||
| val (floorSuppliers2F, floorSuppliers4F) = loadDoFloorSupplierLists() | |||
| val preferredFloor = preferredStoreFloorForSupplier(supplierCode, floorSuppliers2F, floorSuppliers4F) | |||
| val truck = deliveryOrder.shop?.id?.let { shopId -> | |||
| val trucks = truckRepository.findByShopIdAndDeletedFalse(shopId) | |||
| @@ -2094,7 +2070,8 @@ val inventoryLotLine = illId?.let { inventoryLotLineMap[it] } | |||
| truckDepartureTime = effectiveTruck.departureTime, | |||
| truckLanceCode = effectiveTruck.truckLanceCode, | |||
| loadingSequence = effectiveTruck.loadingSequence, | |||
| usedDefaultTruck = usedDefaultTruck | |||
| usedDefaultTruck = usedDefaultTruck, | |||
| isExtra = deliveryOrder.isExtra ?: false, | |||
| ) | |||
| } | |||
| @@ -2104,7 +2081,7 @@ val inventoryLotLine = illId?.let { inventoryLotLineMap[it] } | |||
| statusEnum: DeliveryOrderStatus?, | |||
| etaStart: LocalDateTime?, | |||
| etaEnd: LocalDateTime?, | |||
| isEtra: Boolean?, | |||
| isExtra: Boolean?, | |||
| allowedSupplierCodes: List<String>, | |||
| lanePredicate: (String?) -> Boolean, | |||
| ): List<DeliveryOrderInfoLiteDto> { | |||
| @@ -2118,7 +2095,7 @@ val inventoryLotLine = illId?.let { inventoryLotLineMap[it] } | |||
| status = statusEnum, | |||
| etaStart = etaStart, | |||
| etaEnd = etaEnd, | |||
| isEtra = isEtra, | |||
| isExtra = isExtra, | |||
| allowedSupplierCodes = allowedSupplierCodes, | |||
| pageable = PageRequest.of(dbPage, 500), | |||
| ) | |||
| @@ -2140,6 +2117,7 @@ val inventoryLotLine = illId?.let { inventoryLotLineMap[it] } | |||
| val ids = rows.mapNotNull { it.id } | |||
| if (ids.isEmpty()) return emptyList() | |||
| val deliveryOrdersMap = deliveryOrderRepository.findAllById(ids).associateBy { it.id } | |||
| val (floorSuppliers2F, floorSuppliers4F) = loadDoFloorSupplierLists() | |||
| val shopIdAndDatePairs = rows.mapNotNull { info -> | |||
| val d = deliveryOrdersMap[info.id] | |||
| @@ -2149,11 +2127,7 @@ val inventoryLotLine = illId?.let { inventoryLotLineMap[it] } | |||
| val targetDate = eta.toLocalDate() | |||
| val dayAbbr = getDayOfWeekAbbr(targetDate) | |||
| val supplierCode = d.supplier?.code | |||
| val preferredFloor = when (supplierCode) { | |||
| "P06B" -> "4F" | |||
| "P07", "P06D" -> "2F" | |||
| else -> "2F" | |||
| } | |||
| val preferredFloor = preferredStoreFloorForSupplier(supplierCode, floorSuppliers2F, floorSuppliers4F) | |||
| Triple(shopId, preferredFloor, dayAbbr) | |||
| } else { | |||
| null | |||
| @@ -2169,11 +2143,7 @@ val inventoryLotLine = illId?.let { inventoryLotLineMap[it] } | |||
| return rows.map { info -> | |||
| val deliveryOrder = deliveryOrdersMap[info.id] | |||
| val supplierCode = deliveryOrder?.supplier?.code | |||
| val preferredFloor = when (supplierCode) { | |||
| "P06B" -> "4F" | |||
| "P07", "P06D" -> "2F" | |||
| else -> "2F" | |||
| } | |||
| val preferredFloor = preferredStoreFloorForSupplier(supplierCode, floorSuppliers2F, floorSuppliers4F) | |||
| val shopId = deliveryOrder?.shop?.id | |||
| val infoEta = info.estimatedArrivalDate | |||
| val calculatedTruckLanceCode = | |||
| @@ -2194,14 +2164,14 @@ val inventoryLotLine = illId?.let { inventoryLotLineMap[it] } | |||
| supplierName = info.supplierName, | |||
| shopAddress = info.shopAddress, | |||
| truckLanceCode = calculatedTruckLanceCode, | |||
| isEtra = deliveryOrder?.isEtra ?: info.isEtra, | |||
| isExtra = deliveryOrder?.isExtra ?: info.isExtra, | |||
| ) | |||
| } | |||
| } | |||
| /** | |||
| * 依店鋪 + 揀貨樓層解析當日應顯示之車線。 | |||
| * - **2F**(P07/P06D):`TruckLanceCode` 多為 `車線-…` 且不含星期縮寫,不依 `LIKE '%Mon%'` 篩選;取該店該樓層未刪除車輛中出發時間最早者。 | |||
| * - **2F**(P07/P06D/P06Y):`TruckLanceCode` 多為 `車線-…` 且不含星期縮寫,不依 `LIKE '%Mon%'` 篩選;取該店該樓層未刪除車輛中出發時間最早者。 | |||
| * - **4F**(P06B):維持以星期縮寫篩選 [TruckRepository.findByShopIdAndStoreIdAndDayOfWeek];無命中時再退回同樓層最早出發。 | |||
| */ | |||
| private fun resolveTruckForShopFloorAndDay( | |||
| @@ -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 pickOrderRepository: PickOrderRepository, | |||
| private val doPickOrderRecordRepository: DoPickOrderRecordRepository, | |||
| private val doFloorSupplierSettingsService: DoFloorSupplierSettingsService, | |||
| ) { | |||
| private val poolSize = Runtime.getRuntime().availableProcessors() | |||
| private val executor = Executors.newFixedThreadPool(min(poolSize, 4)) | |||
| @@ -140,22 +141,15 @@ class DoReleaseCoordinatorService( | |||
| private fun updateBatchTicketNumbers() { | |||
| try { | |||
| val dayOfWeekSql = getDayOfWeekAbbrSql("do.estimatedArrivalDate") | |||
| val pfCases = doFloorSupplierSettingsService.sqlPreferredFloorCases("s.code") | |||
| val updateSql = """ | |||
| UPDATE fpsmsdb.do_pick_order dpo | |||
| INNER JOIN ( | |||
| WITH PreferredFloor AS ( | |||
| SELECT | |||
| do.id AS deliveryOrderId, | |||
| CASE | |||
| WHEN s.code = 'P06B' THEN '4F' | |||
| WHEN s.code = 'P07' OR s.code = 'P06D' THEN '2F' | |||
| ELSE NULL | |||
| END AS preferred_floor, | |||
| CASE | |||
| WHEN s.code = 'P06B' THEN 4 | |||
| WHEN s.code = 'P07' OR s.code = 'P06D' THEN 2 | |||
| ELSE NULL | |||
| END AS preferred_store_id | |||
| ${pfCases.floorStringCase} AS preferred_floor, | |||
| ${pfCases.storeIdNumericCase} AS preferred_store_id | |||
| FROM fpsmsdb.delivery_order do | |||
| LEFT JOIN fpsmsdb.shop s ON s.id = do.supplierId AND s.deleted = 0 | |||
| WHERE do.deleted = 0 | |||
| @@ -307,20 +301,13 @@ class DoReleaseCoordinatorService( | |||
| println(" DEBUG: Getting ordered IDs for ${ids.size} orders") | |||
| println(" DEBUG: First 5 IDs: ${ids.take(5)}") | |||
| val dayOfWeekSql = getDayOfWeekAbbrSql("do.estimatedArrivalDate") | |||
| val pfCases = doFloorSupplierSettingsService.sqlPreferredFloorCases("s.code") | |||
| val sql = """ | |||
| WITH PreferredFloor AS ( | |||
| SELECT | |||
| do.id AS deliveryOrderId, | |||
| CASE | |||
| WHEN s.code = 'P06B' THEN '4F' | |||
| WHEN s.code = 'P07' OR s.code = 'P06D' THEN '2F' | |||
| ELSE NULL | |||
| END AS preferred_floor, | |||
| CASE | |||
| WHEN s.code = 'P06B' THEN 4 | |||
| WHEN s.code = 'P07' OR s.code = 'P06D' THEN 2 | |||
| ELSE NULL | |||
| END AS preferred_store_id | |||
| ${pfCases.floorStringCase} AS preferred_floor, | |||
| ${pfCases.storeIdNumericCase} AS preferred_store_id | |||
| FROM fpsmsdb.delivery_order do | |||
| LEFT JOIN fpsmsdb.shop s ON s.id = do.supplierId AND s.deleted = 0 | |||
| WHERE do.id IN (${ids.joinToString(",")}) | |||
| @@ -144,6 +144,9 @@ open class DoWorkbenchDopoAssignmentService( | |||
| sql.append(" AND dop.loadingSequence = :loadingSequence ") | |||
| params["loadingSequence"] = request.loadingSequence | |||
| } | |||
| if (isisExtraReleaseType(request.releaseType)) { | |||
| sql.append(" AND LOWER(COALESCE(dop.releaseType, '')) = 'isExtra' ") | |||
| } | |||
| // Fetch a batch of candidates and try atomic-assign sequentially. | |||
| // This avoids forcing the frontend to refresh when a single picked candidate is concurrently assigned. | |||
| val candidateLimit = 50 | |||
| @@ -247,6 +250,9 @@ open class DoWorkbenchDopoAssignmentService( | |||
| sql.append(" AND dop.loadingSequence = :loadingSequence ") | |||
| params["loadingSequence"] = request.loadingSequence | |||
| } | |||
| if (isisExtraReleaseType(request.releaseType)) { | |||
| sql.append(" AND LOWER(COALESCE(dop.releaseType, '')) = 'isExtra' ") | |||
| } | |||
| val shouldOrderBySequenceV1 = actualStoreId == "2/F" && request.loadingSequence == null | |||
| if (shouldOrderBySequenceV1) { | |||
| sql.append(" ORDER BY dop.requiredDeliveryDate ASC, dop.truckDepartureTime ASC, dop.loadingSequence ASC, dop.id ASC LIMIT 1 ") | |||
| @@ -301,6 +307,11 @@ open class DoWorkbenchDopoAssignmentService( | |||
| } else null | |||
| } | |||
| private fun isisExtraReleaseType(releaseType: String?): Boolean { | |||
| val n = releaseType?.trim()?.lowercase().orEmpty() | |||
| return n == "isExtra" | |||
| } | |||
| private fun parseDepartureTimeToSql(raw: String?): Time? { | |||
| if (raw.isNullOrBlank()) return null | |||
| val s = raw.trim() | |||
| @@ -1,3 +1,4 @@ | |||
| package com.ffii.fpsms.modules.deliveryOrder.service | |||
| import com.ffii.core.support.JdbcDao | |||
| @@ -54,6 +55,7 @@ import java.time.format.DateTimeFormatter | |||
| import com.ffii.fpsms.modules.deliveryOrder.web.models.StoreLaneSummary | |||
| import com.ffii.fpsms.modules.deliveryOrder.web.models.LaneRow | |||
| import com.ffii.fpsms.modules.deliveryOrder.web.models.LaneBtn | |||
| import com.ffii.fpsms.modules.deliveryOrder.web.models.WorkbenchEtraShopLaneGroup | |||
| import com.ffii.fpsms.modules.deliveryOrder.web.models.ReleasedDoPickOrderListItem | |||
| import com.ffii.fpsms.modules.deliveryOrder.web.models.WorkbenchTicketReleaseTableResponse | |||
| import com.ffii.fpsms.modules.user.service.UserService | |||
| @@ -670,6 +672,7 @@ return MessageResponse( | |||
| val releaseFilterClause = when (rt) { | |||
| "batch" -> " AND LOWER(COALESCE(dop.releaseType, '')) = 'batch' " | |||
| "single" -> " AND LOWER(COALESCE(dop.releaseType, '')) = 'single' " | |||
| "isExtra" -> " AND LOWER(COALESCE(dop.releaseType, '')) = 'isExtra' " | |||
| else -> "" | |||
| } | |||
| val sql = """ | |||
| @@ -812,6 +815,7 @@ return MessageResponse( | |||
| unassigned = it.unassigned, | |||
| total = it.total, | |||
| handlerName = it.handlerName, | |||
| storeId = actualStoreId, | |||
| ) | |||
| } | |||
| .sortedWith( | |||
| @@ -853,24 +857,181 @@ return MessageResponse( | |||
| ) | |||
| } | |||
| /** | |||
| * Workbench Etra view: all `delivery_order_pick_order` with `releaseType` = isExtra (case-insensitive), | |||
| * for one [requiredDeliveryDate], grouped by shop then by truck / time / loading sequence. | |||
| */ | |||
| open fun getWorkbenchEtraLaneSummary(requiredDate: LocalDate?): List<WorkbenchEtraShopLaneGroup> { | |||
| val targetDate = requiredDate ?: LocalDate.now() | |||
| val defaultTruck = truckRepository.findById(5577L).orElse(null) | |||
| val defaultTruckLaneCode = defaultTruck?.truckLanceCode ?: "" | |||
| val sql = """ | |||
| SELECT | |||
| dop.shopCode AS shopCode, | |||
| dop.shopName AS shopName, | |||
| dop.storeId AS storeId, | |||
| dop.truckDepartureTime AS truckDepartureTime, | |||
| dop.truckLanceCode AS truckLanceCode, | |||
| dop.loadingSequence AS loadingSequence, | |||
| COUNT(DISTINCT dop.id) AS total_cnt, | |||
| SUM(CASE WHEN dop.handledBy IS NULL THEN 1 ELSE 0 END) AS unassigned_cnt, | |||
| GROUP_CONCAT( | |||
| DISTINCT NULLIF(TRIM(dop.handlerName), '') | |||
| ORDER BY dop.handlerName | |||
| SEPARATOR ', ' | |||
| ) AS handler_names | |||
| FROM fpsmsdb.delivery_order_pick_order dop | |||
| WHERE dop.deleted = 0 | |||
| AND LOWER(COALESCE(dop.releaseType, '')) = 'isExtra' | |||
| AND dop.requiredDeliveryDate = :requiredDate | |||
| AND dop.ticketStatus IN ('pending', 'released') | |||
| AND EXISTS ( | |||
| SELECT 1 FROM fpsmsdb.pick_order po | |||
| WHERE po.deliveryOrderPickOrderId = dop.id AND po.deleted = 0 | |||
| ) | |||
| GROUP BY dop.shopCode, dop.shopName, dop.storeId, dop.truckDepartureTime, dop.truckLanceCode, dop.loadingSequence | |||
| """.trimIndent() | |||
| val rawRows: List<Map<String, Any?>> = try { | |||
| jdbcDao.queryForList(sql, mapOf("requiredDate" to targetDate)) | |||
| } catch (e: Exception) { | |||
| println("❌ getWorkbenchEtraLaneSummary: ${e.message}") | |||
| emptyList() | |||
| } | |||
| fun cellStr(row: Map<String, Any?>, name: String): String? { | |||
| val k = row.keys.find { it.equals(name, true) } ?: return null | |||
| return row[k]?.toString()?.trim()?.takeIf { it.isNotEmpty() } | |||
| } | |||
| fun cellNum(row: Map<String, Any?>, vararg names: String): Int { | |||
| for (n in names) { | |||
| val k = row.keys.find { it.equals(n, true) } ?: continue | |||
| (row[k] as? Number)?.toInt()?.let { return it } | |||
| } | |||
| return 0 | |||
| } | |||
| fun cellNullableInt(row: Map<String, Any?>, vararg names: String): Int? { | |||
| for (n in names) { | |||
| val k = row.keys.find { it.equals(n, true) } ?: continue | |||
| val v = row[k] ?: continue | |||
| when (v) { | |||
| is Number -> return v.toInt() | |||
| is String -> v.trim().toIntOrNull()?.let { return it } | |||
| } | |||
| } | |||
| return null | |||
| } | |||
| data class EtraAgg( | |||
| val shopCode: String?, | |||
| val shopName: String?, | |||
| val storeId: String?, | |||
| val sortTime: LocalTime, | |||
| val lance: String, | |||
| val loadingSequence: Int?, | |||
| val unassigned: Int, | |||
| val total: Int, | |||
| val handlerName: String?, | |||
| ) | |||
| val aggs = rawRows.mapNotNull { row -> | |||
| val lance = cellStr(row, "truckLanceCode") ?: return@mapNotNull null | |||
| if (lance == defaultTruckLaneCode) return@mapNotNull null | |||
| val storeIdCol = cellStr(row, "storeId") | |||
| val ttKey = row.keys.find { it.equals("truckDepartureTime", true) } | |||
| val ttVal = ttKey?.let { row[it] } | |||
| val sortTime = when (ttVal) { | |||
| null -> LocalTime.MIDNIGHT | |||
| is java.sql.Time -> ttVal.toLocalTime() | |||
| is LocalTime -> ttVal | |||
| is java.sql.Timestamp -> ttVal.toLocalDateTime().toLocalTime() | |||
| else -> runCatching { LocalTime.parse(ttVal.toString().take(8)) }.getOrNull() | |||
| ?: runCatching { LocalTime.parse(ttVal.toString()) }.getOrNull() | |||
| ?: LocalTime.MIDNIGHT | |||
| } | |||
| val loadingSeq = cellNullableInt(row, "loadingSequence") | |||
| val unassigned = cellNum(row, "unassigned_cnt", "unassignedCnt") | |||
| val total = cellNum(row, "total_cnt", "totalCnt") | |||
| if (total <= 0) return@mapNotNull null | |||
| EtraAgg( | |||
| shopCode = cellStr(row, "shopCode"), | |||
| shopName = cellStr(row, "shopName"), | |||
| storeId = storeIdCol, | |||
| sortTime = sortTime, | |||
| lance = lance, | |||
| loadingSequence = loadingSeq, | |||
| unassigned = unassigned, | |||
| total = total, | |||
| handlerName = cellStr(row, "handler_names"), | |||
| ) | |||
| } | |||
| val byShop = aggs.groupBy { a -> | |||
| listOf(a.shopCode ?: "", a.shopName ?: "").joinToString("|") | |||
| } | |||
| return byShop.entries | |||
| .map { (key, group) -> | |||
| val head = group.first() | |||
| val lanes = group | |||
| .sortedWith( | |||
| compareBy<EtraAgg> { it.sortTime } | |||
| .thenBy { it.lance } | |||
| .thenBy { it.loadingSequence ?: 999 } | |||
| ) | |||
| .map { | |||
| val is4F = it.storeId?.replace("/", "")?.trim()?.equals("4F", ignoreCase = true) == true | |||
| LaneBtn( | |||
| truckLanceCode = it.lance, | |||
| loadingSequence = if (is4F) it.loadingSequence else null, | |||
| unassigned = it.unassigned, | |||
| total = it.total, | |||
| handlerName = it.handlerName, | |||
| storeId = it.storeId, | |||
| truckDepartureTime = it.sortTime.toString(), | |||
| ) | |||
| } | |||
| WorkbenchEtraShopLaneGroup( | |||
| shopCode = head.shopCode, | |||
| shopName = head.shopName, | |||
| lanes = lanes, | |||
| ) | |||
| } | |||
| .sortedWith( | |||
| compareBy<WorkbenchEtraShopLaneGroup> { it.shopName ?: it.shopCode ?: "" } | |||
| .thenBy { it.shopCode ?: "" } | |||
| ) | |||
| } | |||
| open fun findWorkbenchReleasedDeliveryOrderPickOrdersForSelection( | |||
| shopName: String?, | |||
| storeId: String?, | |||
| truck: String?, | |||
| releaseTypeFilter: String? = null, | |||
| ): List<ReleasedDoPickOrderListItem> = | |||
| queryWorkbenchReleasedDopoList(shopName, storeId, truck, beforeToday = true) | |||
| queryWorkbenchReleasedDopoList(shopName, storeId, truck, beforeToday = true, releaseTypeFilter = releaseTypeFilter) | |||
| /** | |||
| * @param requiredDeliveryDate when null, uses [LocalDate.now] (calendar today). | |||
| * When set, filters `dop.requiredDeliveryDate = :targetDate` (workbench date picker / select day). | |||
| * @param releaseTypeFilter when `isExtra` (case-insensitive), only `delivery_order_pick_order.releaseType = isExtra` rows. | |||
| */ | |||
| open fun findWorkbenchReleasedDeliveryOrderPickOrdersForSelectionToday( | |||
| shopName: String?, | |||
| storeId: String?, | |||
| truck: String?, | |||
| requiredDeliveryDate: LocalDate? = null, | |||
| releaseTypeFilter: String? = null, | |||
| ): List<ReleasedDoPickOrderListItem> = | |||
| queryWorkbenchReleasedDopoList(shopName, storeId, truck, beforeToday = false, equalsDeliveryDate = requiredDeliveryDate) | |||
| queryWorkbenchReleasedDopoList( | |||
| shopName, | |||
| storeId, | |||
| truck, | |||
| beforeToday = false, | |||
| equalsDeliveryDate = requiredDeliveryDate, | |||
| releaseTypeFilter = releaseTypeFilter, | |||
| ) | |||
| /** | |||
| * Workbench completed tickets: query `delivery_order_pick_order` where `ticketStatus = completed`. | |||
| @@ -1362,7 +1523,11 @@ return MessageResponse( | |||
| dop.deliveryNoteCode = CodeGenerator.generateNo(prefix = prefix, midfix = midfix, latestCode = latestCode) | |||
| } | |||
| deliveryOrderPickOrderRepository.save(dop) | |||
| <<<<<<< HEAD | |||
| ======= | |||
| >>>>>>> e9f1f48edb57d3696af3ffb23bc40d9644c8c44f | |||
| } | |||
| markDeliveryOrdersCompletedForDeliveryOrderPickOrder(deliveryOrderPickOrderId) | |||
| return MessageResponse( | |||
| @@ -1469,6 +1634,7 @@ return MessageResponse( | |||
| truck: String?, | |||
| beforeToday: Boolean, | |||
| equalsDeliveryDate: LocalDate? = null, | |||
| releaseTypeFilter: String? = null, | |||
| ): List<ReleasedDoPickOrderListItem> { | |||
| val today = LocalDate.now() | |||
| val params = mutableMapOf<String, Any>() | |||
| @@ -1519,6 +1685,10 @@ return MessageResponse( | |||
| sqlBuilder.append(" AND (dop.shopName LIKE :shopPat OR dop.shopCode LIKE :shopPat) ") | |||
| params["shopPat"] = "%${shopName.trim()}%" | |||
| } | |||
| val rtNorm = releaseTypeFilter?.trim()?.lowercase().orEmpty() | |||
| if (rtNorm == "isExtra") { | |||
| sqlBuilder.append(" AND LOWER(COALESCE(dop.releaseType, '')) = 'isExtra' ") | |||
| } | |||
| sqlBuilder.append(" ORDER BY dop.requiredDeliveryDate, dop.truckDepartureTime, dop.truckLanceCode, dop.id ") | |||
| val rows: List<Map<String, Any?>> = try { | |||
| jdbcDao.queryForList(sqlBuilder.toString(), params) | |||
| @@ -1913,6 +2083,7 @@ return MessageResponse( | |||
| tryCompleteDeliveryOrderPickOrderTicketCompleted(poId) | |||
| } | |||
| } | |||
| private fun registerAfterCommit(action: () -> Unit) { | |||
| if (!TransactionSynchronizationManager.isSynchronizationActive()) { | |||
| action() | |||
| @@ -2048,6 +2219,7 @@ return MessageResponse( | |||
| ) | |||
| } | |||
| } | |||
| private fun postWorkbenchPickSideEffects(savedStockOutLine: StockOutLine, deltaQty: BigDecimal, createLedger: Boolean = true) { | |||
| if (deltaQty <= BigDecimal.ZERO) return | |||
| val wall0 = System.nanoTime() | |||
| @@ -2230,9 +2402,10 @@ return MessageResponse( | |||
| throw last ?: RuntimeException("Failed to complete pick order after retries (poId=$poId)") | |||
| } | |||
| /** | |||
| /** | |||
| * Workbench completion: if all pick_orders under the same delivery_order_pick_order are completed, | |||
| * update ONLY delivery_order_pick_order.ticketStatus (no do_pick_order/do_pick_order_line records). | |||
| * update delivery_order_pick_order.ticketStatus and related delivery_order.status → completed. | |||
| * Does not create do_pick_order / do_pick_order_line records. | |||
| */ | |||
| private fun tryCompleteDeliveryOrderPickOrderTicketCompleted(poId: Long) { | |||
| val dopRow = jdbcDao.queryForMap( | |||
| @@ -2307,7 +2480,6 @@ return MessageResponse( | |||
| deliveryOrderRepository.save(deliveryOrder) | |||
| } | |||
| } | |||
| private fun checkWorkbenchPickOrderLineCompleted(pickOrderLineId: Long, allStockOutLines: List<StockOutLineInfo>) { | |||
| val pol = pickOrderLineRepository.findById(pickOrderLineId).orElse(null) ?: return | |||
| if (pol.status == PickOrderLineStatus.COMPLETED) return | |||
| @@ -2745,11 +2917,7 @@ return MessageResponse( | |||
| } | |||
| } | |||
| /** | |||
| * Carton label reprint for workbench: [request.doPickOrderId] is [delivery_order_pick_order.id], | |||
| * same as [getWorkbenchPrintContext]. Legacy [DeliveryOrderService.printDNLabelsReprint] expects | |||
| * [do_pick_order_record.recordId] and must not be used here. | |||
| */ | |||
| private fun exportDNLabelsReprintWorkbench(request: PrintDNLabelsReprintRequest): Map<String, Any> { | |||
| validateWorkbenchCartonReprintRange( | |||
| fromCarton = request.fromCarton, | |||
| @@ -359,14 +359,17 @@ open class DoWorkbenchReleaseService( | |||
| } | |||
| /** | |||
| * `TI-B-yyyyMMdd-2F-001` (batch) or `TI-S-yyyyMMdd-2F-001` (single), same suffix rules as [DoReleaseCoordinatorService] / legacy `do_pick_order`. | |||
| * `TI-B-yyyyMMdd-2F-001` (batch), `TI-S-yyyyMMdd-2F-001` (single), or `TI-E-yyyyMMdd-2F-001` (Etra), | |||
| * same suffix rules as [DoReleaseCoordinatorService] / legacy `do_pick_order`. | |||
| */ | |||
| private fun nextDeliveryOrderPickOrderTicketNo( | |||
| requiredDate: LocalDate, | |||
| storeDisplay: String, | |||
| ticketLetter: String, | |||
| ): String { | |||
| require(ticketLetter == "B" || ticketLetter == "S") { "ticketLetter must be B or S" } | |||
| require(ticketLetter == "B" || ticketLetter == "S" || ticketLetter == "E") { | |||
| "ticketLetter must be B, S or E" | |||
| } | |||
| val ymd = requiredDate.format(DateTimeFormatter.ofPattern("yyyyMMdd")) | |||
| val floor = storeDisplay.replace("/", "").trim() | |||
| val prefix = "TI-$ticketLetter-$ymd-$floor-" | |||
| @@ -397,6 +400,9 @@ open class DoWorkbenchReleaseService( | |||
| private fun nextDeliveryOrderPickOrderSingleTicketNo(requiredDate: LocalDate, storeDisplay: String): String = | |||
| nextDeliveryOrderPickOrderTicketNo(requiredDate, storeDisplay, "S") | |||
| private fun nextDeliveryOrderPickOrderEtraTicketNo(requiredDate: LocalDate, storeDisplay: String): String = | |||
| nextDeliveryOrderPickOrderTicketNo(requiredDate, storeDisplay, "E") | |||
| private fun asyncJobType(useV2: Boolean, dopReleaseType: String): String { | |||
| val single = dopReleaseType.equals("single", ignoreCase = true) | |||
| return when { | |||
| @@ -440,11 +446,6 @@ open class DoWorkbenchReleaseService( | |||
| ): Int { | |||
| if (results.isEmpty()) return 0 | |||
| val releaseTypeCol = when (dopReleaseType.lowercase()) { | |||
| "single" -> "single" | |||
| else -> "batch" | |||
| } | |||
| val grouped = results.groupBy { | |||
| listOf( | |||
| it.shopId?.toString() ?: "", | |||
| @@ -452,7 +453,8 @@ open class DoWorkbenchReleaseService( | |||
| it.preferredFloor, | |||
| it.truckId?.toString() ?: "", | |||
| it.truckDepartureTime?.toString() ?: "", | |||
| it.truckLanceCode ?: "" | |||
| it.truckLanceCode ?: "", | |||
| it.isExtra.toString(), | |||
| ).joinToString("|") | |||
| } | |||
| @@ -477,7 +479,16 @@ open class DoWorkbenchReleaseService( | |||
| (storeId ?: "2/F").replace("/", "").trim() | |||
| } | |||
| val requiredDate = first.estimatedArrivalDate ?: LocalDate.now() | |||
| val tempTicket = if (releaseTypeCol == "single") { | |||
| val releaseTypeCol = if (first.isExtra) { | |||
| "isExtra" | |||
| } else if (dopReleaseType.equals("single", ignoreCase = true)) { | |||
| "single" | |||
| } else { | |||
| "batch" | |||
| } | |||
| val tempTicket = if (first.isExtra) { | |||
| nextDeliveryOrderPickOrderEtraTicketNo(requiredDate, ticketFloorSegment) | |||
| } else if (releaseTypeCol == "single") { | |||
| nextDeliveryOrderPickOrderSingleTicketNo(requiredDate, ticketFloorSegment) | |||
| } else { | |||
| nextDeliveryOrderPickOrderBatchTicketNo(requiredDate, ticketFloorSegment) | |||
| @@ -72,7 +72,7 @@ class DeliveryOrderController( | |||
| pageSize = request.pageSize, | |||
| truckLanceCode = request.truckLanceCode, | |||
| floor = request.floor, | |||
| isEtra = request.isEtra, | |||
| isExtra = request.isExtra, | |||
| ) | |||
| } | |||
| @@ -89,7 +89,7 @@ class DeliveryOrderController( | |||
| pageNum = request.pageNum, | |||
| pageSize = request.pageSize, | |||
| floor = request.floor, | |||
| isEtra = request.isEtra, | |||
| isExtra = request.isExtra, | |||
| ) | |||
| } | |||
| @@ -108,7 +108,7 @@ class DeliveryOrderController( | |||
| pageSize = request.pageSize, | |||
| truckLanceCode = request.truckLanceCode, | |||
| floor = request.floor, | |||
| isEtra = request.isEtra, | |||
| isExtra = request.isExtra, | |||
| ) | |||
| } | |||
| @@ -96,14 +96,27 @@ class DoWorkbenchController( | |||
| ) | |||
| } | |||
| /** All Etra workbench tickets for a day, grouped by shop → truck (see [DoWorkbenchMainService.getWorkbenchEtraLaneSummary]). */ | |||
| @GetMapping("/summary-is-etra") | |||
| fun getWorkbenchEtraSummary( | |||
| @RequestParam(required = false) @DateTimeFormat(iso = DateTimeFormat.ISO.DATE) requiredDate: LocalDate?, | |||
| ): List<WorkbenchEtraShopLaneGroup> = | |||
| doWorkbenchMainService.getWorkbenchEtraLaneSummary(requiredDate) | |||
| /** Past-date backlog tickets from `delivery_order_pick_order` (not `do_pick_order`). */ | |||
| @GetMapping("/released") | |||
| fun getWorkbenchReleasedDoPickOrders( | |||
| @RequestParam(required = false) shopName: String?, | |||
| @RequestParam(required = false) storeId: String?, | |||
| @RequestParam(required = false) truck: String? | |||
| @RequestParam(required = false) truck: String?, | |||
| @RequestParam(required = false) releaseType: String?, | |||
| ): List<ReleasedDoPickOrderListItem> { | |||
| return doWorkbenchMainService.findWorkbenchReleasedDeliveryOrderPickOrdersForSelection(shopName, storeId, truck) | |||
| return doWorkbenchMainService.findWorkbenchReleasedDeliveryOrderPickOrdersForSelection( | |||
| shopName, | |||
| storeId, | |||
| truck, | |||
| releaseTypeFilter = releaseType, | |||
| ) | |||
| } | |||
| @GetMapping("/released-today") | |||
| @@ -112,12 +125,14 @@ class DoWorkbenchController( | |||
| @RequestParam(required = false) storeId: String?, | |||
| @RequestParam(required = false) truck: String?, | |||
| @RequestParam(required = false) @DateTimeFormat(iso = DateTimeFormat.ISO.DATE) requiredDate: LocalDate?, | |||
| @RequestParam(required = false) releaseType: String?, | |||
| ): List<ReleasedDoPickOrderListItem> { | |||
| return doWorkbenchMainService.findWorkbenchReleasedDeliveryOrderPickOrdersForSelectionToday( | |||
| shopName, | |||
| storeId, | |||
| truck, | |||
| requiredDeliveryDate = requiredDate, | |||
| releaseTypeFilter = releaseType, | |||
| ) | |||
| } | |||
| @@ -19,7 +19,7 @@ data class DoDetailResponse( | |||
| val completeDate: LocalDateTime?, | |||
| val status: String?, | |||
| /** 加單 DO(M18 加單專用同步) */ | |||
| val isEtra: Boolean = false, | |||
| val isExtra: Boolean = false, | |||
| val deliveryOrderLines: List<DoDetailLineResponse> | |||
| ) | |||
| @@ -51,7 +51,18 @@ data class LaneBtn( | |||
| val unassigned: Int, | |||
| val total: Int, | |||
| // 同一 truckLanceCode + loadingSequence 的 handler 去重后逗号拼接 | |||
| val handlerName: String? = null | |||
| val handlerName: String? = null, | |||
| /** Workbench Etra lane: `delivery_order_pick_order.storeId` (2/F, 4/F, …) for assign / modal scope */ | |||
| val storeId: String? = null, | |||
| /** Workbench Etra / lane row: `truckDepartureTime` as ISO local time string for assign-by-lane */ | |||
| val truckDepartureTime: String? = null, | |||
| ) | |||
| /** All Etra (`releaseType=isExtra`) tickets for a day, grouped by shop then truck (no 2F/4F split in UI). */ | |||
| data class WorkbenchEtraShopLaneGroup( | |||
| val shopCode: String?, | |||
| val shopName: String?, | |||
| val lanes: List<LaneBtn>, | |||
| ) | |||
| data class AssignByLaneRequest( | |||
| val userId: Long, | |||
| @@ -59,7 +70,9 @@ data class AssignByLaneRequest( | |||
| val truckDepartureTime: String?, // 可选:限定出车时间 | |||
| val truckLanceCode: String , | |||
| val loadingSequence: Int? = null, | |||
| val requiredDate: LocalDate? // 必填:车道编号 | |||
| val requiredDate: LocalDate?, // 必填:车道编号 | |||
| /** When `isExtra`, assignment candidates are limited to `releaseType = isExtra` rows. */ | |||
| val releaseType: String? = null, | |||
| ) | |||
| data class DoPickOrderSummaryItem( | |||
| val truckDepartureTime: java.time.LocalTime?, | |||
| @@ -21,7 +21,8 @@ data class ReleaseDoResult( | |||
| val truckDepartureTime: LocalTime?, | |||
| val truckLanceCode: String?, | |||
| val loadingSequence: Int? | |||
| val loadingSequence: Int?, | |||
| val isExtra: Boolean = false, | |||
| ) | |||
| data class SearchDeliveryOrderInfoRequest( | |||
| val code: String?, | |||
| @@ -31,8 +32,8 @@ data class SearchDeliveryOrderInfoRequest( | |||
| val pageSize: Int?, | |||
| val pageNum: Int?, | |||
| val truckLanceCode: String?, | |||
| /** `ALL`/`All`/null:P06B+P07+P06D;`2F`:P07+P06D;`4F`:P06B。車線-X 亦依供應商歸屬出現在對應樓層。 */ | |||
| /** `ALL`/`All`/null:P06B+P07+P06D+P06Y;`2F`:P07+P06D+P06Y ;`4F`:P06B。車線-X 亦依供應商歸屬出現在對應樓層。 */ | |||
| val floor: String? = null, | |||
| /** null:不篩 isEtra;true/false:只顯示加單或非加單 DO */ | |||
| val isEtra: Boolean? = null, | |||
| /** null:不篩 isExtra;true/false:只顯示加單或非加單 DO */ | |||
| val isExtra: Boolean? = null, | |||
| ) | |||
| @@ -20,7 +20,7 @@ data class SaveDeliveryOrderRequest( | |||
| val handlerId: Long?, | |||
| val m18BeId: Long?, | |||
| val deleted: Boolean? = false, | |||
| val isEtra: Boolean? = false, | |||
| val isExtra: Boolean? = false, | |||
| ) | |||
| data class SaveDeliveryOrderStatusRequest( | |||
| @@ -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) | |||
| // Must match python Bag2.py generate_zpl_dataflex() | |||
| // Must match python Bag3.py generate_zpl_dataflex() field layout / fonts. | |||
| val fontRegular = "E:STXihei.ttf" | |||
| val fontBold = "E:STXihei.ttf" | |||
| // Match python Bag3.py DataFlex defaults: narrower ^PW so job preview is not mostly empty on the right (^A@R fields are tall, not wide). | |||
| val labelPw = 400 | |||
| val labelLl = 500 | |||
| return """ | |||
| ^XA | |||
| ^CI28 | |||
| ^PW700 | |||
| ^LL500 | |||
| ^PW$labelPw | |||
| ^LL$labelLl | |||
| ^PO N | |||
| ^FO10,20 | |||
| ^BQN,2,4^FDQA,$qrValue^FS | |||
| @@ -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.pickOrder.entity.Truck | |||
| import org.springframework.data.jpa.repository.Query | |||
| import org.springframework.data.repository.query.Param | |||
| import org.springframework.stereotype.Repository | |||
| @Repository | |||
| @@ -30,6 +31,15 @@ interface ShopRepository : AbstractRepository<Shop, Long> { | |||
| fun findByCode(code: String): Shop? | |||
| @Query( | |||
| """ | |||
| SELECT s FROM Shop s | |||
| WHERE s.deleted = false | |||
| AND s.code IN :codes | |||
| """ | |||
| ) | |||
| fun findAllByCodeInAndDeletedIsFalse(@Param("codes") codes: Collection<String>): List<Shop> | |||
| @Query( | |||
| nativeQuery = true, | |||
| value = """ | |||
| @@ -34,6 +34,15 @@ import java.util.Comparator | |||
| import com.ffii.fpsms.modules.jobOrder.entity.JobOrderRepository | |||
| import com.ffii.fpsms.modules.productProcess.entity.ProductProcessRepository | |||
| import org.springframework.transaction.annotation.Transactional | |||
| import com.fasterxml.jackson.databind.ObjectMapper | |||
| import com.ffii.fpsms.m18.service.M18BomForShopService | |||
| import com.ffii.fpsms.m18.model.M18BomShopSyncTriggerResult | |||
| import com.ffii.fpsms.m18.model.GoodsReceiptNoteResponse | |||
| import com.ffii.fpsms.m18.entity.M18BomShopSyncLog | |||
| import com.ffii.fpsms.m18.entity.M18BomShopSyncLogRepository | |||
| import com.ffii.fpsms.modules.common.SettingNames | |||
| import com.ffii.fpsms.modules.settings.entity.Settings | |||
| import com.ffii.fpsms.modules.settings.service.SettingsService | |||
| @Service | |||
| open class BomService( | |||
| @@ -52,6 +61,10 @@ open class BomService( | |||
| private val itemUomService: ItemUomService, | |||
| private val jobOrderRepository: JobOrderRepository, | |||
| private val productProcessRepository: ProductProcessRepository, | |||
| private val m18BomForShopService: M18BomForShopService, | |||
| private val m18BomShopSyncLogRepository: M18BomShopSyncLogRepository, | |||
| private val objectMapper: ObjectMapper, | |||
| private val settingsService: SettingsService, | |||
| @Value("\${bom.import.temp-dir:\${java.io.tmpdir}/fpsms-bom-import}") private val bomImportTempDir: String, | |||
| ) { | |||
| open fun uploadBomFiles(files: List<MultipartFile>): BomUploadResponse { | |||
| @@ -119,6 +132,29 @@ open class BomService( | |||
| ?: bomRepository.findAllByItemIdAndDeletedIsFalse(itemId).firstOrNull() | |||
| } | |||
| /** Resolve BOM header for a finished-good item code ([Items.code] on [Bom.item]). */ | |||
| open fun findBomSummaryByItemCode(itemCodeTrimmed: String): BomIdByItemCodeResponse { | |||
| val code = itemCodeTrimmed.trim() | |||
| val item = itemsRepository.findByCodeAndDeletedFalse(code) | |||
| ?: return BomIdByItemCodeResponse( | |||
| itemCode = code, | |||
| message = "Item not found for code", | |||
| ) | |||
| val bom = findByItemId(item.id!!) | |||
| ?: return BomIdByItemCodeResponse( | |||
| itemCode = code, | |||
| itemId = item.id, | |||
| message = "No BOM linked to this item", | |||
| ) | |||
| return BomIdByItemCodeResponse( | |||
| itemCode = code, | |||
| itemId = item.id, | |||
| bomId = bom.id, | |||
| bomCode = bom.code, | |||
| bomM18Id = bom.m18Id, | |||
| ) | |||
| } | |||
| open fun saveBom(request: SaveBomRequest): SaveBomResponse { | |||
| val item = request.code.let { itemsService.findByM18BomCode(it) } ?: request.itemId?.let { itemsService.findById(it) } | |||
| @@ -371,6 +407,114 @@ open class BomService( | |||
| return getBomDetail(bom.id!!) | |||
| } | |||
| /** | |||
| * When {@link SettingNames#M18_BOM_SHOP_SYNC_ENABLED} is true, push BOM to M18 udfBomForShop. | |||
| * Use {@code POST /m18/test/bom-shop-sync/{bomId}} (or future UI) to trigger explicitly. | |||
| * Optional [m18HeaderId]: M18 udfBomForShop **header** record id (e.g. [BomIdByItemCodeResponse.bomM18Id]) | |||
| * to force **update** when [Bom.m18Id] is missing or stale. When null, uses [Bom.m18Id] if set. | |||
| */ | |||
| open fun pushBomToM18ShopIfAllowed(bomId: Long, m18HeaderId: Long? = null): M18BomShopSyncTriggerResult { | |||
| if (!isM18BomShopSyncEnabled()) { | |||
| return M18BomShopSyncTriggerResult( | |||
| bomId = bomId, | |||
| synced = false, | |||
| skippedReason = "M18 BOM shop sync disabled (${SettingNames.M18_BOM_SHOP_SYNC_ENABLED} is not true)", | |||
| ) | |||
| } | |||
| val bom = bomRepository.findByIdAndDeletedIsFalse(bomId) | |||
| ?: return M18BomShopSyncTriggerResult(bomId, false, skippedReason = "BOM not found") | |||
| val req = m18BomForShopService.buildSaveRequest(bom, m18HeaderId) | |||
| ?: return M18BomShopSyncTriggerResult(bomId, false, skippedReason = "Cannot build M18 payload (missing m18 UOM/item ids or no materials)") | |||
| val requestJsonPayload = m18BomForShopService.toJson(req) | |||
| var resp: GoodsReceiptNoteResponse? = null | |||
| var callError: Throwable? = null | |||
| try { | |||
| resp = m18BomForShopService.saveBomForShop(req) | |||
| } catch (e: Exception) { | |||
| callError = e | |||
| } | |||
| val responseJsonPayload = when { | |||
| resp != null -> m18BomForShopService.toJson(resp) | |||
| callError != null -> | |||
| runCatching { | |||
| objectMapper.writeValueAsString( | |||
| mapOf( | |||
| "exceptionType" to callError.javaClass.name, | |||
| "message" to (callError.message ?: ""), | |||
| ), | |||
| ) | |||
| }.getOrElse { """{"error":"failed to serialize exception"}""" } | |||
| else -> """{"error":"M18 API returned null"}""" | |||
| } | |||
| val msgSummary = resp?.messages?.joinToString("; ") { it.msgDetail ?: it.msgCode ?: "" }.orEmpty() | |||
| val apiStatus = resp?.status == true | |||
| val recordId = resp?.recordId ?: 0L | |||
| val result = when { | |||
| callError != null -> | |||
| M18BomShopSyncTriggerResult( | |||
| bomId = bomId, | |||
| synced = false, | |||
| skippedReason = callError.message ?: "M18 API call failed", | |||
| status = false, | |||
| messageSummary = callError.message, | |||
| ) | |||
| resp == null -> | |||
| M18BomShopSyncTriggerResult(bomId, false, skippedReason = "M18 API returned null") | |||
| resp.status == true && resp.recordId > 0L -> { | |||
| bom.m18Id = resp.recordId | |||
| bomRepository.saveAndFlush(bom) | |||
| M18BomShopSyncTriggerResult( | |||
| bomId = bomId, | |||
| synced = true, | |||
| recordId = resp.recordId, | |||
| status = true, | |||
| messageSummary = msgSummary.ifBlank { null }, | |||
| ) | |||
| } | |||
| else -> | |||
| M18BomShopSyncTriggerResult( | |||
| bomId = bomId, | |||
| synced = false, | |||
| skippedReason = "M18 save failed or status=false", | |||
| recordId = resp.recordId.takeIf { it > 0 }, | |||
| status = resp.status, | |||
| messageSummary = msgSummary.ifBlank { null }, | |||
| ) | |||
| } | |||
| val logMessage = listOfNotNull( | |||
| msgSummary.ifBlank { null }, | |||
| callError?.message, | |||
| result.skippedReason?.takeIf { !result.synced }, | |||
| ).joinToString("; ").take(4000) | |||
| m18BomShopSyncLogRepository.save( | |||
| M18BomShopSyncLog().apply { | |||
| this.bomId = bomId | |||
| finishedItemCode = req.udfbomforshop.values.firstOrNull()?.udfBomCode | |||
| m18HeaderCode = req.udfbomforshop.values.firstOrNull()?.code | |||
| requestFingerprint = m18BomForShopService.contentFingerprint(req) | |||
| m18RecordId = recordId.takeIf { it > 0 } | |||
| m18ApiStatus = apiStatus | |||
| synced = result.synced | |||
| message = logMessage.ifBlank { null } | |||
| requestJson = requestJsonPayload | |||
| responseJson = responseJsonPayload | |||
| }, | |||
| ) | |||
| return result | |||
| } | |||
| private fun isM18BomShopSyncEnabled(): Boolean = | |||
| settingsService.findByName(SettingNames.M18_BOM_SHOP_SYNC_ENABLED) | |||
| .map { Settings.VALUE_BOOLEAN_TRUE == it.value } | |||
| .orElse(false) | |||
| private fun resolveEquipmentForBomProcess(pReq: EditBomProcessRequest): Equipment { | |||
| val equipmentId = pReq.equipmentId | |||
| val equipmentCode = pReq.equipmentCode?.trim().orEmpty() | |||
| @@ -282,6 +282,20 @@ open class ItemUomService( | |||
| return finalizePreciseStockQty(stockUnit, stockQty) | |||
| } | |||
| /** | |||
| * Convert quantity from [uomId] (must exist on `item_uom` for [itemId]) to the item's **base unit** quantity. | |||
| * Returns null when no `item_uom` row links the item to that UOM. | |||
| */ | |||
| open fun convertQtyToBaseQtyPrecise(itemId: Long, uomId: Long, sourceQty: BigDecimal): BigDecimal? { | |||
| val itemUom = findFirstByItemIdAndUomId(itemId, uomId) ?: return null | |||
| val one = BigDecimal.ONE | |||
| val calcScale = 10 | |||
| return sourceQty | |||
| .multiply(itemUom.ratioN ?: one) | |||
| .divide(itemUom.ratioD ?: one, calcScale, RoundingMode.HALF_UP) | |||
| .stripTrailingZeros() | |||
| } | |||
| // See if need to update the response | |||
| open fun saveItemUom(request: ItemUomRequest): ItemUom { | |||
| val itemUom = request.m18Id?.let { itemUomRespository.findFirstByM18IdOrderByIdDesc(it) } | |||
| @@ -29,6 +29,8 @@ import com.ffii.fpsms.modules.master.web.models.ImportBomRequestPayload | |||
| import com.ffii.fpsms.modules.master.web.models.BomDetailResponse | |||
| import com.ffii.fpsms.modules.master.web.models.EditBomRequest | |||
| import com.ffii.fpsms.modules.master.web.models.BomExcelCheckProgress | |||
| import com.ffii.fpsms.modules.master.web.models.BomIdByItemCodeResponse | |||
| import com.ffii.core.exception.BadRequestException | |||
| import java.util.logging.Logger | |||
| import java.nio.file.Files | |||
| import org.springframework.core.io.FileSystemResource | |||
| @@ -120,6 +122,16 @@ fun downloadBomFormatIssueLog( | |||
| // fun exportProblematicBom() { | |||
| // return bomService.importBOM() | |||
| // } | |||
| /** Testing: FPSMS BOM id by finished-good item code (same item as BOM header). */ | |||
| @GetMapping("/by-item-code") | |||
| fun getBomByItemCode(@RequestParam code: String): BomIdByItemCodeResponse { | |||
| if (code.isBlank()) { | |||
| throw BadRequestException("query parameter code is required") | |||
| } | |||
| return bomService.findBomSummaryByItemCode(code.trim()) | |||
| } | |||
| @GetMapping("/{id}/detail") | |||
| fun getBomDetail(@PathVariable id: Long): BomDetailResponse { | |||
| return bomService.getBomDetail(id) | |||
| @@ -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 | |||
| import com.ffii.core.entity.BaseEntity | |||
| import com.ffii.fpsms.modules.logistic.entity.Logistic | |||
| import com.ffii.fpsms.modules.master.entity.Shop | |||
| import jakarta.persistence.* | |||
| import jakarta.validation.constraints.NotNull | |||
| @@ -42,4 +43,8 @@ open class Truck : BaseEntity<Long>() { | |||
| @Column(name = "remark") | |||
| open var remark: String? = null | |||
| } | |||
| @ManyToOne(fetch = FetchType.LAZY) | |||
| @JoinColumn(name = "logisticId") | |||
| open var logistic: Logistic? = null | |||
| } | |||
| @@ -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 | |||
| import com.ffii.core.support.AbstractRepository | |||
| import com.ffii.fpsms.modules.logistic.entity.Logistic | |||
| import org.springframework.data.jpa.repository.Modifying | |||
| import org.springframework.data.jpa.repository.Query | |||
| import org.springframework.data.repository.query.Param | |||
| import org.springframework.stereotype.Repository | |||
| @@ -32,8 +34,81 @@ interface TruckRepository : AbstractRepository<Truck, Long> { | |||
| fun findByTruckLanceCode(truckLanceCode: String): Truck? | |||
| @Query("SELECT t FROM Truck t WHERE t.truckLanceCode = :truckLanceCode AND t.deleted = false") | |||
| fun findAllByTruckLanceCodeAndDeletedFalse(@Param("truckLanceCode") truckLanceCode: String): List<Truck> | |||
| /** | |||
| * Same lane group as `findAllUniqueTruckLanceCodeAndRemarkCombinations`: | |||
| * remark NULL / blank belong to one bucket; non-blank matches exactly. | |||
| */ | |||
| @Query( | |||
| """ | |||
| SELECT DISTINCT t FROM Truck t | |||
| LEFT JOIN FETCH t.logistic | |||
| LEFT JOIN FETCH t.shop | |||
| WHERE t.truckLanceCode = :truckLanceCode | |||
| AND t.deleted = false | |||
| AND ( | |||
| (:blankRemark = true AND (t.remark IS NULL OR trim(t.remark) = '')) | |||
| OR (:blankRemark = false AND trim(t.remark) = :exactRemark) | |||
| ) | |||
| """ | |||
| ) | |||
| fun findAllByTruckLanceCodeAndRemarkAndDeletedFalse( | |||
| @Param("truckLanceCode") truckLanceCode: String, | |||
| @Param("blankRemark") blankRemark: Boolean, | |||
| @Param("exactRemark") exactRemark: String?, | |||
| ): List<Truck> | |||
| /** | |||
| * RouteBoard O(1) load: return all truck rows used by lanes, with logistic pre-fetched. | |||
| * Frontend groups by (truckLanceCode, normalizedRemark) where normalizedRemark is: | |||
| * - NULL / blank => "" | |||
| * - else TRIM(remark) | |||
| */ | |||
| @Query( | |||
| """ | |||
| SELECT t FROM Truck t | |||
| LEFT JOIN FETCH t.logistic | |||
| LEFT JOIN FETCH t.shop | |||
| WHERE t.deleted = false | |||
| AND t.truckLanceCode IS NOT NULL | |||
| AND trim(t.truckLanceCode) <> '' | |||
| ORDER BY t.truckLanceCode ASC, | |||
| CASE WHEN t.remark IS NULL OR trim(t.remark) = '' THEN '' ELSE trim(t.remark) END ASC, | |||
| t.loadingSequence ASC, | |||
| t.id ASC | |||
| """ | |||
| ) | |||
| fun findAllForRouteBoard(): List<Truck> | |||
| /** | |||
| * 單一 UPDATE 寫入整條 lane 的 logistic,避免先 JOIN FETCH 載入再逐列 save(大車線會極慢)。 | |||
| */ | |||
| @Modifying(clearAutomatically = true, flushAutomatically = true) | |||
| @Query( | |||
| """ | |||
| UPDATE Truck t SET t.logistic = :logistic | |||
| WHERE t.truckLanceCode = :truckLanceCode | |||
| AND t.deleted = false | |||
| AND ( | |||
| (:blankRemark = true AND (t.remark IS NULL OR trim(t.remark) = '')) | |||
| OR (:blankRemark = false AND trim(t.remark) = :exactRemark) | |||
| ) | |||
| """, | |||
| ) | |||
| fun bulkUpdateLogisticForLaneGroup( | |||
| @Param("logistic") logistic: Logistic?, | |||
| @Param("truckLanceCode") truckLanceCode: String, | |||
| @Param("blankRemark") blankRemark: Boolean, | |||
| @Param("exactRemark") exactRemark: String?, | |||
| ): Int | |||
| fun findAllByTruckLanceCodeAndStoreIdAndDeletedFalse(truckLanceCode: String, storeId: String): List<Truck> | |||
| fun findByShopNameAndStoreIdAndTruckLanceCode(shopName: String, storeId: String, truckLanceCode: String): Truck? | |||
| fun findByShopCodeAndStoreId(shopCode: String, storeId: String): Truck? | |||
| /** 同店同樓層重複列時取 id 最小一筆,避免 NonUniqueResultException */ | |||
| fun findFirstByShopCodeAndStoreIdAndDeletedFalseOrderByIdAsc( | |||
| shopCode: String, | |||
| storeId: String, | |||
| ): Truck? | |||
| fun findByShopIdAndStoreId(shopId: Long, storeId: String): Truck? | |||
| @@ -60,15 +135,17 @@ fun findByShopIdAndStoreIdAndDayOfWeek( | |||
| SELECT t.* | |||
| FROM truck t | |||
| INNER JOIN ( | |||
| SELECT TruckLanceCode, remark, MIN(id) as min_id | |||
| SELECT TruckLanceCode, | |||
| COALESCE(NULLIF(TRIM(remark), ''), '') AS remark_norm, | |||
| MIN(id) AS min_id | |||
| FROM truck | |||
| WHERE deleted = false | |||
| AND TruckLanceCode IS NOT NULL | |||
| GROUP BY TruckLanceCode, remark | |||
| GROUP BY TruckLanceCode, COALESCE(NULLIF(TRIM(remark), ''), '') | |||
| ) AS unique_combos | |||
| ON t.id = unique_combos.min_id | |||
| WHERE t.deleted = false | |||
| ORDER BY t.TruckLanceCode, t.remark | |||
| ORDER BY t.TruckLanceCode, COALESCE(NULLIF(TRIM(t.remark), ''), '') | |||
| """ | |||
| ) | |||
| fun findAllUniqueTruckLanceCodeAndRemarkCombinations(): List<Truck> | |||
| @@ -240,6 +240,9 @@ ORDER BY | |||
| "id" to row["stockOutLineId"], | |||
| "status" to row["stockOutLineStatus"], | |||
| "qty" to row["stockOutLineQty"], | |||
| "requiredQty" to row["requiredQty"], | |||
| "suggestedPickLotQty" to row["requiredQty"], | |||
| "suggestedPickLotId" to row["suggestedPickLotId"], | |||
| "lotId" to lotId, | |||
| "lotNo" to (row["lotNo"] ?: ""), | |||
| "location" to (row["location"] ?: ""), | |||
| @@ -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 org.apache.poi.ss.usermodel.Workbook | |||
| import org.apache.poi.xssf.usermodel.XSSFWorkbook | |||
| import org.springframework.http.ContentDisposition | |||
| import org.springframework.http.HttpHeaders | |||
| import org.springframework.http.MediaType | |||
| import org.springframework.http.ResponseEntity | |||
| import java.nio.charset.StandardCharsets | |||
| import org.springframework.web.bind.annotation.* | |||
| import org.springframework.web.multipart.MultipartHttpServletRequest | |||
| @@ -17,12 +21,19 @@ import com.ffii.fpsms.modules.pickOrder.web.models.UpdateTruckShopDetailsRequest | |||
| import com.ffii.fpsms.modules.pickOrder.service.TruckService | |||
| import com.ffii.fpsms.modules.pickOrder.entity.TruckRepository | |||
| import com.ffii.fpsms.modules.pickOrder.web.models.SaveTruckLane | |||
| import com.ffii.fpsms.modules.pickOrder.web.models.TruckLaneCombinationResponse | |||
| import com.ffii.fpsms.modules.pickOrder.web.models.ExportRouteLanesRequest | |||
| import com.ffii.fpsms.modules.pickOrder.web.models.ExportRouteReportRequest | |||
| import com.ffii.fpsms.modules.pickOrder.web.models.ExportTruckLaneVersionReportExcelRequest | |||
| import com.ffii.fpsms.modules.pickOrder.web.models.UpdateLaneLogisticRequest | |||
| import com.ffii.fpsms.modules.pickOrder.web.models.ParseRouteLanesExcelResponse | |||
| import com.ffii.fpsms.modules.pickOrder.web.models.deleteTruckLane | |||
| import com.ffii.fpsms.modules.pickOrder.web.models.toLaneCombinationResponse | |||
| import jakarta.validation.Valid | |||
| @RestController | |||
| @RequestMapping("/truck") | |||
| class TruckController( | |||
| open class TruckController( | |||
| private val truckService: TruckService, | |||
| private val truckRepository: TruckRepository, | |||
| ) { | |||
| @@ -80,6 +91,142 @@ class TruckController( | |||
| } | |||
| } | |||
| /** | |||
| * PDF 圖1:多車線匯出;每個 laneId(encodeLaneId)一個 worksheet,格式 MTMS_ROUTE_V1。 | |||
| */ | |||
| @PostMapping( | |||
| "/exportRouteLanesExcel", | |||
| consumes = [MediaType.APPLICATION_JSON_VALUE], | |||
| produces = ["application/vnd.openxmlformats-officedocument.spreadsheetml.sheet"], | |||
| ) | |||
| fun exportRouteLanesExcel(@RequestBody req: ExportRouteLanesRequest): ResponseEntity<ByteArray> { | |||
| val bytes = truckService.exportRouteLanesExcelBytes(req.laneIds) | |||
| val filename = "MTMS_車線_${System.currentTimeMillis()}.xlsx" | |||
| val disposition = ContentDisposition.attachment() | |||
| .filename(filename, StandardCharsets.UTF_8) | |||
| .build() | |||
| return ResponseEntity.ok() | |||
| .header(HttpHeaders.CONTENT_DISPOSITION, disposition.toString()) | |||
| .contentType(MediaType.parseMediaType("application/vnd.openxmlformats-officedocument.spreadsheetml.sheet")) | |||
| .body(bytes) | |||
| } | |||
| /** | |||
| * 圖2:車線 Report(單一 sheet;每間物流公司一個水平區塊) | |||
| */ | |||
| @PostMapping( | |||
| "/exportRouteReportExcel", | |||
| consumes = [MediaType.APPLICATION_JSON_VALUE], | |||
| produces = ["application/vnd.openxmlformats-officedocument.spreadsheetml.sheet"], | |||
| ) | |||
| fun exportRouteReportExcel( | |||
| request: HttpServletRequest, | |||
| @RequestBody req: ExportRouteReportRequest, | |||
| ): ResponseEntity<ByteArray> { | |||
| val preparedBy = request.userPrincipal?.name?.trim().takeUnless { it.isNullOrBlank() } ?: "current user" | |||
| val bytes = truckService.exportRouteReportExcelBytes(req.laneIds, preparedBy) | |||
| val filename = truckService.buildRouteReportFilename() | |||
| val disposition = ContentDisposition.attachment() | |||
| .filename(filename, StandardCharsets.UTF_8) | |||
| .build() | |||
| return ResponseEntity.ok() | |||
| .header(HttpHeaders.CONTENT_DISPOSITION, disposition.toString()) | |||
| .contentType(MediaType.parseMediaType("application/vnd.openxmlformats-officedocument.spreadsheetml.sheet")) | |||
| .body(bytes) | |||
| } | |||
| @PostMapping( | |||
| "/exportTruckLaneVersionReportExcel", | |||
| consumes = [MediaType.APPLICATION_JSON_VALUE], | |||
| produces = ["application/vnd.openxmlformats-officedocument.spreadsheetml.sheet"], | |||
| ) | |||
| open fun exportTruckLaneVersionReportExcel( | |||
| request: HttpServletRequest, | |||
| @RequestBody req: ExportTruckLaneVersionReportExcelRequest, | |||
| ): ResponseEntity<ByteArray> { | |||
| val preparedBy = request.userPrincipal?.name?.trim().takeUnless { it.isNullOrBlank() } ?: "current user" | |||
| val bytes = truckService.exportTruckLaneVersionReportExcelBytes( | |||
| TruckService.ExportTruckLaneVersionReportInput( | |||
| fromVersionId = req.fromVersionId, | |||
| toVersionId = req.toVersionId, | |||
| preparedBy = preparedBy, | |||
| ), | |||
| ) | |||
| val filename = "車線版本報告_${System.currentTimeMillis()}.xlsx" | |||
| val disposition = ContentDisposition.attachment() | |||
| .filename(filename, StandardCharsets.UTF_8) | |||
| .build() | |||
| return ResponseEntity.ok() | |||
| .header(HttpHeaders.CONTENT_DISPOSITION, disposition.toString()) | |||
| .contentType(MediaType.parseMediaType("application/vnd.openxmlformats-officedocument.spreadsheetml.sheet")) | |||
| .body(bytes) | |||
| } | |||
| /** 與 [importRouteLanesExcel] 同一格式;僅解析、不寫入 DB(看板 staged import 預覽)。 */ | |||
| @PostMapping("/parseRouteLanesExcel") | |||
| @Throws(ServletRequestBindingException::class) | |||
| fun parseRouteLanesExcel(request: HttpServletRequest): ResponseEntity<ParseRouteLanesExcelResponse> { | |||
| var workbook: Workbook? = null | |||
| try { | |||
| val multipartFile = (request as MultipartHttpServletRequest).getFile("multipartFileList") | |||
| workbook = XSSFWorkbook(multipartFile?.inputStream) | |||
| return ResponseEntity.ok(truckService.parseRouteLanesExcel(workbook)) | |||
| } catch (e: Exception) { | |||
| println("Error reading Excel file: ${e.message}") | |||
| return ResponseEntity.badRequest().body( | |||
| ParseRouteLanesExcelResponse(0, 0, emptyList()), | |||
| ) | |||
| } finally { | |||
| try { | |||
| workbook?.close() | |||
| } catch (_: Exception) { | |||
| } | |||
| } | |||
| } | |||
| /** 與 [exportRouteLanesExcel] 同一格式;一個檔案內多 sheet,每 sheet 一條車線。 */ | |||
| @PostMapping("/importRouteLanesExcel") | |||
| @Throws(ServletRequestBindingException::class) | |||
| fun importRouteLanesExcel(request: HttpServletRequest): ResponseEntity<*> { | |||
| var workbook: Workbook? = null | |||
| try { | |||
| val multipartFile = (request as MultipartHttpServletRequest).getFile("multipartFileList") | |||
| workbook = XSSFWorkbook(multipartFile?.inputStream) | |||
| } catch (e: Exception) { | |||
| println("Error reading Excel file: ${e.message}") | |||
| return ResponseEntity.badRequest().body( | |||
| MessageResponse( | |||
| id = null, | |||
| name = null, | |||
| code = null, | |||
| type = "truck", | |||
| message = "Error reading Excel file: ${e.message}", | |||
| errorPosition = null, | |||
| entity = null, | |||
| ), | |||
| ) | |||
| } | |||
| try { | |||
| val result = truckService.importRouteLanesExcel(workbook) | |||
| return ResponseEntity.ok( | |||
| MessageResponse( | |||
| id = null, | |||
| name = null, | |||
| code = null, | |||
| type = "truck", | |||
| message = result, | |||
| errorPosition = null, | |||
| entity = null, | |||
| ), | |||
| ) | |||
| } finally { | |||
| try { | |||
| workbook?.close() | |||
| } catch (_: Exception) { | |||
| } | |||
| } | |||
| } | |||
| @PostMapping("/importExcel") | |||
| @Throws(ServletRequestBindingException::class) | |||
| fun importExcel(request: HttpServletRequest): ResponseEntity<*> { | |||
| @@ -103,18 +250,25 @@ class TruckController( | |||
| ) | |||
| } | |||
| val result = truckService.importExcel(workbook) | |||
| return ResponseEntity.ok( | |||
| MessageResponse( | |||
| id = null, | |||
| name = null, | |||
| code = null, | |||
| type = "truck", | |||
| message = result, | |||
| errorPosition = null, | |||
| entity = null | |||
| try { | |||
| val result = truckService.importExcel(workbook) | |||
| return ResponseEntity.ok( | |||
| MessageResponse( | |||
| id = null, | |||
| name = null, | |||
| code = null, | |||
| type = "truck", | |||
| message = result, | |||
| errorPosition = null, | |||
| entity = null | |||
| ) | |||
| ) | |||
| ) | |||
| } finally { | |||
| try { | |||
| workbook?.close() | |||
| } catch (_: Exception) { | |||
| } | |||
| } | |||
| } | |||
| @GetMapping("/findTruckLane/{shopId}") | |||
| @@ -136,7 +290,7 @@ class TruckController( | |||
| type = "truck", | |||
| message = if (truck != null) "Truck lane updated successfully" else "Truck lane not found", | |||
| errorPosition = null, | |||
| entity = truck | |||
| entity = null | |||
| ) | |||
| } catch (e: Exception) { | |||
| return MessageResponse( | |||
| @@ -151,6 +305,32 @@ class TruckController( | |||
| } | |||
| } | |||
| @PostMapping("/updateLaneLogistic") | |||
| fun updateLaneLogistic(@Valid @RequestBody request: UpdateLaneLogisticRequest): MessageResponse { | |||
| try { | |||
| val n = truckService.updateLogisticForEntireLane(request) | |||
| return MessageResponse( | |||
| id = null, | |||
| name = null, | |||
| code = request.truckLanceCode, | |||
| type = "truck", | |||
| message = "Updated logistic for $n truck row(s)", | |||
| errorPosition = null, | |||
| entity = null, | |||
| ) | |||
| } catch (e: Exception) { | |||
| return MessageResponse( | |||
| id = null, | |||
| name = null, | |||
| code = null, | |||
| type = "truck", | |||
| message = "Error: ${e.message}", | |||
| errorPosition = null, | |||
| entity = null, | |||
| ) | |||
| } | |||
| } | |||
| @PostMapping("/deleteTruckLane") | |||
| fun deleteTruckLane(@Valid @RequestBody request: deleteTruckLane): MessageResponse { | |||
| try { | |||
| @@ -178,8 +358,10 @@ class TruckController( | |||
| } | |||
| @GetMapping("/findAllUniqueTruckLanceCodeAndRemarkCombinations") | |||
| fun findAllUniqueTruckLanceCodeAndRemarkCombinations(): List<Truck> { | |||
| return truckService.findAllUniqueTruckLanceCodeAndRemarkCombinations() | |||
| fun findAllUniqueTruckLanceCodeAndRemarkCombinations(): List<TruckLaneCombinationResponse> { | |||
| return truckService | |||
| .findAllUniqueTruckLanceCodeAndRemarkCombinations() | |||
| .map { it.toLaneCombinationResponse() } | |||
| } | |||
| @@ -193,6 +375,27 @@ class TruckController( | |||
| return truckService.findAllByTruckLanceCodeAndDeletedFalse(truckLanceCode) | |||
| } | |||
| /** | |||
| * Filter trucks by the same (truckLanceCode, remark) group as the unique-combinations query. | |||
| * Omit `remark` or pass empty for rows with NULL/empty remark. | |||
| */ | |||
| @GetMapping("/findAllByTruckLanceCodeAndRemarkAndDeletedFalse") | |||
| fun findAllByTruckLanceCodeAndRemarkAndDeletedFalse( | |||
| @RequestParam truckLanceCode: String, | |||
| @RequestParam(required = false) remark: String?, | |||
| ): List<Truck> { | |||
| return truckService.findAllByTruckLanceCodeAndRemarkAndDeletedFalse(truckLanceCode, remark) | |||
| } | |||
| /** | |||
| * RouteBoard O(1) load: return all truck rows (deleted=false) once. | |||
| * Frontend groups by (truckLanceCode, normalizedRemark). | |||
| */ | |||
| @GetMapping("/findAllForRouteBoard") | |||
| fun findAllForRouteBoard(): List<Truck> { | |||
| return truckService.findAllForRouteBoard() | |||
| } | |||
| @GetMapping("/findAllUniqueShopNamesAndCodesFromTrucks") | |||
| fun findAllUniqueShopNamesAndCodesFromTrucks(): List<Map<String, String>> { | |||
| return truckService.findAllUniqueShopNamesAndCodesFromTrucks() | |||
| @@ -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 | |||
| import jakarta.validation.constraints.NotBlank | |||
| import java.time.LocalTime | |||
| data class SaveTruckRequest( | |||
| val id: Long? = null, | |||
| @@ -11,6 +12,7 @@ data class SaveTruckRequest( | |||
| val loadingSequence: Int, | |||
| val remark: String? = null, | |||
| val districtReference: String? = null, | |||
| val logisticId: Long? = null, | |||
| ) | |||
| data class SaveTruckLane( | |||
| val id: Long, | |||
| @@ -19,7 +21,10 @@ data class SaveTruckLane( | |||
| val loadingSequence: Long, | |||
| val districtReference: String?, | |||
| val storeId: String, | |||
| val remark: String? = null | |||
| val remark: String? = null, | |||
| val logisticId: Long? = null, | |||
| /** When true, apply [logisticId] (including null to clear); when false, leave truck.logistic unchanged. */ | |||
| val updateLogistic: Boolean = false, | |||
| ) | |||
| data class deleteTruckLane( | |||
| val id: Long | |||
| @@ -39,4 +44,13 @@ data class CreateTruckWithoutShopRequest( | |||
| val loadingSequence: Int = 0, | |||
| val districtReference: String? = null, | |||
| val remark: String? = null, | |||
| val logisticId: Long? = null, | |||
| ) | |||
| /** 單一 transaction 更新同 (truckLanceCode, remark) 桶內所有 truck 的 logistic。 */ | |||
| data class UpdateLaneLogisticRequest( | |||
| @field:NotBlank | |||
| val truckLanceCode: String, | |||
| val remark: String? = null, | |||
| val logisticId: Long? = null, | |||
| ) | |||
| @@ -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) | |||
| .sortedBy { it.seqNo ?: 0L } | |||
| println("=== findNewCreatedLineIds DEBUG START ===") | |||
| println("BOM bomProcessMap: $bomProcessMap") | |||
| println("All lines (sorted by seqNo):") | |||
| //println("=== findNewCreatedLineIds DEBUG START ===") | |||
| //println("BOM bomProcessMap: $bomProcessMap") | |||
| //println("All lines (sorted by seqNo):") | |||
| allLines.forEach { line -> | |||
| println(" id=${line.id}, seqNo=${line.seqNo}, bomProcessId=${line.bomProcess?.id}") | |||
| //println(" id=${line.id}, seqNo=${line.seqNo}, bomProcessId=${line.bomProcess?.id}") | |||
| } | |||
| // 创建一个集合来跟踪哪些 line 是新创建的 | |||
| @@ -2402,18 +2402,18 @@ open class ProductProcessService( | |||
| iteration++ | |||
| hasChanges = false | |||
| println("\n--- Iteration $iteration ---") | |||
| //println("\n--- Iteration $iteration ---") | |||
| // 获取剩余的 line(排除已标记为新创建的),按 seqNo 排序 | |||
| val remainingLines = allLines.filter { it.id !in newCreatedLineIds } | |||
| .sortedBy { it.seqNo ?: 0L } | |||
| println("Remaining lines (excluding new created):") | |||
| //println("Remaining lines (excluding new created):") | |||
| remainingLines.forEach { line -> | |||
| println(" id=${line.id}, seqNo=${line.seqNo}, bomProcessId=${line.bomProcess?.id}") | |||
| //println(" id=${line.id}, seqNo=${line.seqNo}, bomProcessId=${line.bomProcess?.id}") | |||
| } | |||
| println("New created line IDs so far: $newCreatedLineIds") | |||
| //println("New created line IDs so far: $newCreatedLineIds") | |||
| // 计算每个剩余 line 的期望 seqNo(应该是连续的 1, 2, 3...) | |||
| val expectedSeqNoMap = remainingLines.mapIndexed { index, line -> | |||
| @@ -2430,7 +2430,7 @@ open class ProductProcessService( | |||
| val bomProcessId = line.bomProcess?.id | |||
| val expectedSeqNo = expectedSeqNoMap[line.id] ?: continue | |||
| println("\nChecking line id=${line.id}, seqNo=${line.seqNo}, bomProcessId=$bomProcessId, expectedSeqNo=$expectedSeqNo") | |||
| //println("\nChecking line id=${line.id}, seqNo=${line.seqNo}, bomProcessId=$bomProcessId, expectedSeqNo=$expectedSeqNo") | |||
| if (bomProcessId == null) { | |||
| println(" -> No bomProcessId, marking as new created") | |||
| @@ -2442,7 +2442,7 @@ open class ProductProcessService( | |||
| // 查找这个 bomProcessId 在 BOM 中的实际 seqNo | |||
| val bomProcessSeqNo = bomProcessMap[bomProcessId] | |||
| println(" -> BOM bomProcessId=$bomProcessId has seqNo=$bomProcessSeqNo in BOM") | |||
| //println(" -> BOM bomProcessId=$bomProcessId has seqNo=$bomProcessSeqNo in BOM") | |||
| if (bomProcessSeqNo == null) { | |||
| println(" -> bomProcessId not found in BOM, marking as new created") | |||
| @@ -2461,8 +2461,8 @@ open class ProductProcessService( | |||
| } | |||
| } | |||
| } | |||
| println("\n=== Final Result ===") | |||
| println("New created line IDs: $newCreatedLineIds") | |||
| //println("\n=== Final Result ===") | |||
| //println("New created line IDs: $newCreatedLineIds") | |||
| println("=== findNewCreatedLineIds DEBUG END ===\n") | |||
| return newCreatedLineIds | |||
| @@ -3,6 +3,7 @@ package com.ffii.fpsms.modules.purchaseOrder.entity | |||
| import com.ffii.core.support.AbstractRepository | |||
| import com.ffii.fpsms.modules.purchaseOrder.entity.projections.PurchaseOrderLineInfo | |||
| import com.ffii.fpsms.modules.purchaseOrder.enums.PurchaseOrderLineStatus | |||
| import org.springframework.data.domain.Pageable | |||
| import org.springframework.data.jpa.repository.Query | |||
| import org.springframework.data.repository.query.Param | |||
| import org.springframework.stereotype.Repository | |||
| @@ -10,6 +11,37 @@ import java.io.Serializable | |||
| @Repository | |||
| interface PurchaseOrderLineRepository : AbstractRepository<PurchaseOrderLine, Long> { | |||
| @Query( | |||
| "SELECT pol FROM PurchaseOrderLine pol " + | |||
| "LEFT JOIN FETCH pol.purchaseOrder po " + | |||
| "LEFT JOIN FETCH po.supplier " + | |||
| "JOIN FETCH pol.uom " + | |||
| "LEFT JOIN FETCH pol.uomM18 " + | |||
| "WHERE pol.deleted = false AND pol.item.id = :itemId AND pol.m18DataLog IS NOT NULL " + | |||
| "ORDER BY pol.created DESC", | |||
| ) | |||
| fun findLatestLinesForBomM18ByItemId( | |||
| @Param("itemId") itemId: Long, | |||
| pageable: Pageable, | |||
| ): List<PurchaseOrderLine> | |||
| /** | |||
| * Latest PO (by header `purchase_order.created`) for a material item code: supplier `shop.m18Id` from `purchase_order.supplierId`. | |||
| * Mirrors manual SQL: pol → items (code), po, shop on supplier, uom_conversion; order by po.created desc limit 1. | |||
| */ | |||
| @Query( | |||
| value = | |||
| "SELECT sh.m18Id FROM purchase_order_line pol " + | |||
| "LEFT JOIN items it ON pol.itemId = it.id " + | |||
| "LEFT JOIN purchase_order po ON pol.purchaseOrderId = po.id " + | |||
| "LEFT JOIN shop sh ON po.supplierId = sh.id " + | |||
| "LEFT JOIN uom_conversion um ON pol.uomIdM18 = um.id " + | |||
| "WHERE pol.deleted = false AND it.deleted = false AND it.code = :itemCode " + | |||
| "ORDER BY po.created DESC LIMIT 1", | |||
| nativeQuery = true, | |||
| ) | |||
| fun findLatestPoSupplierM18IdByItemCodeNative(@Param("itemCode") itemCode: String): List<Long> | |||
| fun findByM18DataLogIdAndDeletedIsFalse(m18datalogId: Serializable): PurchaseOrderLine? | |||
| fun findAllPurchaseOrderLineInfoByPurchaseOrderIdAndDeletedIsFalse(purchaseOrderId: Long): List<PurchaseOrderLineInfo> | |||
| fun findAllByPurchaseOrderIdAndDeletedIsFalse(purchaseOrderId: Long): List<PurchaseOrderLine> | |||
| @@ -9,13 +9,23 @@ class FGStockOutTraceabilityReportService( | |||
| ) { | |||
| fun getDistinctHandlersForFGStockOutTraceability(): List<String> { | |||
| val sql = """ | |||
| SELECT DISTINCT COALESCE(picker_user.name, modified_user.name, '') AS handler | |||
| FROM stock_out_line sol | |||
| INNER JOIN stock_out so ON sol.stockOutId = so.id AND so.deleted = 0 AND so.type = 'do' | |||
| LEFT JOIN user picker_user ON sol.handled_by = picker_user.id AND picker_user.deleted = 0 | |||
| LEFT JOIN user modified_user ON sol.modifiedBy = modified_user.staffNo AND modified_user.deleted = 0 AND sol.handled_by IS NULL | |||
| WHERE sol.deleted = 0 | |||
| ORDER BY handler | |||
| SELECT DISTINCT h.handler | |||
| FROM ( | |||
| SELECT TRIM(COALESCE(picker_user.name, modified_user.name, '')) AS handler | |||
| FROM stock_out_line sol | |||
| INNER JOIN stock_out so ON sol.stockOutId = so.id AND so.deleted = 0 AND so.type = 'do' | |||
| LEFT JOIN user picker_user ON sol.handled_by = picker_user.id AND picker_user.deleted = 0 | |||
| LEFT JOIN user modified_user ON sol.modifiedBy = modified_user.staffNo AND modified_user.deleted = 0 AND sol.handled_by IS NULL | |||
| WHERE sol.deleted = 0 | |||
| UNION | |||
| SELECT TRIM(IFNULL(handlerName, '')) AS handler | |||
| FROM delivery_order_pick_order | |||
| WHERE deleted = 0 | |||
| AND ticketStatus = 'completed' | |||
| AND IFNULL(handlerName, '') <> '' | |||
| ) h | |||
| WHERE TRIM(IFNULL(h.handler, '')) <> '' | |||
| ORDER BY h.handler | |||
| """.trimIndent() | |||
| return jdbcDao | |||
| @@ -54,7 +64,7 @@ class FGStockOutTraceabilityReportService( | |||
| val yearSql = if (!year.isNullOrBlank()) { | |||
| args["year"] = year | |||
| "AND YEAR(IFNULL(dpor.RequiredDeliveryDate, do.estimatedArrivalDate)) = :year" | |||
| "AND YEAR(IFNULL(dopo.requiredDeliveryDate, do.estimatedArrivalDate)) = :year" | |||
| } else { | |||
| "" | |||
| } | |||
| @@ -62,7 +72,7 @@ class FGStockOutTraceabilityReportService( | |||
| val lastOutDateStartSql = if (!lastOutDateStart.isNullOrBlank()) { | |||
| val formattedDate = lastOutDateStart.replace("/", "-") | |||
| args["lastOutDateStart"] = formattedDate | |||
| "AND DATE(IFNULL(dpor.RequiredDeliveryDate, do.estimatedArrivalDate)) >= DATE(:lastOutDateStart)" | |||
| "AND DATE(IFNULL(dopo.requiredDeliveryDate, do.estimatedArrivalDate)) >= DATE(:lastOutDateStart)" | |||
| } else { | |||
| "" | |||
| } | |||
| @@ -70,14 +80,14 @@ class FGStockOutTraceabilityReportService( | |||
| val lastOutDateEndSql = if (!lastOutDateEnd.isNullOrBlank()) { | |||
| val formattedDate = lastOutDateEnd.replace("/", "-") | |||
| args["lastOutDateEnd"] = formattedDate | |||
| "AND DATE(IFNULL(dpor.RequiredDeliveryDate, do.estimatedArrivalDate)) <= DATE(:lastOutDateEnd)" | |||
| "AND DATE(IFNULL(dopo.requiredDeliveryDate, do.estimatedArrivalDate)) <= DATE(:lastOutDateEnd)" | |||
| } else { | |||
| "" | |||
| } | |||
| val handlerSql = buildMultiValueExactClause( | |||
| handler, | |||
| "COALESCE(picker_user.name, modified_user.name, '')", | |||
| "COALESCE(picker_user.name, modified_user.name, IFNULL(dopo.handlerName, ''))", | |||
| "handler", | |||
| args, | |||
| ) | |||
| @@ -85,13 +95,13 @@ class FGStockOutTraceabilityReportService( | |||
| val sql = """ | |||
| SELECT | |||
| IFNULL(DATE_FORMAT( | |||
| IFNULL(dpor.RequiredDeliveryDate, do.estimatedArrivalDate), | |||
| IFNULL(dopo.requiredDeliveryDate, do.estimatedArrivalDate), | |||
| '%Y-%m-%d' | |||
| ), '') AS deliveryDate, | |||
| IFNULL(it.code, '') AS itemNo, | |||
| IFNULL(it.name, '') AS itemName, | |||
| IFNULL(uc.udfudesc, '') AS unitOfMeasure, | |||
| IFNULL(dpor.deliveryNoteCode, '') AS dnNo, | |||
| IFNULL(dopo.deliveryNoteCode, '') AS dnNo, | |||
| CAST(IFNULL(sp.id, 0) AS CHAR) AS customerId, | |||
| IFNULL(sp.name, '') AS customerName, | |||
| FORMAT( | |||
| @@ -109,11 +119,13 @@ class FGStockOutTraceabilityReportService( | |||
| COALESCE( | |||
| picker_user.name, | |||
| modified_user.name, | |||
| dopo.handlerName, | |||
| '' | |||
| ) AS handler, | |||
| COALESCE( | |||
| picker_user.name, | |||
| modified_user.name, | |||
| dopo.handlerName, | |||
| '' | |||
| ) AS pickedBy, | |||
| GROUP_CONCAT(DISTINCT wh.code ORDER BY wh.code SEPARATOR ', ') AS storeLocation, | |||
| @@ -122,19 +134,22 @@ class FGStockOutTraceabilityReportService( | |||
| ROUND(SUM(IFNULL(sol.qty, 0)) OVER (PARTITION BY it.code), 0), 0 | |||
| ) AS totalStockOutQty, | |||
| 0 AS stockSubCategory | |||
| FROM do_pick_order_line_record dpolr | |||
| LEFT JOIN do_pick_order_record dpor | |||
| ON dpolr.record_id = dpor.id | |||
| AND dpor.deleted = 0 | |||
| AND dpor.ticket_status = 'completed' | |||
| FROM delivery_order_pick_order dopo | |||
| INNER JOIN pick_order po | |||
| ON po.deliveryOrderPickOrderId = dopo.id | |||
| AND po.deleted = 0 | |||
| INNER JOIN delivery_order do | |||
| ON dpolr.do_order_id = do.id | |||
| ON po.doId = do.id | |||
| AND do.deleted = 0 | |||
| LEFT JOIN shop sp | |||
| ON do.shopId = sp.id | |||
| AND sp.deleted = 0 | |||
| LEFT JOIN pick_order_line pol | |||
| ON pol.poId = po.id | |||
| AND pol.deleted = 0 | |||
| LEFT JOIN delivery_order_line dol | |||
| ON do.id = dol.deliveryOrderId | |||
| ON dol.deliveryOrderId = do.id | |||
| AND dol.itemId = pol.itemId | |||
| AND dol.deleted = 0 | |||
| LEFT JOIN items it | |||
| ON dol.itemId = it.id | |||
| @@ -144,13 +159,6 @@ class FGStockOutTraceabilityReportService( | |||
| AND iu.stockUnit = 1 | |||
| LEFT JOIN uom_conversion uc | |||
| ON iu.uomId = uc.id | |||
| LEFT JOIN pick_order_line pol | |||
| ON dpolr.pick_order_id = pol.poId | |||
| AND pol.itemId = it.id | |||
| AND pol.deleted = 0 | |||
| LEFT JOIN pick_order po | |||
| ON pol.poId = po.id | |||
| AND po.deleted = 0 | |||
| LEFT JOIN stock_out_line sol | |||
| ON pol.id = sol.pickOrderLineId | |||
| AND sol.itemId = it.id | |||
| @@ -176,7 +184,8 @@ class FGStockOutTraceabilityReportService( | |||
| AND modified_user.deleted = 0 | |||
| AND sol.handled_by IS NULL | |||
| WHERE | |||
| dpolr.deleted = 0 | |||
| dopo.deleted = 0 | |||
| AND dopo.ticketStatus = 'completed' | |||
| $stockCategorySql | |||
| $stockSubCategorySql | |||
| $itemCodeSql | |||
| @@ -186,12 +195,13 @@ class FGStockOutTraceabilityReportService( | |||
| $handlerSql | |||
| GROUP BY | |||
| sol.id, | |||
| dpor.RequiredDeliveryDate, | |||
| dopo.requiredDeliveryDate, | |||
| dopo.handlerName, | |||
| do.estimatedArrivalDate, | |||
| it.code, | |||
| it.name, | |||
| uc.udfudesc, | |||
| dpor.deliveryNoteCode, | |||
| dopo.deliveryNoteCode, | |||
| sp.id, | |||
| sp.name, | |||
| sol.qty, | |||
| @@ -10,6 +10,7 @@ import com.ffii.fpsms.m18.M18GrnRules | |||
| import com.ffii.fpsms.modules.master.entity.ShopRepository | |||
| import com.ffii.fpsms.modules.master.enums.ShopType | |||
| import com.ffii.fpsms.modules.master.service.ItemUomService | |||
| import com.ffii.fpsms.modules.deliveryOrder.service.DoFloorSupplierSettingsService | |||
| import java.math.BigDecimal | |||
| import net.sf.jasperreports.engine.export.ooxml.JRXlsxExporter | |||
| import net.sf.jasperreports.export.SimpleExporterInput | |||
| @@ -20,6 +21,7 @@ open class ReportService( | |||
| private val jdbcDao: JdbcDao, | |||
| private val itemUomService: ItemUomService, | |||
| private val shopRepository: ShopRepository, | |||
| private val doFloorSupplierSettingsService: DoFloorSupplierSettingsService, | |||
| ) { | |||
| /** | |||
| * Queries the database for inventory data based on dates and optional item type. | |||
| @@ -101,7 +103,7 @@ open class ReportService( | |||
| val yearSql = if (!year.isNullOrBlank()) { | |||
| args["year"] = year | |||
| "AND YEAR(IFNULL(dpor.RequiredDeliveryDate, do.estimatedArrivalDate)) = :year" | |||
| "AND YEAR(IFNULL(dopo.requiredDeliveryDate, do.estimatedArrivalDate)) = :year" | |||
| } else { | |||
| "" | |||
| } | |||
| @@ -109,25 +111,27 @@ open class ReportService( | |||
| val lastOutDateStartSql = if (!lastOutDateStart.isNullOrBlank()) { | |||
| val formattedDate = lastOutDateStart.replace("/", "-") | |||
| args["lastOutDateStart"] = formattedDate | |||
| "AND DATE(IFNULL(dpor.RequiredDeliveryDate, do.estimatedArrivalDate)) >= DATE(:lastOutDateStart)" | |||
| "AND DATE(IFNULL(dopo.requiredDeliveryDate, do.estimatedArrivalDate)) >= DATE(:lastOutDateStart)" | |||
| } else "" | |||
| val lastOutDateEndSql = if (!lastOutDateEnd.isNullOrBlank()) { | |||
| val formattedDate = lastOutDateEnd.replace("/", "-") | |||
| args["lastOutDateEnd"] = formattedDate | |||
| "AND DATE(IFNULL(dpor.RequiredDeliveryDate, do.estimatedArrivalDate)) <= DATE(:lastOutDateEnd)" | |||
| "AND DATE(IFNULL(dopo.requiredDeliveryDate, do.estimatedArrivalDate)) <= DATE(:lastOutDateEnd)" | |||
| } else "" | |||
| val supplierFloorSqlCases = doFloorSupplierSettingsService.sqlPreferredFloorCases("supplier.code") | |||
| val sql = """ | |||
| SELECT | |||
| IFNULL(DATE_FORMAT( | |||
| IFNULL(dpor.RequiredDeliveryDate, do.estimatedArrivalDate), | |||
| IFNULL(dopo.requiredDeliveryDate, do.estimatedArrivalDate), | |||
| '%Y-%m-%d' | |||
| ), '') AS deliveryDate, | |||
| IFNULL(it.code, '') AS itemNo, | |||
| IFNULL(it.name, '') AS itemName, | |||
| IFNULL(uc.udfudesc, '') AS unitOfMeasure, | |||
| IFNULL(dpor.deliveryNoteCode, '') AS dnNo, | |||
| IFNULL(dopo.deliveryNoteCode, '') AS dnNo, | |||
| CAST(IFNULL(sp.id, 0) AS CHAR) AS customerId, | |||
| IFNULL(sp.name, '') AS customerName, | |||
| CAST( | |||
| @@ -138,28 +142,20 @@ open class ReportService( | |||
| FORMAT(ROUND(IFNULL(IFNULL(sol.qty, dol.qty), 0), 0), 0) AS qty, | |||
| COALESCE( | |||
| dpor.TruckLanceCode, | |||
| dopo.truckLanceCode, | |||
| (SELECT t2.TruckLanceCode | |||
| FROM truck t2 | |||
| WHERE t2.shopId = do.shopId | |||
| AND t2.deleted = 0 | |||
| AND t2.Store_id = CASE | |||
| WHEN supplier.code = 'P06B' THEN '4F' | |||
| WHEN supplier.code IN ('P07', 'P06D') THEN '2F' | |||
| ELSE NULL | |||
| END | |||
| AND t2.Store_id = ${supplierFloorSqlCases.floorStringCase} | |||
| AND ( | |||
| (CASE | |||
| WHEN supplier.code = 'P06B' THEN '4F' | |||
| WHEN supplier.code IN ('P07', 'P06D') THEN '2F' | |||
| ELSE NULL | |||
| END | |||
| (${supplierFloorSqlCases.floorStringCase} | |||
| AND (SELECT COUNT(*) FROM truck t3 | |||
| WHERE t3.shopId = do.shopId AND t3.deleted = 0 | |||
| AND t3.Store_id = '4F') > 1 | |||
| AND IFNULL(dpor.RequiredDeliveryDate, do.estimatedArrivalDate) IS NOT NULL | |||
| AND IFNULL(dopo.requiredDeliveryDate, do.estimatedArrivalDate) IS NOT NULL | |||
| AND t2.TruckLanceCode LIKE CONCAT('%', | |||
| CASE DAYNAME(IFNULL(dpor.RequiredDeliveryDate, do.estimatedArrivalDate)) | |||
| CASE DAYNAME(IFNULL(dopo.requiredDeliveryDate, do.estimatedArrivalDate)) | |||
| WHEN 'Monday' THEN 'Mon' | |||
| WHEN 'Tuesday' THEN 'Tue' | |||
| WHEN 'Wednesday' THEN 'Wed' | |||
| @@ -170,11 +166,7 @@ FORMAT(ROUND(IFNULL(IFNULL(sol.qty, dol.qty), 0), 0), 0) AS qty, | |||
| ELSE '' | |||
| END, '%')) | |||
| OR | |||
| t2.Store_id = CASE | |||
| WHEN supplier.code = 'P06B' THEN '4F' | |||
| WHEN supplier.code IN ('P07', 'P06D') THEN '2F' | |||
| ELSE NULL | |||
| END | |||
| t2.Store_id = ${supplierFloorSqlCases.floorStringCase} | |||
| ) | |||
| ORDER BY t2.DepartureTime ASC | |||
| LIMIT 1), | |||
| @@ -183,13 +175,12 @@ FORMAT(ROUND(IFNULL(IFNULL(sol.qty, dol.qty), 0), 0), 0) AS qty, | |||
| '' AS driver, | |||
| IFNULL(do.code, '') AS deliveryOrderNo, | |||
| IFNULL(qc.name, '') AS stockSubCategory | |||
| FROM do_pick_order_line_record dpolr | |||
| LEFT JOIN do_pick_order_record dpor | |||
| ON dpolr.do_pick_order_id = dpor.record_id | |||
| AND dpor.deleted = 0 | |||
| AND dpor.ticket_status = 'completed' | |||
| FROM delivery_order_pick_order dopo | |||
| INNER JOIN pick_order po | |||
| ON po.deliveryOrderPickOrderId = dopo.id | |||
| AND po.deleted = 0 | |||
| INNER JOIN delivery_order do | |||
| ON dpolr.do_order_id = do.id | |||
| ON po.doId = do.id | |||
| AND do.deleted = 0 | |||
| LEFT JOIN shop supplier | |||
| ON do.supplierId = supplier.id | |||
| @@ -197,8 +188,12 @@ FORMAT(ROUND(IFNULL(IFNULL(sol.qty, dol.qty), 0), 0), 0) AS qty, | |||
| LEFT JOIN shop sp | |||
| ON do.shopId = sp.id | |||
| AND sp.deleted = 0 | |||
| LEFT JOIN pick_order_line pol | |||
| ON pol.poId = po.id | |||
| AND pol.deleted = 0 | |||
| LEFT JOIN delivery_order_line dol | |||
| ON do.id = dol.deliveryOrderId | |||
| ON dol.deliveryOrderId = do.id | |||
| AND dol.itemId = pol.itemId | |||
| AND dol.deleted = 0 | |||
| LEFT JOIN items it | |||
| ON dol.itemId = it.id | |||
| @@ -215,10 +210,6 @@ FORMAT(ROUND(IFNULL(IFNULL(sol.qty, dol.qty), 0), 0), 0) AS qty, | |||
| AND iu.stockUnit = 1 | |||
| LEFT JOIN uom_conversion uc | |||
| ON iu.uomId = uc.id | |||
| LEFT JOIN pick_order_line pol | |||
| ON dpolr.pick_order_id = pol.poId | |||
| AND pol.itemId = it.id | |||
| AND pol.deleted = 0 | |||
| LEFT JOIN stock_out_line sol | |||
| ON pol.id = sol.pickOrderLineId | |||
| AND sol.itemId = it.id | |||
| @@ -234,8 +225,8 @@ FORMAT(ROUND(IFNULL(IFNULL(sol.qty, dol.qty), 0), 0), 0) AS qty, | |||
| ON il.stockInLineId = sil.id | |||
| AND sil.deleted = 0 | |||
| WHERE | |||
| dpolr.deleted = 0 | |||
| AND (dpor.id IS NULL OR dpor.ticket_status = 'completed') | |||
| dopo.deleted = 0 | |||
| AND dopo.ticketStatus = 'completed' | |||
| AND COALESCE(sol.qty, dol.qty, 0) <> 0 | |||
| $stockCategorySql | |||
| $stockSubCategorySql | |||
| @@ -258,13 +249,23 @@ return result | |||
| fun getDistinctHandlersForFGStockOutTraceability(): List<String> { | |||
| val sql = """ | |||
| SELECT DISTINCT COALESCE(picker_user.name, modified_user.name, '') AS handler | |||
| FROM stock_out_line sol | |||
| INNER JOIN stock_out so ON sol.stockOutId = so.id AND so.deleted = 0 AND so.type = 'do' | |||
| LEFT JOIN user picker_user ON sol.handled_by = picker_user.id AND picker_user.deleted = 0 | |||
| LEFT JOIN user modified_user ON sol.modifiedBy = modified_user.staffNo AND modified_user.deleted = 0 AND sol.handled_by IS NULL | |||
| WHERE sol.deleted = 0 | |||
| ORDER BY handler | |||
| SELECT DISTINCT h.handler | |||
| FROM ( | |||
| SELECT TRIM(COALESCE(picker_user.name, modified_user.name, '')) AS handler | |||
| FROM stock_out_line sol | |||
| INNER JOIN stock_out so ON sol.stockOutId = so.id AND so.deleted = 0 AND so.type = 'do' | |||
| LEFT JOIN user picker_user ON sol.handled_by = picker_user.id AND picker_user.deleted = 0 | |||
| LEFT JOIN user modified_user ON sol.modifiedBy = modified_user.staffNo AND modified_user.deleted = 0 AND sol.handled_by IS NULL | |||
| WHERE sol.deleted = 0 | |||
| UNION | |||
| SELECT TRIM(IFNULL(handlerName, '')) AS handler | |||
| FROM delivery_order_pick_order | |||
| WHERE deleted = 0 | |||
| AND ticketStatus = 'completed' | |||
| AND IFNULL(handlerName, '') <> '' | |||
| ) h | |||
| WHERE TRIM(IFNULL(h.handler, '')) <> '' | |||
| ORDER BY h.handler | |||
| """.trimIndent() | |||
| return jdbcDao.queryForList(sql, emptyMap<String, Any>()).map { row -> (row["handler"]?.toString() ?: "").trim() }.filter { it.isNotBlank() } | |||
| } | |||
| @@ -5,6 +5,7 @@ import java.util.List; | |||
| import org.springframework.http.HttpStatus; | |||
| import org.springframework.web.bind.annotation.GetMapping; | |||
| import org.springframework.web.bind.annotation.PatchMapping; | |||
| import org.springframework.web.bind.annotation.PostMapping; | |||
| import org.springframework.web.bind.annotation.PathVariable; | |||
| import org.springframework.web.bind.annotation.RequestBody; | |||
| import org.springframework.web.bind.annotation.RequestMapping; | |||
| @@ -41,13 +42,24 @@ public class SettingsController{ | |||
| // @PreAuthorize("hasAuthority('ADMIN')") | |||
| @ResponseStatus(HttpStatus.NO_CONTENT) | |||
| public void update(@PathVariable String name, @RequestBody @Valid UpdateReq body) { | |||
| applyUpdate(name, body); | |||
| } | |||
| /** Same as PATCH; use from browsers where CORS preflight for PATCH is blocked. */ | |||
| @PostMapping("/{name}") | |||
| @ResponseStatus(HttpStatus.NO_CONTENT) | |||
| public void updatePost(@PathVariable String name, @RequestBody @Valid UpdateReq body) { | |||
| applyUpdate(name, body); | |||
| } | |||
| private void applyUpdate(String name, UpdateReq body) { | |||
| Settings entity = this.settingsService.findByName(name) | |||
| .orElseThrow(NotFoundException::new); | |||
| if (!this.settingsService.validateType(entity.getType(), body.value)) { | |||
| if (!this.settingsService.validateType(entity.getType(), body.getValue())) { | |||
| throw new BadRequestException(); | |||
| } | |||
| entity.setValue(body.value); | |||
| entity.setValue(body.getValue()); | |||
| this.settingsService.save(entity); | |||
| } | |||
| @@ -14,11 +14,22 @@ import java.util.Optional | |||
| interface InventoryRepository: AbstractRepository<Inventory, Long> { | |||
| fun findInventoryInfoByDeletedIsFalse(): List<InventoryInfo> | |||
| @Query("SELECT i FROM Inventory i " + | |||
| "WHERE (:code IS NULL OR i.item.code LIKE CONCAT('%', :code, '%')) " + | |||
| "AND (:name IS NULL OR i.item.name LIKE CONCAT('%', :name, '%')) " + | |||
| "AND (:type IS NULL OR :type = '' OR i.item.type = :type) " + | |||
| "AND i.deleted = false") | |||
| @Query( | |||
| """ | |||
| SELECT i FROM Inventory i | |||
| WHERE (:code IS NULL OR i.item.code LIKE CONCAT('%', :code, '%')) | |||
| AND (:name IS NULL OR i.item.name LIKE CONCAT('%', :name, '%')) | |||
| AND (:type IS NULL OR :type = '' OR i.item.type = :type) | |||
| AND i.deleted = false | |||
| AND EXISTS ( | |||
| SELECT 1 FROM ItemUom iu | |||
| WHERE iu.item.id = i.item.id | |||
| AND iu.deleted = false | |||
| AND iu.baseUnit = true | |||
| AND iu.uom.id = i.uom.id | |||
| ) | |||
| """ | |||
| ) | |||
| fun findInventoryInfoByItemCodeContainsAndItemNameContainsAndItemTypeAndDeletedIsFalse(code: String, name: String, type: String, pageable: Pageable): Page<InventoryInfo> | |||
| @Query("SELECT i FROM Inventory i " + | |||
| @@ -1,11 +1,12 @@ | |||
| package com.ffii.fpsms.modules.stock.entity | |||
| import com.ffii.core.support.AbstractRepository | |||
| import org.springframework.data.domain.Page | |||
| import org.springframework.data.domain.Pageable | |||
| import org.springframework.data.jpa.repository.Query | |||
| import org.springframework.data.repository.query.Param | |||
| import org.springframework.stereotype.Repository | |||
| import java.time.LocalDate | |||
| import java.util.Optional | |||
| import java.time.LocalDateTime | |||
| @Repository | |||
| interface StockLedgerRepository: AbstractRepository<StockLedger, Long> { | |||
| @@ -19,17 +20,17 @@ interface StockLedgerRepository: AbstractRepository<StockLedger, Long> { | |||
| AND (:itemCode IS NULL OR sl.itemCode LIKE CONCAT('%', :itemCode, '%')) | |||
| AND (:itemName IS NULL OR i.name LIKE CONCAT('%', :itemName, '%')) | |||
| AND (:type IS NULL OR sl.type = :type) | |||
| AND (:startDate IS NULL OR DATE(sl.created) >= :startDate) | |||
| AND (:endDate IS NULL OR DATE(sl.created) <= :endDate) | |||
| ORDER BY sl.created ASC, sl.itemId | |||
| AND (:startDateTime IS NULL OR sl.created >= :startDateTime) | |||
| AND (:endDateExclusive IS NULL OR sl.created < :endDateExclusive) | |||
| """) | |||
| fun findStockTransactions( | |||
| @Param("itemCode") itemCode: String?, | |||
| @Param("itemName") itemName: String?, | |||
| @Param("type") type: String?, | |||
| @Param("startDate") startDate: LocalDate?, | |||
| @Param("endDate") endDate: LocalDate? | |||
| ): List<StockLedger> | |||
| @Param("startDateTime") startDateTime: LocalDateTime?, | |||
| @Param("endDateExclusive") endDateExclusive: LocalDateTime?, | |||
| pageable: Pageable | |||
| ): Page<StockLedger> | |||
| @Query(""" | |||
| SELECT COUNT(sl) FROM StockLedger sl | |||
| @@ -39,15 +40,15 @@ interface StockLedgerRepository: AbstractRepository<StockLedger, Long> { | |||
| AND (:itemCode IS NULL OR sl.itemCode LIKE CONCAT('%', :itemCode, '%')) | |||
| AND (:itemName IS NULL OR i.name LIKE CONCAT('%', :itemName, '%')) | |||
| AND (:type IS NULL OR sl.type = :type) | |||
| AND (:startDate IS NULL OR DATE(sl.created) >= :startDate) | |||
| AND (:endDate IS NULL OR DATE(sl.created) <= :endDate) | |||
| AND (:startDateTime IS NULL OR sl.created >= :startDateTime) | |||
| AND (:endDateExclusive IS NULL OR sl.created < :endDateExclusive) | |||
| """) | |||
| fun countStockTransactions( | |||
| @Param("itemCode") itemCode: String?, | |||
| @Param("itemName") itemName: String?, | |||
| @Param("type") type: String?, | |||
| @Param("startDate") startDate: LocalDate?, | |||
| @Param("endDate") endDate: LocalDate? | |||
| @Param("startDateTime") startDateTime: LocalDateTime?, | |||
| @Param("endDateExclusive") endDateExclusive: LocalDateTime? | |||
| ): Long | |||
| @@ -16,6 +16,7 @@ import java.time.LocalDateTime | |||
| import java.math.BigDecimal | |||
| import com.ffii.fpsms.modules.user.entity.UserRepository | |||
| import org.springframework.data.domain.PageRequest | |||
| import org.springframework.data.domain.Sort | |||
| import com.ffii.core.response.RecordsRes | |||
| import com.ffii.fpsms.modules.stock.service.InventoryLotLineService | |||
| import com.ffii.fpsms.modules.stock.entity.StockTakeLine | |||
| @@ -2741,40 +2742,32 @@ open fun searchStockTransactions(request: SearchStockTransactionRequest): Record | |||
| return RecordsRes(emptyList(), 0) | |||
| } | |||
| val startDate = request.startDate | |||
| val endDate = request.endDate | |||
| val startDateTime = request.startDate?.atStartOfDay() | |||
| val endDateExclusive = request.endDate?.plusDays(1)?.atStartOfDay() | |||
| println("Processed params: itemCode=$itemCode, itemName=$itemName, startDate=$startDate, endDate=$endDate") | |||
| val total = stockLedgerRepository.countStockTransactions( | |||
| itemCode = itemCode, | |||
| itemName = itemName, | |||
| type = request.type, | |||
| startDate = startDate, | |||
| endDate = endDate | |||
| println( | |||
| "Processed params: itemCode=$itemCode, itemName=$itemName, " + | |||
| "startDateTime=$startDateTime, endDateExclusive=$endDateExclusive" | |||
| ) | |||
| println("Total count: $total") | |||
| val actualPageSize = if (request.pageSize == 100) { | |||
| total.toInt().coerceAtLeast(1) | |||
| } else { | |||
| request.pageSize | |||
| } | |||
| val offset = request.pageNum * actualPageSize | |||
| val pageable = PageRequest.of( | |||
| request.pageNum.coerceAtLeast(0), | |||
| request.pageSize.coerceAtLeast(1), | |||
| Sort.by(Sort.Order.asc("created"), Sort.Order.asc("itemId")) | |||
| ) | |||
| val ledgers = stockLedgerRepository.findStockTransactions( | |||
| val ledgerPage = stockLedgerRepository.findStockTransactions( | |||
| itemCode = itemCode, | |||
| itemName = itemName, | |||
| type = request.type, | |||
| startDate = startDate, | |||
| endDate = endDate | |||
| startDateTime = startDateTime, | |||
| endDateExclusive = endDateExclusive, | |||
| pageable = pageable | |||
| ) | |||
| println("Found ${ledgers.size} ledgers") | |||
| println("Found ${ledgerPage.numberOfElements} ledgers in current page, total=${ledgerPage.totalElements}") | |||
| val transactions = ledgers.map { ledger -> | |||
| val transactions = ledgerPage.content.map { ledger -> | |||
| val stockInLine = ledger.stockInLine | |||
| val stockOutLine = ledger.stockOutLine | |||
| @@ -2805,17 +2798,9 @@ open fun searchStockTransactions(request: SearchStockTransactionRequest): Record | |||
| ) | |||
| } | |||
| val sortedTransactions = transactions.sortedWith( | |||
| compareBy<StockTransactionResponse>( | |||
| { it.date ?: it.transactionDate?.toLocalDate() }, | |||
| { it.transactionDate } | |||
| ) | |||
| ) | |||
| val paginatedTransactions = sortedTransactions.drop(offset).take(actualPageSize) | |||
| val totalTime = System.currentTimeMillis() - startTime | |||
| println("Total time (Repository query): ${totalTime}ms, count: ${paginatedTransactions.size}, total: $total") | |||
| println("Total time (Repository query): ${totalTime}ms, count: ${transactions.size}, total: ${ledgerPage.totalElements}") | |||
| return RecordsRes(paginatedTransactions, total.toInt()) | |||
| return RecordsRes(transactions, ledgerPage.totalElements.toInt()) | |||
| } | |||
| } | |||
| @@ -42,6 +42,7 @@ import com.ffii.fpsms.modules.stock.entity.projection.StockOutLineInfo | |||
| import com.ffii.fpsms.modules.stock.entity.StockOutLIneRepository | |||
| import com.ffii.fpsms.modules.stock.web.model.StockOutStatus | |||
| import com.ffii.fpsms.modules.common.SecurityUtils | |||
| import com.ffii.fpsms.modules.deliveryOrder.service.DoFloorSupplierSettingsService | |||
| @Service | |||
| open class SuggestedPickLotService( | |||
| val suggestedPickLotRepository: SuggestPickLotRepository, | |||
| @@ -57,7 +58,8 @@ open class SuggestedPickLotService( | |||
| val failInventoryLotLineRepository: FailInventoryLotLineRepository, | |||
| val stockOutRepository: StockOutRepository, | |||
| val itemRepository: ItemsRepository, | |||
| val stockOutLineRepository: StockOutLIneRepository | |||
| val stockOutLineRepository: StockOutLIneRepository, | |||
| private val doFloorSupplierSettingsService: DoFloorSupplierSettingsService, | |||
| ) { | |||
| // Calculation Available Qty / Remaining Qty | |||
| @@ -114,6 +116,8 @@ open class SuggestedPickLotService( | |||
| .filter { it.expiryDate.isAfter(today) || it.expiryDate.isEqual(today)} | |||
| .sortedBy { it.expiryDate } | |||
| .groupBy { it.item?.id } | |||
| val (floorSuppliers2F, floorSuppliers4F) = doFloorSupplierSettingsService.loadDoFloorSupplierLists() | |||
| // loop for suggest pick lot line | |||
| pols.forEach { line -> | |||
| @@ -126,11 +130,11 @@ open class SuggestedPickLotService( | |||
| val doPreferredFloor: String? = if (isDoPickOrder) { | |||
| val supplierCode = pickOrder?.deliveryOrder?.supplier?.code | |||
| when (supplierCode) { | |||
| "P06B" -> "4F" | |||
| "P07", "P06D" -> "2F" | |||
| else -> null // 其他供应商不限定 2F/4F | |||
| } | |||
| doFloorSupplierSettingsService.preferredFloorForPickLotOrNull( | |||
| supplierCode, | |||
| floorSuppliers2F, | |||
| floorSuppliers4F, | |||
| ) | |||
| } else { | |||
| null | |||
| } | |||
| @@ -32,7 +32,8 @@ class StockTakeRecordController( | |||
| @RequestParam(required = false) stockTakeSections: String?, | |||
| @RequestParam(required = false) status: String?, | |||
| @RequestParam(required = false) area: String?, | |||
| @RequestParam(required = false) storeId: String? | |||
| @RequestParam(required = false) storeId: String?, | |||
| @RequestParam(required = false, defaultValue = "false") onlyLatestRound: Boolean | |||
| ): RecordsRes<AllPickedStockTakeListReponse> { | |||
| var all = stockOutRecordService.AllPickedStockTakeList() | |||
| if (sectionDescription != null && sectionDescription != "All") { | |||
| @@ -71,6 +72,18 @@ class StockTakeRecordController( | |||
| it.storeId?.contains(storeIdKeyword, ignoreCase = true) == true | |||
| } | |||
| } | |||
| if (onlyLatestRound) { | |||
| val latestRoundKey = all | |||
| .mapNotNull { item -> | |||
| item.stockTakeRoundId ?: item.stockTakeId.takeIf { it > 0 } | |||
| } | |||
| .maxOrNull() | |||
| all = if (latestRoundKey == null) { | |||
| emptyList() | |||
| } else { | |||
| all.filter { (it.stockTakeRoundId ?: it.stockTakeId) == latestRoundKey } | |||
| } | |||
| } | |||
| val total = all.size | |||
| val fromIndex = pageNum * pageSize | |||
| val toIndex = minOf(fromIndex + pageSize, total) | |||
| @@ -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; | |||
| import java.util.Date; | |||
| import java.util.LinkedHashMap; | |||
| import java.util.List; | |||
| import java.util.Map; | |||
| import java.util.stream.Collectors; | |||
| @@ -209,4 +210,13 @@ public class GroupService extends AbstractBaseEntityService<Group, Long, GroupRe | |||
| return jdbcDao.queryForList(sql.toString(), args); | |||
| } | |||
| @Transactional(rollbackFor = Exception.class) | |||
| public Map<Integer, List<Map<String, Object>>> listAuthForUsers(List<Integer> userIds) { | |||
| Map<Integer, List<Map<String, Object>>> result = new LinkedHashMap<>(); | |||
| for (Integer userId : userIds) { | |||
| result.put(userId, listAuth(Map.of("userId", userId))); | |||
| } | |||
| return result; | |||
| } | |||
| } | |||
| @@ -185,8 +185,8 @@ public class UserService extends AbstractBaseEntityService<User, Long, UserRepos | |||
| if (!authBatchDeleteValues.isEmpty()) { | |||
| jdbcDao.batchUpdate( | |||
| "DELETE FROM user_authority" | |||
| + " WHERE userId = :userId ", | |||
| // + "AND authId = :authId", | |||
| + " WHERE userId = :userId " | |||
| + " AND authId = :authId", | |||
| authBatchDeleteValues); | |||
| } | |||
| if (!authBatchInsertValues.isEmpty()) { | |||
| @@ -228,8 +228,8 @@ public class UserService extends AbstractBaseEntityService<User, Long, UserRepos | |||
| if (!authBatchDeleteValues.isEmpty()) { | |||
| jdbcDao.batchUpdate( | |||
| "DELETE FROM user_authority" | |||
| + " WHERE userId = :userId ", | |||
| // + "AND authId = :authId", | |||
| + " WHERE userId = :userId " | |||
| + " AND authId = :authId", | |||
| authBatchDeleteValues); | |||
| } | |||
| if (!authBatchInsertValues.isEmpty()) { | |||
| @@ -1,6 +1,7 @@ | |||
| package com.ffii.fpsms.modules.user.web; | |||
| import java.util.HashMap; | |||
| import java.util.List; | |||
| import java.util.Map; | |||
| import org.apache.commons.logging.Log; | |||
| @@ -11,6 +12,7 @@ import org.springframework.web.bind.annotation.DeleteMapping; | |||
| import org.springframework.web.bind.annotation.GetMapping; | |||
| import org.springframework.web.bind.annotation.PathVariable; | |||
| import org.springframework.web.bind.annotation.PostMapping; | |||
| import org.springframework.web.bind.annotation.RequestParam; | |||
| import org.springframework.web.bind.annotation.RequestBody; | |||
| import org.springframework.web.bind.annotation.RequestMapping; | |||
| import org.springframework.web.bind.annotation.ResponseStatus; | |||
| @@ -80,7 +82,6 @@ public class GroupController{ | |||
| @GetMapping("/auth/{target}/{id}") | |||
| public RecordsRes<Map<String, Object>> authComboJson(HttpServletRequest request, @PathVariable("id") int id, @PathVariable("target") String target) throws ServletRequestBindingException { | |||
| System.out.println(request); | |||
| Map<String, Object> args = new HashMap<>(); | |||
| if (id != 0){ | |||
| if (target.equals("group")){ | |||
| @@ -94,4 +95,11 @@ public class GroupController{ | |||
| return new RecordsRes<>(groupService.listAuth(args)); | |||
| } | |||
| @GetMapping("/auth/user-batch") | |||
| public Map<Integer, List<Map<String, Object>>> authBatchByUserIds( | |||
| @RequestParam("userIds") List<Integer> userIds | |||
| ) { | |||
| return groupService.listAuthForUsers(userIds); | |||
| } | |||
| } | |||
| @@ -78,7 +78,6 @@ public class UserController{ | |||
| @GetMapping | |||
| // @PreAuthorize("hasAuthority('VIEW_USER')") | |||
| public ResponseEntity<List<UserRecord>> list(@ModelAttribute @Valid SearchUserReq req) { | |||
| logger.info("Test List user"); | |||
| return ResponseEntity.ok(userService.search(req)); | |||
| } | |||
| @@ -120,13 +119,10 @@ public class UserController{ | |||
| @GetMapping("/{id}") | |||
| @PreAuthorize("hasAuthority('VIEW_USER')") | |||
| public LoadUserRes load(@PathVariable long id) { | |||
| LoadUserRes test = new LoadUserRes( | |||
| return new LoadUserRes( | |||
| userService.find(id).orElseThrow(NotFoundException::new), | |||
| userService.listUserAuthId(id), | |||
| userService.listUserGroupId(id)); | |||
| logger.info("Test List user2"); | |||
| logger.info(test); | |||
| return test; | |||
| } | |||
| @GetMapping("/user-info/{id}") | |||
| // @PreAuthorize("hasAuthority('VIEW_USER')") | |||
| @@ -147,7 +143,6 @@ public class UserController{ | |||
| // @ResponseStatus(HttpStatus.CREATED) | |||
| // @PreAuthorize("hasAuthority('MAINTAIN_USER')") | |||
| public IdRes newRecord(@RequestBody @Valid NewUserReq req) throws UnsupportedEncodingException { | |||
| System.out.println(req.getUsername()); | |||
| return new IdRes(userService.newRecord(req).getId()); | |||
| } | |||
| @@ -27,6 +27,10 @@ scheduler: | |||
| syncOffsetDays: 0 | |||
| inventoryLotExpiry: | |||
| enabled: true | |||
| # Job order: at 00:00:15 daily, process JOs whose planStart was yesterday (hide or reschedule). | |||
| jo: | |||
| planStart: | |||
| enabled: true | |||
| # Nav: PO stock_in_line pending/receiving within last N days (see ProductProcessService for 工單 QC/上架:今日+昨日). | |||
| fpsms: | |||
| @@ -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 | |||
| PatternLayout: | |||
| Pattern: "%d %p [%l] - %m%n" | |||
| charset: UTF-8 | |||
| Policies: | |||
| SizeBasedTriggeringPolicy: | |||
| size: 4096KB | |||
| @@ -11,6 +11,7 @@ Configutation: | |||
| filePattern: ${log_location}fpsms-all.log.%i.gz | |||
| PatternLayout: | |||
| Pattern: "%d %p [%l] - %m%n" | |||
| charset: UTF-8 | |||
| Policies: | |||
| SizeBasedTriggeringPolicy: | |||
| size: 4096KB | |||
| @@ -10,6 +10,7 @@ Configutation: | |||
| target: SYSTEM_OUT | |||
| PatternLayout: | |||
| pattern: ${log_pattern} | |||
| charset: UTF-8 | |||
| Loggers: | |||
| Root: | |||
| level: info | |||