diff --git a/.gitignore b/.gitignore index 28da35d..f0ac852 100644 --- a/.gitignore +++ b/.gitignore @@ -37,4 +37,5 @@ out/ .vscode/ package-lock.json python/Bag3.spec -python/dist/Bag3.exe +python/dist + diff --git a/python/Bag3.py b/python/Bag3.py index 63b5d83..887c401 100644 --- a/python/Bag3.py +++ b/python/Bag3.py @@ -16,6 +16,7 @@ Bag2 is kept as a separate legacy v2.x line; do not assume Bag2 matches Bag3. Run: python Bag3.py """ +import errno import json import os import select @@ -344,6 +345,25 @@ DATAFLEX_UI_PROGRESS_EVERY = max( DATAFLEX_SINGLE_TCP_JOB = _dataflex_bool_env( "FPSMS_DATAFLEX_SINGLE_TCP_JOB", False ) +# Link-OS SGD: raw-ZPL job shows this host id instead of a generic name (e.g. "ZPL. EMULATION"). +# Same id when the same job order is printed again. Disable: FPSMS_DATAFLEX_HOST_IDENTIFICATION_SGD=0 +DATAFLEX_HOST_IDENTIFICATION_SGD = _dataflex_bool_env( + "FPSMS_DATAFLEX_HOST_IDENTIFICATION_SGD", True +) +# Bag ZPL size (dots). ^PW700 matched little content (mostly vertical ^A@R), so previews showed a wide strip with empty right margin. +DATAFLEX_LABEL_PW = max( + 280, + _dataflex_int_env("FPSMS_DATAFLEX_LABEL_PW", 400), +) +DATAFLEX_LABEL_LL = max( + 200, + _dataflex_int_env("FPSMS_DATAFLEX_LABEL_LL", 500), +) +# Some Zebra/DataFlex units RST the socket on host half-close; Windows surfaces WinError 10054. +# Set FPSMS_DATAFLEX_SKIP_SHUTDOWN_WR=1 to omit shutdown(SHUT_WR) and only close() (often avoids RST). +DATAFLEX_SKIP_SHUTDOWN_WR = _dataflex_bool_env( + "FPSMS_DATAFLEX_SKIP_SHUTDOWN_WR", False +) # Full recovery (~JR soft reset) — used by「打袋重設」only; longer delay for firmware DATAFLEX_POST_FULL_RECOVERY_DELAY_SEC = 1.2 # Zebra ~RO only (used when FPSMS_DATAFLEX_NO_JR is set for full recovery) @@ -364,12 +384,56 @@ def _zpl_escape(s: str) -> str: return s.replace("\\", "\\\\").replace("^", "\\^") +def _dataflex_host_identification_sgd_prefix(job_order_id: Optional[int]) -> str: + """ + Optional ASCII prefix before ^XA: set zpl.host_identification so the printer lists the job + under the job order id instead of a generic raw-ZPL label. + """ + if not DATAFLEX_HOST_IDENTIFICATION_SGD or job_order_id is None: + return "" + try: + jid = str(int(job_order_id)) + except (TypeError, ValueError): + return "" + if not jid.isdigit(): + return "" + return f'! U1 setvar "zpl.host_identification" "{jid}"\r\n' + + def _dataflex_zpl_bytes(zpl: str) -> bytes: """UTF-8 ZPL with one trailing CRLF so the printer sees a clear job boundary.""" s = (zpl or "").rstrip("\r\n") return (s + "\r\n").encode("utf-8") +def _dataflex_is_benign_tcp_reset(err: BaseException) -> bool: + """True when peer closed with RST/FIN in a way that is normal for raw printer TCP (Windows 10054).""" + if isinstance(err, (BrokenPipeError, ConnectionResetError, ConnectionAbortedError)): + return True + if isinstance(err, OSError): + if getattr(err, "winerror", None) == 10054: # WSAECONNRESET + return True + if err.errno in ( + errno.ECONNRESET, + errno.EPIPE, + errno.ECONNABORTED, + ): + return True + return False + + +def _dataflex_shutdown_write_maybe(sock: socket.socket) -> None: + """Half-close write side; ignore printer RST (common after ZPL on port 9100-style links).""" + if DATAFLEX_SKIP_SHUTDOWN_WR: + return + try: + sock.shutdown(socket.SHUT_WR) + except OSError as e: + if _dataflex_is_benign_tcp_reset(e): + return + raise + + def generate_zpl_dataflex( batch_no: str, item_code: str, @@ -377,6 +441,7 @@ def generate_zpl_dataflex( item_id: Optional[int] = None, stock_in_line_id: Optional[int] = None, lot_no: Optional[str] = None, + job_order_id: Optional[int] = None, font_regular: str = "E:STXihei.ttf", font_bold: str = "E:STXihei.ttf", ) -> str: @@ -398,11 +463,12 @@ def generate_zpl_dataflex( qr_value = _zpl_escape(qr_payload) # Explicit ^PQ1: each ^XA…^XZ is exactly one bag. Avoids E1005 "over quantity" on some Zebra/DataFlex # firmware when many labels are sent on one TCP session without a per-job quantity. - return f"""^XA + host_id = _dataflex_host_identification_sgd_prefix(job_order_id) + return host_id + f"""^XA ^PQ1,0,1,N ^CI28 -^PW700 -^LL500 +^PW{DATAFLEX_LABEL_PW} +^LL{DATAFLEX_LABEL_LL} ^PO N ^FO10,20 ^BQN,2,4^FDQA,{qr_value}^FS @@ -447,10 +513,7 @@ def send_dataflex_preprint_reset(ip: str, port: int, *, force: bool = False) -> sock.connect((ip, port)) sock.sendall(DATAFLEX_PREPRINT_BYTES) time.sleep(DATAFLEX_POST_PREPRINT_DELAY_SEC) - try: - sock.shutdown(socket.SHUT_WR) - except OSError: - pass + _dataflex_shutdown_write_maybe(sock) finally: sock.close() @@ -472,10 +535,7 @@ def send_dataflex_job_counter_reset(ip: str, port: int, *, force: bool = False) sock.connect((ip, port)) sock.sendall(_dataflex_full_recovery_payload()) time.sleep(DATAFLEX_POST_FULL_RECOVERY_DELAY_SEC) - try: - sock.shutdown(socket.SHUT_WR) - except OSError: - pass + _dataflex_shutdown_write_maybe(sock) finally: sock.close() @@ -527,10 +587,7 @@ def send_dataflex_reset_and_labels( time.sleep(DATAFLEX_POST_LABEL_SETTLE_SEC) if i < copies - 1: time.sleep(delay_sec) - try: - sock.shutdown(socket.SHUT_WR) - except OSError: - pass + _dataflex_shutdown_write_maybe(sock) finally: sock.close() @@ -879,10 +936,7 @@ def send_zpl_to_dataflex(ip: str, port: int, zpl: str) -> None: sock.connect((ip, port)) sock.sendall(_dataflex_zpl_bytes(zpl)) time.sleep(DATAFLEX_POST_LABEL_SETTLE_SEC) - try: - sock.shutdown(socket.SHUT_WR) - except OSError: - pass + _dataflex_shutdown_write_maybe(sock) finally: sock.close() @@ -907,6 +961,10 @@ def query_dataflex_host_status(ip: str, port: int) -> str: data = sock.recv(4096) except socket.timeout: break + except OSError as ex: + if _dataflex_is_benign_tcp_reset(ex): + break + raise if not data: break chunks.append(data) @@ -2451,6 +2509,7 @@ def main() -> None: item_id=item_id, stock_in_line_id=stock_in_line_id, lot_no=lot_no, + job_order_id=j.get("id"), ) label_text = (lot_no or b).strip() if continuous: diff --git a/python/__pycache__/Bag3.cpython-313.pyc b/python/__pycache__/Bag3.cpython-313.pyc new file mode 100644 index 0000000..a01ec70 Binary files /dev/null and b/python/__pycache__/Bag3.cpython-313.pyc differ diff --git a/src/main/java/com/ffii/fpsms/m18/entity/M18BomShopSyncLog.kt b/src/main/java/com/ffii/fpsms/m18/entity/M18BomShopSyncLog.kt new file mode 100644 index 0000000..64578c4 --- /dev/null +++ b/src/main/java/com/ffii/fpsms/m18/entity/M18BomShopSyncLog.kt @@ -0,0 +1,48 @@ +package com.ffii.fpsms.m18.entity + +import com.ffii.core.entity.BaseEntity +import jakarta.persistence.Column +import jakarta.persistence.Entity +import jakarta.persistence.Table +import jakarta.validation.constraints.NotNull + +/** + * Audit log for FPSMS → M18 udfBomForShop sync (request / response bodies). + */ +@Entity +@Table(name = "m18_bom_shop_sync_log") +open class M18BomShopSyncLog : BaseEntity() { + + @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 +} diff --git a/src/main/java/com/ffii/fpsms/m18/entity/M18BomShopSyncLogRepository.kt b/src/main/java/com/ffii/fpsms/m18/entity/M18BomShopSyncLogRepository.kt new file mode 100644 index 0000000..ee1b83a --- /dev/null +++ b/src/main/java/com/ffii/fpsms/m18/entity/M18BomShopSyncLogRepository.kt @@ -0,0 +1,12 @@ +package com.ffii.fpsms.m18.entity + +import com.ffii.core.support.AbstractRepository + +interface M18BomShopSyncLogRepository : AbstractRepository { + fun findFirstByBomIdOrderByIdDesc(bomId: Long): M18BomShopSyncLog? + + fun findTop100ByBomIdOrderByIdDesc(bomId: Long): List + + /** Successful M18 udfBomForShop saves only — used for `BOM{item}Vnnn` version allocation. */ + fun findTop100ByBomIdAndSyncedIsTrueOrderByIdDesc(bomId: Long): List +} diff --git a/src/main/java/com/ffii/fpsms/m18/model/M18BomForShopSaveRequest.kt b/src/main/java/com/ffii/fpsms/m18/model/M18BomForShopSaveRequest.kt new file mode 100644 index 0000000..6dfe3bc --- /dev/null +++ b/src/main/java/com/ffii/fpsms/m18/model/M18BomForShopSaveRequest.kt @@ -0,0 +1,96 @@ +package com.ffii.fpsms.m18.model + +import com.fasterxml.jackson.annotation.JsonInclude +import com.fasterxml.jackson.annotation.JsonProperty + +/** + * M18 save payload for Shop BOM (udfBomForShop). + * PUT /root/api/save/udfbomforshop?menuCode=udfbomforshop + * + * Same idea as GRN (`mainan` + `ant`): header and lines each wrapped as `{ "values": [ ... ] }`. + * Root keys: **`udfbomforshop`** and **`udfproduct`** (same as M18 read [M18BomData]). + * (Spelling is **udf**, not "uni".) + */ +@JsonInclude(JsonInclude.Include.NON_NULL) +data class M18BomForShopSaveRequest( + @JsonProperty("udfbomforshop") + val udfbomforshop: M18MainUdfBomForShopWrapper, + @JsonProperty("udfproduct") + val udfproduct: M18UdfProductWrapper, +) + +@JsonInclude(JsonInclude.Include.NON_NULL) +data class M18MainUdfBomForShopWrapper( + val values: List, +) + +/** + * 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, +) + +/** + * 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, +) diff --git a/src/main/java/com/ffii/fpsms/m18/model/M18BomShopSyncTriggerResult.kt b/src/main/java/com/ffii/fpsms/m18/model/M18BomShopSyncTriggerResult.kt new file mode 100644 index 0000000..8f78e08 --- /dev/null +++ b/src/main/java/com/ffii/fpsms/m18/model/M18BomShopSyncTriggerResult.kt @@ -0,0 +1,14 @@ +package com.ffii.fpsms.m18.model + +/** + * Result of [com.ffii.fpsms.modules.master.service.BomService.pushBomToM18ShopIfAllowed] + * (e.g. POST /m18/test/bom-shop-sync/{bomId}). + */ +data class M18BomShopSyncTriggerResult( + val bomId: Long, + val synced: Boolean, + val skippedReason: String? = null, + val recordId: Long? = null, + val status: Boolean? = null, + val messageSummary: String? = null, +) diff --git a/src/main/java/com/ffii/fpsms/m18/service/M18BomForShopService.kt b/src/main/java/com/ffii/fpsms/m18/service/M18BomForShopService.kt new file mode 100644 index 0000000..f74dd70 --- /dev/null +++ b/src/main/java/com/ffii/fpsms/m18/service/M18BomForShopService.kt @@ -0,0 +1,394 @@ +package com.ffii.fpsms.m18.service + +import com.fasterxml.jackson.core.JsonGenerator +import com.fasterxml.jackson.databind.ObjectMapper +import com.fasterxml.jackson.module.kotlin.jacksonObjectMapper +import com.ffii.fpsms.api.service.ApiCallerService +import com.ffii.fpsms.m18.M18Config +import com.ffii.fpsms.m18.entity.M18BomShopSyncLog +import com.ffii.fpsms.m18.entity.M18BomShopSyncLogRepository +import com.ffii.fpsms.m18.model.GoodsReceiptNoteResponse +import com.ffii.fpsms.m18.model.M18BomForShopSaveRequest +import com.ffii.fpsms.m18.model.M18MainUdfBomForShopValue +import com.ffii.fpsms.m18.model.M18MainUdfBomForShopWrapper +import com.ffii.fpsms.m18.model.M18UdfProductSaveValue +import com.ffii.fpsms.m18.model.M18UdfProductWrapper +import com.ffii.fpsms.modules.master.entity.Bom +import com.ffii.fpsms.modules.master.entity.BomMaterial +import com.ffii.fpsms.modules.master.service.ItemUomService +import com.ffii.fpsms.modules.purchaseOrder.entity.PurchaseOrderLineRepository +import org.slf4j.Logger +import org.slf4j.LoggerFactory +import org.springframework.data.domain.PageRequest +import org.springframework.stereotype.Service +import org.springframework.util.LinkedMultiValueMap +import reactor.core.publisher.Mono +import java.math.BigDecimal +import java.math.RoundingMode +import java.nio.charset.StandardCharsets +import java.security.MessageDigest +import java.time.ZoneId + +/** + * Push FPSMS BOM + materials to M18 udfBomForShop (similar to GRN save/an). + * PUT /root/api/save/udfbomforshop?menuCode=udfbomforshop + */ +@Service +open class M18BomForShopService( + private val m18Config: M18Config, + private val apiCallerService: ApiCallerService, + private val itemUomService: ItemUomService, + private val purchaseOrderLineRepository: PurchaseOrderLineRepository, + private val m18BomShopSyncLogRepository: M18BomShopSyncLogRepository, +) { + private val logger: Logger = LoggerFactory.getLogger(M18BomForShopService::class.java) + + private val savePath = "/root/api/save/udfbomforshop" + private val menuCode = "udfbomforshop" + + /** M18 business entity id for udfBomForShop header (`udfbomforshop.values[0].beId`). */ + private val bomShopMainBeId: Int = 29 + + /** + * Stock UOM `code` on the **BOM header item** (e.g. PACK2LB = prefix + pack multiple + unit suffix). + * [udfHarvest] = [Bom.outputQty] × middle number; [udfHarvestUnit] = trailing unit (e.g. LB). + */ + private val bomItemStockUomPackCodeRegex = Regex("^([A-Za-z]+)(\\d+)([A-Za-z]+)$") + + companion object { + private const val HARVEST_CALC_SCALE = 10 + private val m18Tz: ZoneId = ZoneId.of("Asia/Hong_Kong") + + private fun formatBomShopHeaderCode(itemCode: String, version: Int): String = + "BOM${itemCode}V${version.toString().padStart(3, '0')}" + } + + @Suppress("DEPRECATION") + private val objectMapper: ObjectMapper = jacksonObjectMapper().apply { + disable(JsonGenerator.Feature.ESCAPE_NON_ASCII) + } + + /** + * Stable hash of payload **excluding** M18 header `id`, `code`, and `rev` (so version bumps do not affect equality). + * Used with [M18BomShopSyncLog] to decide V000 vs V001+. + */ + open fun contentFingerprint(request: M18BomForShopSaveRequest): String { + val json = objectMapper.writeValueAsString(normalizedForFingerprint(request)) + return sha256Hex(json) + } + + private fun normalizedForFingerprint(request: M18BomForShopSaveRequest): M18BomForShopSaveRequest { + val v = request.udfbomforshop.values.firstOrNull() + ?: return request + val headerNorm = v.copy(id = null, code = null, rev = null) + val linesSorted = request.udfproduct.values.sortedWith( + compareBy({ it.itemNo }, { it.udfProduct }, { it.udfIngredients }), + ) + return M18BomForShopSaveRequest( + udfbomforshop = M18MainUdfBomForShopWrapper(values = listOf(headerNorm)), + udfproduct = M18UdfProductWrapper(values = linesSorted), + ) + } + + private fun sha256Hex(text: String): String { + val md = MessageDigest.getInstance("SHA-256") + val bytes = md.digest(text.toByteArray(StandardCharsets.UTF_8)) + return bytes.joinToString("") { "%02x".format(it) } + } + + /** + * Builds M18 save body from a persisted BOM (materials loaded). + * [headerM18IdOverride] optional M18 header record id when forcing update; skips version/fingerprint logic for **id** only, + * reuses latest logged [M18BomShopSyncLog.m18HeaderCode] when possible. + * Otherwise uses [Bom.m18Id] when the normalized payload matches the latest log; on content change, bumps `BOM{item}Vnnn`. + */ + open fun buildSaveRequest(bom: Bom, headerM18IdOverride: Long? = null): M18BomForShopSaveRequest? { + val bomId = bom.id ?: return null + val routingCode = bom.code ?: return null + val itemCode = bom.item?.code?.trim().orEmpty().ifEmpty { + logger.warn("[M18 BOM] bom.item.code missing; cannot build M18 BOM shop payload. bomId=$bomId") + return null + } + + val flowTypeId = resolveFlowTypeId(routingCode) + val udfUnit = bom.uom?.m18Id?.takeIf { it > 0 } ?: return null + val outputQty = bom.outputQty ?: BigDecimal.ZERO + val (udfHarvest, udfHarvestUnit) = resolveUdfHarvestFields(bom, outputQty) + val udfEffectiveDate = bom.created?.atZone(m18Tz)?.toInstant()?.toEpochMilli() + + val lines = bom.bomMaterials + .filter { it.deleted != true } + .sortedBy { it.id ?: 0L } + .mapIndexedNotNull { idx, mat -> toProductLine(mat, idx + 1) } + + if (lines.isEmpty()) { + logger.warn("[M18 BOM] BOM id=$bomId code=$routingCode has no materials; skipping M18 save") + return null + } + + val (headerCode, rev, headerM18IdForRequest) = resolveHeaderCodeAndM18Id( + bomId = bomId, + itemCode = itemCode, + lines = lines, + udfUnit = udfUnit, + udfHarvest = udfHarvest, + udfHarvestUnit = udfHarvestUnit, + udfEffectiveDate = udfEffectiveDate, + bomYield = bom.yield, + bomName = bom.name, + bomDescription = bom.description, + flowTypeId = flowTypeId, + headerM18IdOverride = headerM18IdOverride, + bomM18Id = bom.m18Id?.takeIf { it > 0 }, + ) + + val header = M18MainUdfBomForShopValue( + id = headerM18IdForRequest?.toString(), + code = headerCode, + beId = bomShopMainBeId, + desc = bom.name ?: bom.description, + descEn = bom.name ?: bom.description, + udfBomCode = itemCode, + rev = rev, + udfUnit = udfUnit, + udfHarvest = udfHarvest, + udfHarvestUnit = udfHarvestUnit, + udfEffectiveDate = udfEffectiveDate, + udfYieldratePP = bom.yield, + udftypeoffood = "半成品", + udfconfirmed = true, + staffId = 232, + flowTypeId = flowTypeId, + virDeptId = 117, + status = "Y", + ) + + logger.info( + "[M18 BOM] buildSaveRequest fpsmsBomId=$bomId routingCode=$routingCode itemCode=$itemCode headerCode=$headerCode " + + "mainM18Id=$headerM18IdForRequest (override=$headerM18IdOverride, bom.m18Id=${bom.m18Id})", + ) + + return M18BomForShopSaveRequest( + udfbomforshop = M18MainUdfBomForShopWrapper(values = listOf(header)), + udfproduct = M18UdfProductWrapper(values = lines), + ) + } + + @Suppress("LongParameterList") + private fun resolveHeaderCodeAndM18Id( + bomId: Long, + itemCode: String, + lines: List, + udfUnit: Long, + udfHarvest: String, + udfHarvestUnit: String?, + udfEffectiveDate: Long?, + bomYield: BigDecimal?, + bomName: String?, + bomDescription: String?, + flowTypeId: Int, + headerM18IdOverride: Long?, + bomM18Id: Long?, + ): Triple { + val draftHeader = M18MainUdfBomForShopValue( + id = null, + code = null, + beId = bomShopMainBeId, + desc = bomName ?: bomDescription, + descEn = bomName ?: bomDescription, + udfBomCode = itemCode, + rev = null, + udfUnit = udfUnit, + udfHarvest = udfHarvest, + udfHarvestUnit = udfHarvestUnit, + udfEffectiveDate = udfEffectiveDate, + udfYieldratePP = bomYield, + udftypeoffood = "半成品", + udfconfirmed = true, + staffId = 232, + flowTypeId = flowTypeId, + virDeptId = 117, + status = "Y", + ) + val draftRequest = M18BomForShopSaveRequest( + udfbomforshop = M18MainUdfBomForShopWrapper(values = listOf(draftHeader)), + udfproduct = M18UdfProductWrapper(values = lines), + ) + val fp = contentFingerprint(draftRequest) + + val forcedId = headerM18IdOverride?.takeIf { it > 0 } + if (forcedId != null) { + val latest = m18BomShopSyncLogRepository.findFirstByBomIdOrderByIdDesc(bomId) + val codeForUpdate = + latest?.m18HeaderCode?.takeIf { it.isNotBlank() } + ?: formatBomShopHeaderCode(itemCode, 0) + val forcedRev = parseTrailingVersion(codeForUpdate) ?: "000" + return Triple(codeForUpdate, forcedRev, forcedId) + } + + val latestLog = m18BomShopSyncLogRepository.findFirstByBomIdOrderByIdDesc(bomId) + val prevFp = resolveLogFingerprint(latestLog) + val prevCodeTrimmed = latestLog?.m18HeaderCode?.trim().orEmpty() + val samePayload = latestLog != null && prevFp != null && prevFp == fp && prevCodeTrimmed.isNotEmpty() + + if (samePayload) { + val revReuse = parseTrailingVersion(prevCodeTrimmed) ?: "000" + return Triple(prevCodeTrimmed, revReuse, bomM18Id) + } + + val maxV = maxVersionFromLogs(bomId, itemCode) + val nextV = maxV + 1 + val newCode = formatBomShopHeaderCode(itemCode, nextV) + val rev = nextV.toString().padStart(3, '0') + return Triple(newCode, rev, null) + } + + private fun resolveLogFingerprint(log: M18BomShopSyncLog?): String? { + if (log == null) return null + log.requestFingerprint?.takeIf { it.isNotBlank() }?.let { return it } + val json = log.requestJson ?: return null + return runCatching { + val prev = objectMapper.readValue(json, M18BomForShopSaveRequest::class.java) + contentFingerprint(prev) + }.getOrNull() + } + + private fun maxVersionFromLogs(bomId: Long, itemCode: String): Int { + val versionPat = Regex("^BOM${Regex.escape(itemCode)}V(\\d+)$") + // Only successful syncs advance the numeric tail; failed attempts log a code but must not consume Vnnn. + return m18BomShopSyncLogRepository.findTop100ByBomIdAndSyncedIsTrueOrderByIdDesc(bomId) + .mapNotNull { row -> + val c = row.m18HeaderCode?.trim().orEmpty().ifEmpty { + extractHeaderCodeFromJson(row.requestJson).orEmpty() + } + versionPat.find(c)?.groupValues?.get(1)?.toIntOrNull() + } + .maxOrNull() ?: -1 + } + + private fun extractHeaderCodeFromJson(json: String?): String? { + if (json.isNullOrBlank()) return null + return runCatching { + val node = objectMapper.readTree(json) + val text = node.path("udfbomforshop").path("values").path(0).path("code").asText() + text.trim().takeIf { it.isNotEmpty() } + }.getOrNull() + } + + private fun parseTrailingVersion(headerCode: String): String? = + Regex("V(\\d+)$").find(headerCode.trim())?.groupValues?.get(1)?.padStart(3, '0') + + /** + * From the **finished-good** [Bom.item] stock unit [com.ffii.fpsms.modules.master.entity.UomConversion.code] + * (pattern `LETTER_PREFIX` + `DIGITS` + `UNIT_SUFFIX`, e.g. PACK2LB): harvest qty = outputQty × digits, unit = suffix. + * Falls back to plain [outputQty] and null unit when item/stock UOM/code is missing or does not match. + */ + private fun resolveUdfHarvestFields(bom: Bom, outputQty: BigDecimal): Pair { + val itemId = bom.item?.id + if (itemId == null) { + logger.warn("[M18 BOM] bom.item id missing; udfHarvest=outputQty only. bomId=${bom.id}") + return outputQty.stripTrailingZeros().toPlainString() to null + } + val stockCode = itemUomService.findStockUnitByItemId(itemId)?.uom?.code?.trim().orEmpty() + if (stockCode.isEmpty()) { + logger.warn("[M18 BOM] stock UOM code missing for bom itemId=$itemId; udfHarvest=outputQty only. bomId=${bom.id}") + return outputQty.stripTrailingZeros().toPlainString() to null + } + val match = bomItemStockUomPackCodeRegex.matchEntire(stockCode) + if (match == null) { + logger.warn( + "[M18 BOM] stock UOM code '$stockCode' does not match PREFIX+NUMBER+SUFFIX; " + + "udfHarvest=outputQty only. bomId=${bom.id} itemId=$itemId", + ) + return outputQty.stripTrailingZeros().toPlainString() to null + } + val mult = match.groupValues[2].toBigDecimalOrNull() + if (mult == null || mult.compareTo(BigDecimal.ZERO) <= 0) { + logger.warn( + "[M18 BOM] invalid pack multiple in stock UOM code '$stockCode'; udfHarvest=outputQty only. bomId=${bom.id}", + ) + return outputQty.stripTrailingZeros().toPlainString() to null + } + val unitSuffix = match.groupValues[3] + val harvestQty = outputQty.multiply(mult).setScale(HARVEST_CALC_SCALE, RoundingMode.HALF_UP).stripTrailingZeros() + return harvestQty.toPlainString() to unitSuffix + } + + private fun toProductLine(mat: BomMaterial, lineNo: Int): M18UdfProductSaveValue? { + val proId = mat.item?.m18Id?.takeIf { it > 0 } ?: run { + logger.warn("[M18 BOM] material item m18Id missing bomMaterialId=${mat.id} itemId=${mat.item?.id}") + return null + } + val udfBaseUnit = mat.uom?.code?.trim()?.takeIf { it.isNotEmpty() } ?: run { + logger.warn("[M18 BOM] material UOM code missing bomMaterialId=${mat.id}") + return null + } + val 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 { + val queryParams = LinkedMultiValueMap().apply { + add("menuCode", menuCode) + } + val qs = queryParams.entries.flatMap { (k, v) -> v.map { "$k=$it" } }.joinToString("&") + val fullUrl = "${m18Config.BASE_URL}$savePath?$qs" + val bodyJson = objectMapper.writeValueAsString(request) + logger.info("[M18 BOM udfBomForShop] PUT url=$fullUrl bodyUtf8Bytes=${bodyJson.toByteArray(StandardCharsets.UTF_8).size}") + logger.debug("[M18 BOM udfBomForShop] PUT body=$bodyJson") + return apiCallerService.putWithJsonString( + urlPath = savePath, + queryParams = queryParams, + bodyJson = bodyJson, + ).doOnSuccess { r -> + logger.info("[M18 BOM udfBomForShop] response status=${r.status} recordId=${r.recordId} messages=${r.messages}") + }.doOnError { e -> + logger.error("[M18 BOM udfBomForShop] failed: ${e.message}", e) + } + } +} diff --git a/src/main/java/com/ffii/fpsms/m18/service/M18DeliveryOrderService.kt b/src/main/java/com/ffii/fpsms/m18/service/M18DeliveryOrderService.kt index 52c2576..08781e3 100644 --- a/src/main/java/com/ffii/fpsms/m18/service/M18DeliveryOrderService.kt +++ b/src/main/java/com/ffii/fpsms/m18/service/M18DeliveryOrderService.kt @@ -154,20 +154,20 @@ open class M18DeliveryOrderService( open fun saveDeliveryOrders(request: M18CommonRequest): SyncResult { val deliveryOrdersWithType = getDeliveryOrdersWithType(request) - return saveDeliveryOrdersWithPreparedList(deliveryOrdersWithType, syncIsEtra = false) + return saveDeliveryOrdersWithPreparedList(deliveryOrdersWithType, syncisExtra = false) } /** * Sync a single M18 shop PO / delivery order by document [code], same search pattern as * [com.ffii.fpsms.m18.service.M18PurchaseOrderService.savePurchaseOrderByCode]. * - * @param isEtraSync when true, persist local `delivery_order.isEtra=true` (manual DO(加單) sync). + * @param isExtraSync when true, persist local `delivery_order.isExtra=true` (manual DO(加單) sync). * No M18-side "加單" filtering is used. * @param newOnly when true, skip if a non-deleted local DO already exists with the same `code`. */ open fun saveDeliveryOrderByCode( code: String, - isEtraSync: Boolean = false, + isExtraSync: Boolean = false, newOnly: Boolean = false, ): SyncResult { if (newOnly && deliveryOrderRepository.existsByCodeAndDeletedIsFalse(code)) { @@ -210,12 +210,12 @@ open class M18DeliveryOrderService( query = conds ) - return saveDeliveryOrdersWithPreparedList(prepared, syncIsEtra = isEtraSync) + return saveDeliveryOrdersWithPreparedList(prepared, syncisExtra = isExtraSync) } private fun saveDeliveryOrdersWithPreparedList( deliveryOrdersWithType: M18PurchaseOrderListResponseWithType?, - syncIsEtra: Boolean = false, + syncisExtra: Boolean = false, ): SyncResult { logger.info("--------------------------------------------Start - Saving M18 Delivery Order--------------------------------------------") @@ -303,7 +303,7 @@ open class M18DeliveryOrderService( handlerId = null, m18BeId = mainpo.beId, deleted = mainpo.udfIsVoid == true, - isEtra = syncIsEtra, + isExtra = syncisExtra, ) val saveDeliveryOrderResponse = diff --git a/src/main/java/com/ffii/fpsms/m18/web/M18TestController.kt b/src/main/java/com/ffii/fpsms/m18/web/M18TestController.kt index 5fbafb9..2138251 100644 --- a/src/main/java/com/ffii/fpsms/m18/web/M18TestController.kt +++ b/src/main/java/com/ffii/fpsms/m18/web/M18TestController.kt @@ -4,8 +4,9 @@ import com.ffii.core.utils.JwtTokenUtil import com.ffii.fpsms.m18.M18Config import com.ffii.fpsms.m18.model.SyncResult import com.ffii.fpsms.m18.service.* +import com.ffii.fpsms.m18.model.M18BomShopSyncTriggerResult import com.ffii.fpsms.m18.web.models.M18CommonRequest -import com.ffii.fpsms.modules.common.SettingNames +import com.ffii.fpsms.modules.master.service.BomService import com.ffii.fpsms.modules.common.scheduler.service.SchedulerService import com.ffii.fpsms.modules.master.entity.ItemUom import com.ffii.fpsms.modules.master.entity.Items @@ -35,6 +36,7 @@ class M18TestController ( private val m18DeliveryOrderService: M18DeliveryOrderService, val schedulerService: SchedulerService, private val settingsService: SettingsService, + private val bomService: BomService, ) { var logger: Logger = LoggerFactory.getLogger(JwtTokenUtil::class.java) @@ -65,6 +67,14 @@ class M18TestController ( return schedulerService.getM18Pos(); } + @PostMapping("/test/bom-shop-sync/{bomId}") + fun testBomShopSync( + @PathVariable bomId: Long, + @RequestParam(required = false) m18HeaderId: Long?, + ): M18BomShopSyncTriggerResult { + return bomService.pushBomToM18ShopIfAllowed(bomId, m18HeaderId) + } + @GetMapping("/test/po-by-code") fun testSyncPoByCode(@RequestParam code: String): SyncResult { return m18PurchaseOrderService.savePurchaseOrderByCode(code) @@ -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") diff --git a/src/main/java/com/ffii/fpsms/modules/bag/service/bagService.kt b/src/main/java/com/ffii/fpsms/modules/bag/service/bagService.kt index af4b56d..dbe49c7 100644 --- a/src/main/java/com/ffii/fpsms/modules/bag/service/bagService.kt +++ b/src/main/java/com/ffii/fpsms/modules/bag/service/bagService.kt @@ -29,7 +29,7 @@ open class BagService( ) { open fun createBagLotLinesByBagId(request: CreateBagLotLineRequest): MessageResponse { val bag = bagRepository.findById(request.bagId).orElse(null) - val lot = inventoryLotRepository.findByLotNoAndItemId(request.lotNo, request.itemId) + val lot = inventoryLotRepository.findByIdAndDeletedFalse(request.lotId) val BaseUnitOfMeasure= itemUomRepository.findByItemIdAndStockUnitIsTrueAndDeletedIsFalse(request.itemId) val baseRatioN = BaseUnitOfMeasure?.ratioN ?: BigDecimal.ONE println("baseRatioN: $baseRatioN") diff --git a/src/main/java/com/ffii/fpsms/modules/chart/service/ChartService.kt b/src/main/java/com/ffii/fpsms/modules/chart/service/ChartService.kt index 73b3a2e..e46c5a4 100644 --- a/src/main/java/com/ffii/fpsms/modules/chart/service/ChartService.kt +++ b/src/main/java/com/ffii/fpsms/modules/chart/service/ChartService.kt @@ -40,27 +40,28 @@ open class ChartService( /** * Delivery orders: order count and total line qty by date. - * Uses delivery_order.completeDate or estimatedArrivalDate for date. + * X-axis date: [delivery_order.estimatedArrivalDate] only (no completeDate/orderDate fallback). + * Rows without estimatedArrivalDate are excluded. */ fun getDeliveryOrderByDate(startDate: LocalDate?, endDate: LocalDate?): List> { val args = mutableMapOf() val startSql = if (startDate != null) { args["startDate"] = startDate.toString() - "AND DATE(COALESCE(do.completeDate, do.estimatedArrivalDate, do.orderDate)) >= :startDate" + "AND DATE(do.estimatedArrivalDate) >= :startDate" } else "" val endSql = if (endDate != null) { args["endDate"] = endDate.toString() - "AND DATE(COALESCE(do.completeDate, do.estimatedArrivalDate, do.orderDate)) <= :endDate" + "AND DATE(do.estimatedArrivalDate) <= :endDate" } else "" val sql = """ SELECT - DATE_FORMAT(COALESCE(do.completeDate, do.estimatedArrivalDate, do.orderDate), '%Y-%m-%d') AS date, + DATE_FORMAT(do.estimatedArrivalDate, '%Y-%m-%d') AS date, COUNT(DISTINCT do.id) AS orderCount, COALESCE(SUM(dol.qty), 0) AS totalQty FROM delivery_order do LEFT JOIN delivery_order_line dol ON dol.deliveryOrderId = do.id AND dol.deleted = 0 - WHERE do.deleted = 0 $startSql $endSql - GROUP BY DATE(COALESCE(do.completeDate, do.estimatedArrivalDate, do.orderDate)) + WHERE do.deleted = 0 AND do.estimatedArrivalDate IS NOT NULL $startSql $endSql + GROUP BY DATE(do.estimatedArrivalDate) ORDER BY date """.trimIndent() return jdbcDao.queryForList(sql, args) @@ -529,17 +530,32 @@ open class ChartService( * Stock in vs stock out by date. * Stock in: stock_in_line.acceptedQty, date from stock_in.completeDate or receiptDate/created. * Stock out: stock_out_line.qty, date from stock_out.completeDate or created. + * + * Date range is applied inside each UNION branch (predicate pushdown) so we do not aggregate + * all history before filtering. Reads filtered headers first via STRAIGHT_JOIN (si/so then lines). */ fun getStockInOutByDate(startDate: LocalDate?, endDate: LocalDate?): List> { val args = mutableMapOf() - val startSql = if (startDate != null) { - args["startDate"] = startDate.toString() - "AND u.dt >= :startDate" - } else "" - val endSql = if (endDate != null) { - args["endDate"] = endDate.toString() - "AND u.dt <= :endDate" - } else "" + if (startDate != null) args["startDate"] = startDate.toString() + if (endDate != null) args["endDate"] = endDate.toString() + val inDateFilter = buildString { + if (startDate != null) { + append(" AND DATE(COALESCE(si.completeDate, sil.receiptDate, si.created)) >= :startDate") + } + if (endDate != null) { + append(" AND DATE(COALESCE(si.completeDate, sil.receiptDate, si.created)) <= :endDate") + } + } + val outDateFilter = buildString { + if (startDate != null) { + append(" AND DATE(COALESCE(so.completeDate, so.created)) >= :startDate") + } + if (endDate != null) { + append(" AND DATE(COALESCE(so.completeDate, so.created)) <= :endDate") + } + } + val startSql = if (startDate != null) "AND u.dt >= :startDate" else "" + val endSql = if (endDate != null) "AND u.dt <= :endDate" else "" val sql = """ SELECT DATE_FORMAT(u.dt, '%Y-%m-%d') AS date, COALESCE(SUM(u.inQty), 0) AS inQty, @@ -547,16 +563,16 @@ open class ChartService( FROM ( SELECT DATE(COALESCE(si.completeDate, sil.receiptDate, si.created)) AS dt, SUM(COALESCE(sil.acceptedQty, 0)) AS inQty, 0 AS outQty - FROM stock_in_line sil - INNER JOIN stock_in si ON sil.stockInId = si.id AND si.deleted = 0 - WHERE sil.deleted = 0 + FROM stock_in si + STRAIGHT_JOIN stock_in_line sil ON sil.stockInId = si.id AND sil.deleted = 0 + WHERE si.deleted = 0$inDateFilter GROUP BY DATE(COALESCE(si.completeDate, sil.receiptDate, si.created)) UNION ALL SELECT DATE(COALESCE(so.completeDate, so.created)) AS dt, 0 AS inQty, SUM(COALESCE(sol.qty, 0)) AS outQty - FROM stock_out_line sol - INNER JOIN stock_out so ON sol.stockOutId = so.id AND so.deleted = 0 - WHERE sol.deleted = 0 + FROM stock_out so + STRAIGHT_JOIN stock_out_line sol ON sol.stockOutId = so.id AND sol.deleted = 0 + WHERE so.deleted = 0$outDateFilter GROUP BY DATE(COALESCE(so.completeDate, so.created)) ) u WHERE 1=1 $startSql $endSql @@ -568,23 +584,25 @@ open class ChartService( /** * Distinct items that appear in delivery_order_line in the period (for multi-select options). + * Period filter: [delivery_order.estimatedArrivalDate] only; null ETA excluded. + * Uses STRAIGHT_JOIN so MySQL reads filtered `delivery_order` first (avoids full scan on `delivery_order_line`). */ fun getTopDeliveryItemsItemOptions(startDate: LocalDate?, endDate: LocalDate?): List> { val args = mutableMapOf() val startSql = if (startDate != null) { args["startDate"] = startDate.toString() - "AND DATE(COALESCE(do.completeDate, do.estimatedArrivalDate, do.orderDate)) >= :startDate" + "AND DATE(do.estimatedArrivalDate) >= :startDate" } else "" val endSql = if (endDate != null) { args["endDate"] = endDate.toString() - "AND DATE(COALESCE(do.completeDate, do.estimatedArrivalDate, do.orderDate)) <= :endDate" + "AND DATE(do.estimatedArrivalDate) <= :endDate" } else "" val sql = """ SELECT DISTINCT it.code AS itemCode, COALESCE(it.name, '') AS itemName - FROM delivery_order_line dol - INNER JOIN delivery_order do ON dol.deliveryOrderId = do.id AND do.deleted = 0 - INNER JOIN items it ON dol.itemId = it.id AND it.deleted = 0 - WHERE dol.deleted = 0 $startSql $endSql + FROM delivery_order do + STRAIGHT_JOIN delivery_order_line dol ON dol.deliveryOrderId = do.id AND dol.deleted = 0 + STRAIGHT_JOIN items it ON it.id = dol.itemId AND it.deleted = 0 + WHERE do.deleted = 0 AND do.estimatedArrivalDate IS NOT NULL $startSql $endSql ORDER BY it.code """.trimIndent() return jdbcDao.queryForList(sql, args) @@ -592,6 +610,8 @@ open class ChartService( /** * Top delivery items by total qty in the period. When itemCodes is non-empty, only those items (still ordered by totalQty, limit applied). + * Period filter: [delivery_order.estimatedArrivalDate] only; null ETA excluded. + * Uses STRAIGHT_JOIN so MySQL reads filtered `delivery_order` first (avoids full scan on `delivery_order_line`). */ fun getTopDeliveryItems( startDate: LocalDate?, @@ -602,11 +622,11 @@ open class ChartService( val args = mutableMapOf("limit" to limit) val startSql = if (startDate != null) { args["startDate"] = startDate.toString() - "AND DATE(COALESCE(do.completeDate, do.estimatedArrivalDate, do.orderDate)) >= :startDate" + "AND DATE(do.estimatedArrivalDate) >= :startDate" } else "" val endSql = if (endDate != null) { args["endDate"] = endDate.toString() - "AND DATE(COALESCE(do.completeDate, do.estimatedArrivalDate, do.orderDate)) <= :endDate" + "AND DATE(do.estimatedArrivalDate) <= :endDate" } else "" val itemSql = if (!itemCodes.isNullOrEmpty()) { val codes = itemCodes.map { it.trim() }.filter { it.isNotBlank() } @@ -620,10 +640,10 @@ open class ChartService( it.code AS itemCode, it.name AS itemName, SUM(COALESCE(dol.qty, 0)) AS totalQty - FROM delivery_order_line dol - INNER JOIN delivery_order do ON dol.deliveryOrderId = do.id AND do.deleted = 0 - INNER JOIN items it ON dol.itemId = it.id AND it.deleted = 0 - WHERE dol.deleted = 0 $startSql $endSql $itemSql + FROM delivery_order do + STRAIGHT_JOIN delivery_order_line dol ON dol.deliveryOrderId = do.id AND dol.deleted = 0 + STRAIGHT_JOIN items it ON it.id = dol.itemId AND it.deleted = 0 + WHERE do.deleted = 0 AND do.estimatedArrivalDate IS NOT NULL $startSql $endSql $itemSql GROUP BY dol.itemId, it.code, it.name ORDER BY totalQty DESC LIMIT :limit @@ -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? + staffNos: List?, + storeId: String?, + storeIdNull: Boolean?, ): List> { val args = mutableMapOf() val startSql = if (startDate != null) { args["startDate"] = startDate.toString() - "AND DATE(dpor.ticketCompleteDateTime) >= :startDate" + "AND DATE(dop.ticketCompleteDateTime) >= :startDate" } else "" val endSql = if (endDate != null) { args["endDate"] = endDate.toString() - "AND DATE(dpor.ticketCompleteDateTime) <= :endDate" + "AND DATE(dop.ticketCompleteDateTime) <= :endDate" } else "" val staffSql = if (!staffNos.isNullOrEmpty()) { val nos = staffNos.map { it.trim() }.filter { it.isNotBlank() } @@ -746,25 +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) diff --git a/src/main/java/com/ffii/fpsms/modules/chart/web/ChartController.kt b/src/main/java/com/ffii/fpsms/modules/chart/web/ChartController.kt index c83ef36..7d568ec 100644 --- a/src/main/java/com/ffii/fpsms/modules/chart/web/ChartController.kt +++ b/src/main/java/com/ffii/fpsms/modules/chart/web/ChartController.kt @@ -26,7 +26,7 @@ class ChartController( /** * GET /chart/delivery-order-by-date?startDate=&endDate= - * Returns [{ date, orderCount, totalQty }] + * Returns [{ date, orderCount, totalQty }]. Date axis: delivery_order.estimatedArrivalDate only (null ETA excluded). */ @GetMapping("/delivery-order-by-date") fun getDeliveryOrderByDate( @@ -129,7 +129,7 @@ class ChartController( /** * GET /chart/stock-in-out-by-date?startDate=&endDate= - * Returns [{ date, inQty, outQty }] + * Returns [{ date, inQty, outQty }]. Date range pushed into each UNION branch; si/so read before lines. */ @GetMapping("/stock-in-out-by-date") fun getStockInOutByDate( @@ -140,6 +140,7 @@ class ChartController( /** * GET /chart/top-delivery-items-item-options?startDate=&endDate= * Returns [{ itemCode, itemName }] — distinct items in delivery lines in the period (for multi-select). + * Period: delivery_order.estimatedArrivalDate only (null ETA excluded). */ @GetMapping("/top-delivery-items-item-options") fun getTopDeliveryItemsItemOptions( @@ -150,6 +151,7 @@ class ChartController( /** * GET /chart/top-delivery-items?startDate=&endDate=&limit=20&itemCode=A&itemCode=B * Returns [{ itemCode, itemName, totalQty }]. When itemCode present, only those items (still by totalQty, limit). + * Period: delivery_order.estimatedArrivalDate only (null ETA excluded). */ @GetMapping("/top-delivery-items") fun getTopDeliveryItems( @@ -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?, + @RequestParam(required = false) storeId: String?, + @RequestParam(required = false) storeIdNull: Boolean?, ): List> = - chartService.getStaffDeliveryPerformance(startDate, endDate, staffNo) + chartService.getStaffDeliveryPerformance(startDate, endDate, staffNo, storeId, storeIdNull) // ---------- Job order reports ---------- diff --git a/src/main/java/com/ffii/fpsms/modules/common/SettingNames.java b/src/main/java/com/ffii/fpsms/modules/common/SettingNames.java index 61cb2b7..e74855c 100644 --- a/src/main/java/com/ffii/fpsms/modules/common/SettingNames.java +++ b/src/main/java/com/ffii/fpsms/modules/common/SettingNames.java @@ -41,6 +41,11 @@ public abstract class SettingNames { */ public static final String M18_UNITS_SYNC_INITIAL_FULL_SYNC_DONE = "M18.units.sync.initialFullSyncDone"; + /** + * When "true", FPSMS may push BOM header + materials to M18 udfBomForShop. + */ + public static final String M18_BOM_SHOP_SYNC_ENABLED = "M18.bom.shop.sync.enabled"; + /** Post completed DN and process M18 GRN (cron, e.g. "0 40 23 * * *" for 23:40 daily) */ public static final String SCHEDULE_POST_COMPLETED_DN_GRN = "SCHEDULE.postCompletedDn.grn"; @@ -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 */ diff --git a/src/main/java/com/ffii/fpsms/modules/common/scheduler/service/SchedulerService.kt b/src/main/java/com/ffii/fpsms/modules/common/scheduler/service/SchedulerService.kt index ba3b879..55aab0d 100644 --- a/src/main/java/com/ffii/fpsms/modules/common/scheduler/service/SchedulerService.kt +++ b/src/main/java/com/ffii/fpsms/modules/common/scheduler/service/SchedulerService.kt @@ -10,6 +10,7 @@ import com.ffii.fpsms.m18.entity.SchedulerSyncLog import com.ffii.fpsms.m18.entity.SchedulerSyncLogRepository import com.ffii.fpsms.m18.model.SyncResult import com.ffii.fpsms.modules.common.SettingNames +import com.ffii.fpsms.modules.jobOrder.service.JobOrderPlanStartAutoService import com.ffii.fpsms.modules.master.service.ProductionScheduleService import com.ffii.fpsms.modules.stock.service.SearchCompletedDnService import com.ffii.fpsms.modules.stock.service.InventoryLotLineService @@ -42,6 +43,7 @@ open class SchedulerService( @Value("\${scheduler.inventoryLotExpiry.enabled:true}") val inventoryLotExpiryEnabled: Boolean, /** When false (default), M18 PO / DO1 / DO2 / master-data cron jobs are not registered — use true in production only. */ @Value("\${scheduler.m18Sync.enabled:false}") val m18SyncEnabled: Boolean, + @Value("\${scheduler.jo.planStart.enabled:true}") val jobOrderPlanStartAutoEnabled: Boolean, val settingsService: SettingsService, /** * Lookback window for GRN code sync: rows with `created` from **start of (today − N days)** through **now**, @@ -56,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( diff --git a/src/main/java/com/ffii/fpsms/modules/common/scheduler/web/SchedulerController.kt b/src/main/java/com/ffii/fpsms/modules/common/scheduler/web/SchedulerController.kt index 5c5d49a..3b01c91 100644 --- a/src/main/java/com/ffii/fpsms/modules/common/scheduler/web/SchedulerController.kt +++ b/src/main/java/com/ffii/fpsms/modules/common/scheduler/web/SchedulerController.kt @@ -88,4 +88,9 @@ class SchedulerController( schedulerService.init() return "Cron Schedules Refreshed from Database" } + @GetMapping("/trigger/jo-plan-start") + fun triggerJoPlanStart(): String { + schedulerService.runJobOrderPlanStartAuto() + return "Job order plan-start auto triggered" + } } \ No newline at end of file diff --git a/src/main/java/com/ffii/fpsms/modules/deliveryOrder/entity/DeliveryOrder.kt b/src/main/java/com/ffii/fpsms/modules/deliveryOrder/entity/DeliveryOrder.kt index d9dc6f2..1e61124 100644 --- a/src/main/java/com/ffii/fpsms/modules/deliveryOrder/entity/DeliveryOrder.kt +++ b/src/main/java/com/ffii/fpsms/modules/deliveryOrder/entity/DeliveryOrder.kt @@ -64,6 +64,6 @@ open class DeliveryOrder: BaseEntity() { open var m18BeId: Long? = null /** 加單:由 M18「加單」專用同步標記;一般 DO 為 false */ - @Column(name = "isEtra", nullable = false) - open var isEtra: Boolean = false + @Column(name = "isExtra", nullable = false) + open var isExtra: Boolean = false } \ No newline at end of file diff --git a/src/main/java/com/ffii/fpsms/modules/deliveryOrder/entity/DeliveryOrderRepository.kt b/src/main/java/com/ffii/fpsms/modules/deliveryOrder/entity/DeliveryOrderRepository.kt index 51c9260..65b48fd 100644 --- a/src/main/java/com/ffii/fpsms/modules/deliveryOrder/entity/DeliveryOrderRepository.kt +++ b/src/main/java/com/ffii/fpsms/modules/deliveryOrder/entity/DeliveryOrderRepository.kt @@ -111,7 +111,7 @@ fun searchDoLite( and (:status is null or d.status = :status) and (:etaStart is null or d.estimatedArrivalDate >= :etaStart) and (:etaEnd is null or d.estimatedArrivalDate < :etaEnd) - and (:isEtra is null or d.isEtra = :isEtra) + and (:isExtra is null or d.isExtra = :isExtra) order by d.id desc """) fun searchDoLitePage( @@ -120,7 +120,7 @@ fun searchDoLitePage( @Param("status") status: DeliveryOrderStatus?, @Param("etaStart") etaStart: LocalDateTime?, @Param("etaEnd") etaEnd: LocalDateTime?, - @Param("isEtra") isEtra: Boolean?, + @Param("isExtra") isExtra: Boolean?, pageable: Pageable ): Page @@ -136,7 +136,7 @@ fun searchDoLitePage( and (:status is null or d.status = :status) and (:etaStart is null or d.estimatedArrivalDate >= :etaStart) and (:etaEnd is null or d.estimatedArrivalDate < :etaEnd) - and (:isEtra is null or d.isEtra = :isEtra) + and (:isExtra is null or d.isExtra = :isExtra) and d.supplier is not null and d.supplier.code in :allowedSupplierCodes order by d.id desc @@ -148,7 +148,7 @@ fun searchDoLitePageWithSupplierCodes( @Param("status") status: DeliveryOrderStatus?, @Param("etaStart") etaStart: LocalDateTime?, @Param("etaEnd") etaEnd: LocalDateTime?, - @Param("isEtra") isEtra: Boolean?, + @Param("isExtra") isExtra: Boolean?, @Param("allowedSupplierCodes") allowedSupplierCodes: List, pageable: Pageable, ): Page diff --git a/src/main/java/com/ffii/fpsms/modules/deliveryOrder/entity/models/DeliveryOrderInfo.kt b/src/main/java/com/ffii/fpsms/modules/deliveryOrder/entity/models/DeliveryOrderInfo.kt index f806cb0..c27646e 100644 --- a/src/main/java/com/ffii/fpsms/modules/deliveryOrder/entity/models/DeliveryOrderInfo.kt +++ b/src/main/java/com/ffii/fpsms/modules/deliveryOrder/entity/models/DeliveryOrderInfo.kt @@ -48,8 +48,8 @@ interface DeliveryOrderInfoLite { @get:Value("#{target.shop?.addr3}") val shopAddress: String? - @get:Value("#{target.isEtra}") - val isEtra: Boolean + @get:Value("#{target.isExtra}") + val isExtra: Boolean } data class DeliveryOrderInfoLiteDto( val id: Long, @@ -61,5 +61,5 @@ data class DeliveryOrderInfoLiteDto( val supplierName: String?, val shopAddress: String?, val truckLanceCode: String?, - val isEtra: Boolean = false, + val isExtra: Boolean = false, ) \ No newline at end of file diff --git a/src/main/java/com/ffii/fpsms/modules/deliveryOrder/service/DeliveryOrderService.kt b/src/main/java/com/ffii/fpsms/modules/deliveryOrder/service/DeliveryOrderService.kt index 3f7ef62..abfba71 100644 --- a/src/main/java/com/ffii/fpsms/modules/deliveryOrder/service/DeliveryOrderService.kt +++ b/src/main/java/com/ffii/fpsms/modules/deliveryOrder/service/DeliveryOrderService.kt @@ -90,7 +90,6 @@ import com.ffii.fpsms.modules.stock.entity.InventoryLotLine import com.ffii.fpsms.modules.stock.entity.projection.StockOutLineInfo import java.util.Locale import org.slf4j.Logger - @Service open class DeliveryOrderService( private val deliveryOrderRepository: DeliveryOrderRepository, @@ -121,23 +120,23 @@ open class DeliveryOrderService( private val doPickOrderLineRepository: DoPickOrderLineRepository, private val doPickOrderLineRecordRepository: DoPickOrderLineRecordRepository, private val itemsRepository: ItemsRepository, + private val doFloorSupplierSettingsService: DoFloorSupplierSettingsService, ) { /** - * 樓層篩選:2F → P07/P06D(車線- 族);4F → P06B(P06B_ 族);全部 → 三者。 - * 車線-X 仍屬該 DO 的 supplier,故 P06B+車線-X 不會出現在 2F,P06D+車線-X 不會出現在 4F。 + * 樓層篩選:2F/4F/ALL 由 [DoFloorSupplierSettingsService] 讀 `settings`。 + * 車線-X 仍依 DO supplier 所屬樓層出現在對應 tab。 */ - private fun allowedSupplierCodesForFloor(floor: String?): List { - val f = floor?.trim()?.uppercase(Locale.ROOT).orEmpty() - if (f.isEmpty() || f == "ALL" || f == "All") { - return listOf("P06B", "P07", "P06D") - } - return when (f) { - "2F" -> listOf("P07", "P06D") - "4F" -> listOf("P06B") - else -> listOf("P06B", "P07", "P06D") - } - } + private fun allowedSupplierCodesForFloor(floor: String?): List = + doFloorSupplierSettingsService.allowedSupplierCodesForFloor(floor) + + private fun loadDoFloorSupplierLists(): Pair, List> = + doFloorSupplierSettingsService.loadDoFloorSupplierLists() + private fun preferredStoreFloorForSupplier( + supplierCode: String?, + suppliers2F: List, + suppliers4F: List, + ): String = doFloorSupplierSettingsService.preferredStoreFloorForSupplier(supplierCode, suppliers2F, suppliers4F) open fun searchDoLiteByPage( code: String?, shopName: String?, @@ -147,7 +146,7 @@ open class DeliveryOrderService( pageSize: Int?, truckLanceCode: String?, floor: String? = null, - isEtra: Boolean? = null, + isExtra: Boolean? = null, ): RecordsRes { val page = (pageNum ?: 1) - 1 @@ -169,7 +168,7 @@ open class DeliveryOrderService( status = statusEnum, etaStart = etaStart, etaEnd = etaEnd, - isEtra = isEtra, + isExtra = isExtra, allowedSupplierCodes = allowedForFloor, pageable = PageRequest.of(0, 100_000), ) @@ -181,6 +180,7 @@ open class DeliveryOrderService( .associateBy { it.id } val preFilteredContent = allResult.content + val (floorSuppliers2F, floorSuppliers4F) = loadDoFloorSupplierLists() // ✅ 优化3: 收集所有需要查询的 shopId 和日期组合(只处理预过滤后的记录) val shopIdAndDatePairs = preFilteredContent.mapNotNull { info -> @@ -191,11 +191,7 @@ open class DeliveryOrderService( val targetDate = estimatedArrivalDate.toLocalDate() val dayAbbr = getDayOfWeekAbbr(targetDate) val supplierCode = deliveryOrder.supplier?.code - val preferredFloor = when (supplierCode) { - "P06B" -> "4F" - "P07", "P06D" -> "2F" - else -> "2F" // 或者改成 null / 其他默认值,看你业务需要 - } + val preferredFloor = preferredStoreFloorForSupplier(supplierCode, floorSuppliers2F, floorSuppliers4F) Triple(shopId, preferredFloor, dayAbbr) } else { null @@ -217,11 +213,7 @@ open class DeliveryOrderService( val processedRecords = preFilteredContent.map { info -> val deliveryOrder = deliveryOrdersMap[info.id] val supplierCode = deliveryOrder?.supplier?.code - val preferredFloor = when (supplierCode) { - "P06B" -> "4F" - "P07", "P06D" -> "2F" - else -> "2F" - } + val preferredFloor = preferredStoreFloorForSupplier(supplierCode, floorSuppliers2F, floorSuppliers4F) val shop = deliveryOrder?.shop val shopId = shop?.id val estimatedArrivalDate = info.estimatedArrivalDate @@ -248,7 +240,7 @@ open class DeliveryOrderService( supplierName = info.supplierName, shopAddress = info.shopAddress, truckLanceCode = calculatedTruckLanceCode, - isEtra = deliveryOrdersMap[info.id]?.isEtra ?: info.isEtra, + isExtra = deliveryOrdersMap[info.id]?.isExtra ?: info.isExtra, ) }.filter { dto -> val dtoTruckLanceCode = dto.truckLanceCode?.lowercase() ?: "" @@ -279,19 +271,16 @@ open class DeliveryOrderService( status = statusEnum, etaStart = etaStart, etaEnd = etaEnd, - isEtra = isEtra, + isExtra = isExtra, allowedSupplierCodes = allowedSupplierCodes, pageable = PageRequest.of(page.coerceAtLeast(0), size), ) + val (floorSuppliers2F, floorSuppliers4F) = loadDoFloorSupplierLists() val records = result.content.map { info -> val deliveryOrder = deliveryOrderRepository.findByIdAndDeletedIsFalse(info.id) val supplierCode = deliveryOrder?.supplier?.code - val preferredFloor = when (supplierCode) { - "P06B" -> "4F" - "P07", "P06D" -> "2F" - else -> "2F" - } + val preferredFloor = preferredStoreFloorForSupplier(supplierCode, floorSuppliers2F, floorSuppliers4F) val shop = deliveryOrder?.shop val shopId = shop?.id val estimatedArrivalDate = info.estimatedArrivalDate @@ -315,7 +304,7 @@ open class DeliveryOrderService( supplierName = info.supplierName, shopAddress = info.shopAddress, truckLanceCode = calculatedTruckLanceCode, - isEtra = deliveryOrder?.isEtra ?: info.isEtra, + isExtra = deliveryOrder?.isExtra ?: info.isExtra, ) } @@ -338,7 +327,7 @@ open class DeliveryOrderService( pageSize: Int?, truckLanceCode: String?, floor: String? = null, - isEtra: Boolean? = null, + isExtra: Boolean? = null, ): RecordsRes { val mode = TruckLaneSearchSpec.parse(truckLanceCode) if (mode is TruckLaneSearchSpec.Mode.NoFilter) { @@ -351,7 +340,7 @@ open class DeliveryOrderService( pageSize, null, floor, - isEtra, + isExtra, ) } val pageIdx = (pageNum ?: 1).coerceAtLeast(1) - 1 @@ -367,7 +356,7 @@ open class DeliveryOrderService( statusEnum = statusEnum, etaStart = etaStart, etaEnd = etaEnd, - isEtra = isEtra, + isExtra = isExtra, allowedSupplierCodes = allowedSupplierCodesForFloor(floor), lanePredicate = lanePredicate, ) @@ -391,7 +380,7 @@ open class DeliveryOrderService( pageNum: Int?, pageSize: Int?, floor: String? = null, - isEtra: Boolean? = null, + isExtra: Boolean? = null, ): RecordsRes { val page = (pageNum ?: 1) - 1 val size = pageSize ?: 10 @@ -406,22 +395,19 @@ open class DeliveryOrderService( status = statusEnum, etaStart = etaStart, etaEnd = etaEnd, - isEtra = isEtra, + isExtra = isExtra, allowedSupplierCodes = allowedSupplierCodes, pageable = PageRequest.of(0, 100_000), ) val deliveryOrderIds = allResult.content.mapNotNull { it.id } val deliveryOrdersMap = deliveryOrderRepository.findAllById(deliveryOrderIds).associateBy { it.id } + val (floorSuppliers2F, floorSuppliers4F) = loadDoFloorSupplierLists() val processedRecords = allResult.content.map { info -> val deliveryOrder = deliveryOrdersMap[info.id] val supplierCode = deliveryOrder?.supplier?.code - val preferredFloor = when (supplierCode) { - "P06B" -> "4F" - "P07", "P06D" -> "2F" - else -> "2F" - } + val preferredFloor = preferredStoreFloorForSupplier(supplierCode, floorSuppliers2F, floorSuppliers4F) val shop = deliveryOrder?.shop val shopId = shop?.id val infoEta = info.estimatedArrivalDate @@ -445,7 +431,7 @@ open class DeliveryOrderService( supplierName = info.supplierName, shopAddress = info.shopAddress, truckLanceCode = calculatedTruckLanceCode, - isEtra = deliveryOrdersMap[info.id]?.isEtra ?: info.isEtra, + isExtra = deliveryOrdersMap[info.id]?.isExtra ?: info.isExtra, ) }.filter { dto -> TruckLaneSearchSpec.isUnassignedResolvedLane(dto.truckLanceCode) } @@ -487,7 +473,7 @@ open class DeliveryOrderService( estimatedArrivalDate = deliveryOrder.estimatedArrivalDate, completeDate = deliveryOrder.completeDate, status = deliveryOrder.status?.value, - isEtra = deliveryOrder.isEtra, + isExtra = deliveryOrder.isExtra, deliveryOrderLines = deliveryOrder.deliveryOrderLines.map { line -> DoDetailLineResponse( id = line.id!!, @@ -808,7 +794,7 @@ open class DeliveryOrderService( this.handler = handler m18BeId = request.m18BeId this.deleted = request.deleted - isEtra = request.isEtra ?: false + isExtra = request.isExtra ?: false } val savedDeliveryOrder = deliveryOrderRepository.saveAndFlush(deliveryOrder).let { @@ -948,14 +934,10 @@ open class DeliveryOrderService( println(" DEBUG: Target date: $targetDate, Date prefix: $datePrefix") - // 新逻辑:根据 supplier code 决定楼层 - // 如果 supplier code 是 "P06B",使用 4F,否则使用 2F + // 新逻辑:根据 supplier code 决定楼层(清單來自 settings) val supplierCode = deliveryOrder.supplier?.code - val preferredFloor = when (supplierCode) { - "P06B" -> "4F" - "P07", "P06D" -> "2F" - else -> "2F" // 或者改成 null / 其他默认值,看你业务需要 - } + val (floorSuppliers2F, floorSuppliers4F) = loadDoFloorSupplierLists() + val preferredFloor = preferredStoreFloorForSupplier(supplierCode, floorSuppliers2F, floorSuppliers4F) println(" DEBUG: Supplier code: $supplierCode, Preferred floor: $preferredFloor") @@ -1839,15 +1821,11 @@ val inventoryLotLine = illId?.let { inventoryLotLineMap[it] } } } - // 新逻辑:根据 supplier code 决定楼层 - // 如果 supplier code 是 "P06B",使用 4F,否则使用 2F + // 新逻辑:根据 supplier code 决定楼层(清單來自 settings) val targetDate = deliveryOrder.estimatedArrivalDate?.toLocalDate() ?: LocalDate.now() val supplierCode = deliveryOrder.supplier?.code - val preferredFloor = when (supplierCode) { - "P06B" -> "4F" - "P07", "P06D" -> "2F" - else -> "2F" // 或者改成 null / 其他默认值,看你业务需要 - } + val (floorSuppliers2F, floorSuppliers4F) = loadDoFloorSupplierLists() + val preferredFloor = preferredStoreFloorForSupplier(supplierCode, floorSuppliers2F, floorSuppliers4F) println(" DEBUG: Floor calculation for DO ${deliveryOrder.id}") println(" - Supplier code: $supplierCode") @@ -1936,7 +1914,8 @@ val inventoryLotLine = illId?.let { inventoryLotLineMap[it] } truckDepartureTime = effectiveTruck.departureTime, truckLanceCode = effectiveTruck.truckLanceCode, loadingSequence = effectiveTruck.loadingSequence, - usedDefaultTruck = usedDefaultTruck + usedDefaultTruck = usedDefaultTruck, + isExtra = deliveryOrder.isExtra ?: false, ) } @@ -2022,11 +2001,8 @@ val inventoryLotLine = illId?.let { inventoryLotLineMap[it] } // Truck selection (reuse normal logic) val targetDate = deliveryOrder.estimatedArrivalDate?.toLocalDate() ?: LocalDate.now() val supplierCode = deliveryOrder.supplier?.code - val preferredFloor = when (supplierCode) { - "P06B" -> "4F" - "P07", "P06D" -> "2F" - else -> "2F" - } + val (floorSuppliers2F, floorSuppliers4F) = loadDoFloorSupplierLists() + val preferredFloor = preferredStoreFloorForSupplier(supplierCode, floorSuppliers2F, floorSuppliers4F) val truck = deliveryOrder.shop?.id?.let { shopId -> val trucks = truckRepository.findByShopIdAndDeletedFalse(shopId) @@ -2094,7 +2070,8 @@ val inventoryLotLine = illId?.let { inventoryLotLineMap[it] } truckDepartureTime = effectiveTruck.departureTime, truckLanceCode = effectiveTruck.truckLanceCode, loadingSequence = effectiveTruck.loadingSequence, - usedDefaultTruck = usedDefaultTruck + usedDefaultTruck = usedDefaultTruck, + isExtra = deliveryOrder.isExtra ?: false, ) } @@ -2104,7 +2081,7 @@ val inventoryLotLine = illId?.let { inventoryLotLineMap[it] } statusEnum: DeliveryOrderStatus?, etaStart: LocalDateTime?, etaEnd: LocalDateTime?, - isEtra: Boolean?, + isExtra: Boolean?, allowedSupplierCodes: List, lanePredicate: (String?) -> Boolean, ): List { @@ -2118,7 +2095,7 @@ val inventoryLotLine = illId?.let { inventoryLotLineMap[it] } status = statusEnum, etaStart = etaStart, etaEnd = etaEnd, - isEtra = isEtra, + isExtra = isExtra, allowedSupplierCodes = allowedSupplierCodes, pageable = PageRequest.of(dbPage, 500), ) @@ -2140,6 +2117,7 @@ val inventoryLotLine = illId?.let { inventoryLotLineMap[it] } val ids = rows.mapNotNull { it.id } if (ids.isEmpty()) return emptyList() val deliveryOrdersMap = deliveryOrderRepository.findAllById(ids).associateBy { it.id } + val (floorSuppliers2F, floorSuppliers4F) = loadDoFloorSupplierLists() val shopIdAndDatePairs = rows.mapNotNull { info -> val d = deliveryOrdersMap[info.id] @@ -2149,11 +2127,7 @@ val inventoryLotLine = illId?.let { inventoryLotLineMap[it] } val targetDate = eta.toLocalDate() val dayAbbr = getDayOfWeekAbbr(targetDate) val supplierCode = d.supplier?.code - val preferredFloor = when (supplierCode) { - "P06B" -> "4F" - "P07", "P06D" -> "2F" - else -> "2F" - } + val preferredFloor = preferredStoreFloorForSupplier(supplierCode, floorSuppliers2F, floorSuppliers4F) Triple(shopId, preferredFloor, dayAbbr) } else { null @@ -2169,11 +2143,7 @@ val inventoryLotLine = illId?.let { inventoryLotLineMap[it] } return rows.map { info -> val deliveryOrder = deliveryOrdersMap[info.id] val supplierCode = deliveryOrder?.supplier?.code - val preferredFloor = when (supplierCode) { - "P06B" -> "4F" - "P07", "P06D" -> "2F" - else -> "2F" - } + val preferredFloor = preferredStoreFloorForSupplier(supplierCode, floorSuppliers2F, floorSuppliers4F) val shopId = deliveryOrder?.shop?.id val infoEta = info.estimatedArrivalDate val calculatedTruckLanceCode = @@ -2194,14 +2164,14 @@ val inventoryLotLine = illId?.let { inventoryLotLineMap[it] } supplierName = info.supplierName, shopAddress = info.shopAddress, truckLanceCode = calculatedTruckLanceCode, - isEtra = deliveryOrder?.isEtra ?: info.isEtra, + isExtra = deliveryOrder?.isExtra ?: info.isExtra, ) } } /** * 依店鋪 + 揀貨樓層解析當日應顯示之車線。 - * - **2F**(P07/P06D):`TruckLanceCode` 多為 `車線-…` 且不含星期縮寫,不依 `LIKE '%Mon%'` 篩選;取該店該樓層未刪除車輛中出發時間最早者。 + * - **2F**(P07/P06D/P06Y):`TruckLanceCode` 多為 `車線-…` 且不含星期縮寫,不依 `LIKE '%Mon%'` 篩選;取該店該樓層未刪除車輛中出發時間最早者。 * - **4F**(P06B):維持以星期縮寫篩選 [TruckRepository.findByShopIdAndStoreIdAndDayOfWeek];無命中時再退回同樓層最早出發。 */ private fun resolveTruckForShopFloorAndDay( diff --git a/src/main/java/com/ffii/fpsms/modules/deliveryOrder/service/DoFloorSupplierSettingsService.kt b/src/main/java/com/ffii/fpsms/modules/deliveryOrder/service/DoFloorSupplierSettingsService.kt new file mode 100644 index 0000000..a6bc9f0 --- /dev/null +++ b/src/main/java/com/ffii/fpsms/modules/deliveryOrder/service/DoFloorSupplierSettingsService.kt @@ -0,0 +1,95 @@ +package com.ffii.fpsms.modules.deliveryOrder.service + +import com.ffii.fpsms.modules.settings.entity.SettingsRepository +import org.springframework.stereotype.Service +import java.util.Locale + +/** 供 DO 搜尋/車線/報表 SQL 等共用的 2F/4F 供應商代碼(來自 `settings` CSV)。 */ +@Service +open class DoFloorSupplierSettingsService( + private val settingsRepository: SettingsRepository, +) { + companion object { + private const val SETTING_DO_FLOOR_SUPPLIERS_2F = "DO.floor.suppliers.2F" + private const val SETTING_DO_FLOOR_SUPPLIERS_4F = "DO.floor.suppliers.4F" + + private val DEFAULT_SUPPLIERS_2F = listOf("P07", "P06D", "P06Y") + private val DEFAULT_SUPPLIERS_4F = listOf("P06B") + } + + open fun supplierCodesFromSetting(settingName: String, defaultList: List): List { + val raw = settingsRepository.findByName(settingName).map { it.value }.orElse(null) + ?.trim() + .orEmpty() + if (raw.isEmpty()) return defaultList + val parsed = raw.split(",").map { it.trim() }.filter { it.isNotEmpty() }.distinct() + return parsed.ifEmpty { defaultList } + } + + open fun loadDoFloorSupplierLists(): Pair, List> { + val suppliers2F = supplierCodesFromSetting(SETTING_DO_FLOOR_SUPPLIERS_2F, DEFAULT_SUPPLIERS_2F) + val suppliers4F = supplierCodesFromSetting(SETTING_DO_FLOOR_SUPPLIERS_4F, DEFAULT_SUPPLIERS_4F) + return suppliers2F to suppliers4F + } + + open fun allowedSupplierCodesForFloor(floor: String?): List { + val f = floor?.trim()?.uppercase(Locale.ROOT).orEmpty() + val (codes2F, codes4F) = loadDoFloorSupplierLists() + return when { + f.isEmpty() || f == "ALL" || f == "All" -> (codes2F + codes4F).distinct() + f == "2F" -> codes2F + f == "4F" -> codes4F + else -> (codes2F + codes4F).distinct() + } + } + + /** 4F 清單優先;其餘預設 2F(與既有 DO 車線邏輯一致)。 */ + open fun preferredStoreFloorForSupplier( + supplierCode: String?, + suppliers2F: List, + suppliers4F: List, + ): String { + val code = supplierCode?.trim().orEmpty() + if (code.isEmpty()) return "2F" + if (suppliers4F.contains(code)) return "4F" + if (suppliers2F.contains(code)) return "2F" + return "2F" + } + + /** DO 揀貨建議:名單外供應商不限制 2F/4F。 */ + open fun preferredFloorForPickLotOrNull( + supplierCode: String?, + suppliers2F: List, + suppliers4F: List, + ): String? { + val code = supplierCode?.trim().orEmpty() + if (code.isEmpty()) return null + if (suppliers4F.contains(code)) return "4F" + if (suppliers2F.contains(code)) return "2F" + return null + } + + data class SqlPreferredFloorCases( + /** 例如 `CASE WHEN s.code IN (...) THEN '4F' ... END`(單行,可嵌入原生 SQL) */ + val floorStringCase: String, + val storeIdNumericCase: String, + ) + + /** + * 依目前 settings 產生原生 SQL CASE(供 JDBC 字串拼接)。 + * @param codeExpr 已加別名的欄位,如 `s.code`、`supplier.code` + */ + open fun sqlPreferredFloorCases(codeExpr: String = "s.code"): SqlPreferredFloorCases { + val (s2f, s4f) = loadDoFloorSupplierLists() + val in4 = joinSqlInList(s4f) + val in2 = joinSqlInList(s2f) + val floor = + "CASE WHEN $codeExpr IN ($in4) THEN '4F' WHEN $codeExpr IN ($in2) THEN '2F' ELSE NULL END" + val storeId = + "CASE WHEN $codeExpr IN ($in4) THEN 4 WHEN $codeExpr IN ($in2) THEN 2 ELSE NULL END" + return SqlPreferredFloorCases(floorStringCase = floor, storeIdNumericCase = storeId) + } + + private fun joinSqlInList(codes: List): String = + codes.joinToString(", ") { "'" + it.replace("'", "''") + "'" } +} diff --git a/src/main/java/com/ffii/fpsms/modules/deliveryOrder/service/DoReleaseCoordinatorService.kt b/src/main/java/com/ffii/fpsms/modules/deliveryOrder/service/DoReleaseCoordinatorService.kt index e31662a..7b3ca0a 100644 --- a/src/main/java/com/ffii/fpsms/modules/deliveryOrder/service/DoReleaseCoordinatorService.kt +++ b/src/main/java/com/ffii/fpsms/modules/deliveryOrder/service/DoReleaseCoordinatorService.kt @@ -103,6 +103,7 @@ class DoReleaseCoordinatorService( private val userRepository: UserRepository, private val pickOrderRepository: PickOrderRepository, private val doPickOrderRecordRepository: DoPickOrderRecordRepository, + private val doFloorSupplierSettingsService: DoFloorSupplierSettingsService, ) { private val poolSize = Runtime.getRuntime().availableProcessors() private val executor = Executors.newFixedThreadPool(min(poolSize, 4)) @@ -140,22 +141,15 @@ class DoReleaseCoordinatorService( private fun updateBatchTicketNumbers() { try { val dayOfWeekSql = getDayOfWeekAbbrSql("do.estimatedArrivalDate") + val pfCases = doFloorSupplierSettingsService.sqlPreferredFloorCases("s.code") val updateSql = """ UPDATE fpsmsdb.do_pick_order dpo INNER JOIN ( WITH PreferredFloor AS ( SELECT do.id AS deliveryOrderId, - CASE - WHEN s.code = 'P06B' THEN '4F' - WHEN s.code = 'P07' OR s.code = 'P06D' THEN '2F' - ELSE NULL - END AS preferred_floor, - CASE - WHEN s.code = 'P06B' THEN 4 - WHEN s.code = 'P07' OR s.code = 'P06D' THEN 2 - ELSE NULL - END AS preferred_store_id + ${pfCases.floorStringCase} AS preferred_floor, + ${pfCases.storeIdNumericCase} AS preferred_store_id FROM fpsmsdb.delivery_order do LEFT JOIN fpsmsdb.shop s ON s.id = do.supplierId AND s.deleted = 0 WHERE do.deleted = 0 @@ -307,20 +301,13 @@ class DoReleaseCoordinatorService( println(" DEBUG: Getting ordered IDs for ${ids.size} orders") println(" DEBUG: First 5 IDs: ${ids.take(5)}") val dayOfWeekSql = getDayOfWeekAbbrSql("do.estimatedArrivalDate") + val pfCases = doFloorSupplierSettingsService.sqlPreferredFloorCases("s.code") val sql = """ WITH PreferredFloor AS ( SELECT do.id AS deliveryOrderId, - CASE - WHEN s.code = 'P06B' THEN '4F' - WHEN s.code = 'P07' OR s.code = 'P06D' THEN '2F' - ELSE NULL - END AS preferred_floor, - CASE - WHEN s.code = 'P06B' THEN 4 - WHEN s.code = 'P07' OR s.code = 'P06D' THEN 2 - ELSE NULL - END AS preferred_store_id + ${pfCases.floorStringCase} AS preferred_floor, + ${pfCases.storeIdNumericCase} AS preferred_store_id FROM fpsmsdb.delivery_order do LEFT JOIN fpsmsdb.shop s ON s.id = do.supplierId AND s.deleted = 0 WHERE do.id IN (${ids.joinToString(",")}) diff --git a/src/main/java/com/ffii/fpsms/modules/deliveryOrder/service/DoWorkbenchDopoAssignmentService.kt b/src/main/java/com/ffii/fpsms/modules/deliveryOrder/service/DoWorkbenchDopoAssignmentService.kt index 77eec8e..25ef935 100644 --- a/src/main/java/com/ffii/fpsms/modules/deliveryOrder/service/DoWorkbenchDopoAssignmentService.kt +++ b/src/main/java/com/ffii/fpsms/modules/deliveryOrder/service/DoWorkbenchDopoAssignmentService.kt @@ -144,6 +144,9 @@ open class DoWorkbenchDopoAssignmentService( sql.append(" AND dop.loadingSequence = :loadingSequence ") params["loadingSequence"] = request.loadingSequence } + if (isisExtraReleaseType(request.releaseType)) { + sql.append(" AND LOWER(COALESCE(dop.releaseType, '')) = 'isExtra' ") + } // Fetch a batch of candidates and try atomic-assign sequentially. // This avoids forcing the frontend to refresh when a single picked candidate is concurrently assigned. val candidateLimit = 50 @@ -247,6 +250,9 @@ open class DoWorkbenchDopoAssignmentService( sql.append(" AND dop.loadingSequence = :loadingSequence ") params["loadingSequence"] = request.loadingSequence } + if (isisExtraReleaseType(request.releaseType)) { + sql.append(" AND LOWER(COALESCE(dop.releaseType, '')) = 'isExtra' ") + } val shouldOrderBySequenceV1 = actualStoreId == "2/F" && request.loadingSequence == null if (shouldOrderBySequenceV1) { sql.append(" ORDER BY dop.requiredDeliveryDate ASC, dop.truckDepartureTime ASC, dop.loadingSequence ASC, dop.id ASC LIMIT 1 ") @@ -301,6 +307,11 @@ open class DoWorkbenchDopoAssignmentService( } else null } + private fun isisExtraReleaseType(releaseType: String?): Boolean { + val n = releaseType?.trim()?.lowercase().orEmpty() + return n == "isExtra" + } + private fun parseDepartureTimeToSql(raw: String?): Time? { if (raw.isNullOrBlank()) return null val s = raw.trim() diff --git a/src/main/java/com/ffii/fpsms/modules/deliveryOrder/service/DoWorkbenchMainService.kt b/src/main/java/com/ffii/fpsms/modules/deliveryOrder/service/DoWorkbenchMainService.kt index d7a2d71..4e15dad 100644 --- a/src/main/java/com/ffii/fpsms/modules/deliveryOrder/service/DoWorkbenchMainService.kt +++ b/src/main/java/com/ffii/fpsms/modules/deliveryOrder/service/DoWorkbenchMainService.kt @@ -1,3 +1,4 @@ + package com.ffii.fpsms.modules.deliveryOrder.service import com.ffii.core.support.JdbcDao @@ -54,6 +55,7 @@ import java.time.format.DateTimeFormatter import com.ffii.fpsms.modules.deliveryOrder.web.models.StoreLaneSummary import com.ffii.fpsms.modules.deliveryOrder.web.models.LaneRow import com.ffii.fpsms.modules.deliveryOrder.web.models.LaneBtn +import com.ffii.fpsms.modules.deliveryOrder.web.models.WorkbenchEtraShopLaneGroup import com.ffii.fpsms.modules.deliveryOrder.web.models.ReleasedDoPickOrderListItem import com.ffii.fpsms.modules.deliveryOrder.web.models.WorkbenchTicketReleaseTableResponse import com.ffii.fpsms.modules.user.service.UserService @@ -670,6 +672,7 @@ return MessageResponse( val releaseFilterClause = when (rt) { "batch" -> " AND LOWER(COALESCE(dop.releaseType, '')) = 'batch' " "single" -> " AND LOWER(COALESCE(dop.releaseType, '')) = 'single' " + "isExtra" -> " AND LOWER(COALESCE(dop.releaseType, '')) = 'isExtra' " else -> "" } val sql = """ @@ -812,6 +815,7 @@ return MessageResponse( unassigned = it.unassigned, total = it.total, handlerName = it.handlerName, + storeId = actualStoreId, ) } .sortedWith( @@ -853,24 +857,181 @@ return MessageResponse( ) } + /** + * Workbench Etra view: all `delivery_order_pick_order` with `releaseType` = isExtra (case-insensitive), + * for one [requiredDeliveryDate], grouped by shop then by truck / time / loading sequence. + */ + open fun getWorkbenchEtraLaneSummary(requiredDate: LocalDate?): List { + val targetDate = requiredDate ?: LocalDate.now() + val defaultTruck = truckRepository.findById(5577L).orElse(null) + val defaultTruckLaneCode = defaultTruck?.truckLanceCode ?: "" + + val sql = """ + SELECT + dop.shopCode AS shopCode, + dop.shopName AS shopName, + dop.storeId AS storeId, + dop.truckDepartureTime AS truckDepartureTime, + dop.truckLanceCode AS truckLanceCode, + dop.loadingSequence AS loadingSequence, + COUNT(DISTINCT dop.id) AS total_cnt, + SUM(CASE WHEN dop.handledBy IS NULL THEN 1 ELSE 0 END) AS unassigned_cnt, + GROUP_CONCAT( + DISTINCT NULLIF(TRIM(dop.handlerName), '') + ORDER BY dop.handlerName + SEPARATOR ', ' + ) AS handler_names + FROM fpsmsdb.delivery_order_pick_order dop + WHERE dop.deleted = 0 + AND LOWER(COALESCE(dop.releaseType, '')) = 'isExtra' + AND dop.requiredDeliveryDate = :requiredDate + AND dop.ticketStatus IN ('pending', 'released') + AND EXISTS ( + SELECT 1 FROM fpsmsdb.pick_order po + WHERE po.deliveryOrderPickOrderId = dop.id AND po.deleted = 0 + ) + GROUP BY dop.shopCode, dop.shopName, dop.storeId, dop.truckDepartureTime, dop.truckLanceCode, dop.loadingSequence + """.trimIndent() + + val rawRows: List> = try { + jdbcDao.queryForList(sql, mapOf("requiredDate" to targetDate)) + } catch (e: Exception) { + println("❌ getWorkbenchEtraLaneSummary: ${e.message}") + emptyList() + } + + fun cellStr(row: Map, name: String): String? { + val k = row.keys.find { it.equals(name, true) } ?: return null + return row[k]?.toString()?.trim()?.takeIf { it.isNotEmpty() } + } + fun cellNum(row: Map, vararg names: String): Int { + for (n in names) { + val k = row.keys.find { it.equals(n, true) } ?: continue + (row[k] as? Number)?.toInt()?.let { return it } + } + return 0 + } + fun cellNullableInt(row: Map, vararg names: String): Int? { + for (n in names) { + val k = row.keys.find { it.equals(n, true) } ?: continue + val v = row[k] ?: continue + when (v) { + is Number -> return v.toInt() + is String -> v.trim().toIntOrNull()?.let { return it } + } + } + return null + } + + data class EtraAgg( + val shopCode: String?, + val shopName: String?, + val storeId: String?, + val sortTime: LocalTime, + val lance: String, + val loadingSequence: Int?, + val unassigned: Int, + val total: Int, + val handlerName: String?, + ) + + val aggs = rawRows.mapNotNull { row -> + val lance = cellStr(row, "truckLanceCode") ?: return@mapNotNull null + if (lance == defaultTruckLaneCode) return@mapNotNull null + val storeIdCol = cellStr(row, "storeId") + val ttKey = row.keys.find { it.equals("truckDepartureTime", true) } + val ttVal = ttKey?.let { row[it] } + val sortTime = when (ttVal) { + null -> LocalTime.MIDNIGHT + is java.sql.Time -> ttVal.toLocalTime() + is LocalTime -> ttVal + is java.sql.Timestamp -> ttVal.toLocalDateTime().toLocalTime() + else -> runCatching { LocalTime.parse(ttVal.toString().take(8)) }.getOrNull() + ?: runCatching { LocalTime.parse(ttVal.toString()) }.getOrNull() + ?: LocalTime.MIDNIGHT + } + val loadingSeq = cellNullableInt(row, "loadingSequence") + val unassigned = cellNum(row, "unassigned_cnt", "unassignedCnt") + val total = cellNum(row, "total_cnt", "totalCnt") + if (total <= 0) return@mapNotNull null + EtraAgg( + shopCode = cellStr(row, "shopCode"), + shopName = cellStr(row, "shopName"), + storeId = storeIdCol, + sortTime = sortTime, + lance = lance, + loadingSequence = loadingSeq, + unassigned = unassigned, + total = total, + handlerName = cellStr(row, "handler_names"), + ) + } + + val byShop = aggs.groupBy { a -> + listOf(a.shopCode ?: "", a.shopName ?: "").joinToString("|") + } + + return byShop.entries + .map { (key, group) -> + val head = group.first() + val lanes = group + .sortedWith( + compareBy { it.sortTime } + .thenBy { it.lance } + .thenBy { it.loadingSequence ?: 999 } + ) + .map { + val is4F = it.storeId?.replace("/", "")?.trim()?.equals("4F", ignoreCase = true) == true + LaneBtn( + truckLanceCode = it.lance, + loadingSequence = if (is4F) it.loadingSequence else null, + unassigned = it.unassigned, + total = it.total, + handlerName = it.handlerName, + storeId = it.storeId, + truckDepartureTime = it.sortTime.toString(), + ) + } + WorkbenchEtraShopLaneGroup( + shopCode = head.shopCode, + shopName = head.shopName, + lanes = lanes, + ) + } + .sortedWith( + compareBy { it.shopName ?: it.shopCode ?: "" } + .thenBy { it.shopCode ?: "" } + ) + } + open fun findWorkbenchReleasedDeliveryOrderPickOrdersForSelection( shopName: String?, storeId: String?, truck: String?, + releaseTypeFilter: String? = null, ): List = - queryWorkbenchReleasedDopoList(shopName, storeId, truck, beforeToday = true) + queryWorkbenchReleasedDopoList(shopName, storeId, truck, beforeToday = true, releaseTypeFilter = releaseTypeFilter) /** * @param requiredDeliveryDate when null, uses [LocalDate.now] (calendar today). * When set, filters `dop.requiredDeliveryDate = :targetDate` (workbench date picker / select day). + * @param releaseTypeFilter when `isExtra` (case-insensitive), only `delivery_order_pick_order.releaseType = isExtra` rows. */ open fun findWorkbenchReleasedDeliveryOrderPickOrdersForSelectionToday( shopName: String?, storeId: String?, truck: String?, requiredDeliveryDate: LocalDate? = null, + releaseTypeFilter: String? = null, ): List = - queryWorkbenchReleasedDopoList(shopName, storeId, truck, beforeToday = false, equalsDeliveryDate = requiredDeliveryDate) + queryWorkbenchReleasedDopoList( + shopName, + storeId, + truck, + beforeToday = false, + equalsDeliveryDate = requiredDeliveryDate, + releaseTypeFilter = releaseTypeFilter, + ) /** * Workbench completed tickets: query `delivery_order_pick_order` where `ticketStatus = completed`. @@ -1362,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 { val today = LocalDate.now() val params = mutableMapOf() @@ -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> = 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) { 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 { validateWorkbenchCartonReprintRange( fromCarton = request.fromCarton, diff --git a/src/main/java/com/ffii/fpsms/modules/deliveryOrder/service/DoWorkbenchReleaseService.kt b/src/main/java/com/ffii/fpsms/modules/deliveryOrder/service/DoWorkbenchReleaseService.kt index c138a85..5307654 100644 --- a/src/main/java/com/ffii/fpsms/modules/deliveryOrder/service/DoWorkbenchReleaseService.kt +++ b/src/main/java/com/ffii/fpsms/modules/deliveryOrder/service/DoWorkbenchReleaseService.kt @@ -359,14 +359,17 @@ open class DoWorkbenchReleaseService( } /** - * `TI-B-yyyyMMdd-2F-001` (batch) or `TI-S-yyyyMMdd-2F-001` (single), same suffix rules as [DoReleaseCoordinatorService] / legacy `do_pick_order`. + * `TI-B-yyyyMMdd-2F-001` (batch), `TI-S-yyyyMMdd-2F-001` (single), or `TI-E-yyyyMMdd-2F-001` (Etra), + * same suffix rules as [DoReleaseCoordinatorService] / legacy `do_pick_order`. */ private fun nextDeliveryOrderPickOrderTicketNo( requiredDate: LocalDate, storeDisplay: String, ticketLetter: String, ): String { - require(ticketLetter == "B" || ticketLetter == "S") { "ticketLetter must be B or S" } + require(ticketLetter == "B" || ticketLetter == "S" || ticketLetter == "E") { + "ticketLetter must be B, S or E" + } val ymd = requiredDate.format(DateTimeFormatter.ofPattern("yyyyMMdd")) val floor = storeDisplay.replace("/", "").trim() val prefix = "TI-$ticketLetter-$ymd-$floor-" @@ -397,6 +400,9 @@ open class DoWorkbenchReleaseService( private fun nextDeliveryOrderPickOrderSingleTicketNo(requiredDate: LocalDate, storeDisplay: String): String = nextDeliveryOrderPickOrderTicketNo(requiredDate, storeDisplay, "S") + private fun nextDeliveryOrderPickOrderEtraTicketNo(requiredDate: LocalDate, storeDisplay: String): String = + nextDeliveryOrderPickOrderTicketNo(requiredDate, storeDisplay, "E") + private fun asyncJobType(useV2: Boolean, dopReleaseType: String): String { val single = dopReleaseType.equals("single", ignoreCase = true) return when { @@ -440,11 +446,6 @@ open class DoWorkbenchReleaseService( ): Int { if (results.isEmpty()) return 0 - val releaseTypeCol = when (dopReleaseType.lowercase()) { - "single" -> "single" - else -> "batch" - } - val grouped = results.groupBy { listOf( it.shopId?.toString() ?: "", @@ -452,7 +453,8 @@ open class DoWorkbenchReleaseService( it.preferredFloor, it.truckId?.toString() ?: "", it.truckDepartureTime?.toString() ?: "", - it.truckLanceCode ?: "" + it.truckLanceCode ?: "", + it.isExtra.toString(), ).joinToString("|") } @@ -477,7 +479,16 @@ open class DoWorkbenchReleaseService( (storeId ?: "2/F").replace("/", "").trim() } val requiredDate = first.estimatedArrivalDate ?: LocalDate.now() - val tempTicket = if (releaseTypeCol == "single") { + val releaseTypeCol = if (first.isExtra) { + "isExtra" + } else if (dopReleaseType.equals("single", ignoreCase = true)) { + "single" + } else { + "batch" + } + val tempTicket = if (first.isExtra) { + nextDeliveryOrderPickOrderEtraTicketNo(requiredDate, ticketFloorSegment) + } else if (releaseTypeCol == "single") { nextDeliveryOrderPickOrderSingleTicketNo(requiredDate, ticketFloorSegment) } else { nextDeliveryOrderPickOrderBatchTicketNo(requiredDate, ticketFloorSegment) diff --git a/src/main/java/com/ffii/fpsms/modules/deliveryOrder/web/DeliveryOrderController.kt b/src/main/java/com/ffii/fpsms/modules/deliveryOrder/web/DeliveryOrderController.kt index 8836198..f803111 100644 --- a/src/main/java/com/ffii/fpsms/modules/deliveryOrder/web/DeliveryOrderController.kt +++ b/src/main/java/com/ffii/fpsms/modules/deliveryOrder/web/DeliveryOrderController.kt @@ -72,7 +72,7 @@ class DeliveryOrderController( pageSize = request.pageSize, truckLanceCode = request.truckLanceCode, floor = request.floor, - isEtra = request.isEtra, + isExtra = request.isExtra, ) } @@ -89,7 +89,7 @@ class DeliveryOrderController( pageNum = request.pageNum, pageSize = request.pageSize, floor = request.floor, - isEtra = request.isEtra, + isExtra = request.isExtra, ) } @@ -108,7 +108,7 @@ class DeliveryOrderController( pageSize = request.pageSize, truckLanceCode = request.truckLanceCode, floor = request.floor, - isEtra = request.isEtra, + isExtra = request.isExtra, ) } diff --git a/src/main/java/com/ffii/fpsms/modules/deliveryOrder/web/DoWorkbenchController.kt b/src/main/java/com/ffii/fpsms/modules/deliveryOrder/web/DoWorkbenchController.kt index ebd6dab..ffcdcae 100644 --- a/src/main/java/com/ffii/fpsms/modules/deliveryOrder/web/DoWorkbenchController.kt +++ b/src/main/java/com/ffii/fpsms/modules/deliveryOrder/web/DoWorkbenchController.kt @@ -96,14 +96,27 @@ class DoWorkbenchController( ) } + /** All Etra workbench tickets for a day, grouped by shop → truck (see [DoWorkbenchMainService.getWorkbenchEtraLaneSummary]). */ + @GetMapping("/summary-is-etra") + fun getWorkbenchEtraSummary( + @RequestParam(required = false) @DateTimeFormat(iso = DateTimeFormat.ISO.DATE) requiredDate: LocalDate?, + ): List = + doWorkbenchMainService.getWorkbenchEtraLaneSummary(requiredDate) + /** Past-date backlog tickets from `delivery_order_pick_order` (not `do_pick_order`). */ @GetMapping("/released") fun getWorkbenchReleasedDoPickOrders( @RequestParam(required = false) shopName: String?, @RequestParam(required = false) storeId: String?, - @RequestParam(required = false) truck: String? + @RequestParam(required = false) truck: String?, + @RequestParam(required = false) releaseType: String?, ): List { - return doWorkbenchMainService.findWorkbenchReleasedDeliveryOrderPickOrdersForSelection(shopName, storeId, truck) + return doWorkbenchMainService.findWorkbenchReleasedDeliveryOrderPickOrdersForSelection( + shopName, + storeId, + truck, + releaseTypeFilter = releaseType, + ) } @GetMapping("/released-today") @@ -112,12 +125,14 @@ class DoWorkbenchController( @RequestParam(required = false) storeId: String?, @RequestParam(required = false) truck: String?, @RequestParam(required = false) @DateTimeFormat(iso = DateTimeFormat.ISO.DATE) requiredDate: LocalDate?, + @RequestParam(required = false) releaseType: String?, ): List { return doWorkbenchMainService.findWorkbenchReleasedDeliveryOrderPickOrdersForSelectionToday( shopName, storeId, truck, requiredDeliveryDate = requiredDate, + releaseTypeFilter = releaseType, ) } diff --git a/src/main/java/com/ffii/fpsms/modules/deliveryOrder/web/models/DoDetailResponse.kt b/src/main/java/com/ffii/fpsms/modules/deliveryOrder/web/models/DoDetailResponse.kt index 4643119..141665d 100644 --- a/src/main/java/com/ffii/fpsms/modules/deliveryOrder/web/models/DoDetailResponse.kt +++ b/src/main/java/com/ffii/fpsms/modules/deliveryOrder/web/models/DoDetailResponse.kt @@ -19,7 +19,7 @@ data class DoDetailResponse( val completeDate: LocalDateTime?, val status: String?, /** 加單 DO(M18 加單專用同步) */ - val isEtra: Boolean = false, + val isExtra: Boolean = false, val deliveryOrderLines: List ) @@ -51,7 +51,18 @@ data class LaneBtn( val unassigned: Int, val total: Int, // 同一 truckLanceCode + loadingSequence 的 handler 去重后逗号拼接 - val handlerName: String? = null + val handlerName: String? = null, + /** Workbench Etra lane: `delivery_order_pick_order.storeId` (2/F, 4/F, …) for assign / modal scope */ + val storeId: String? = null, + /** Workbench Etra / lane row: `truckDepartureTime` as ISO local time string for assign-by-lane */ + val truckDepartureTime: String? = null, +) + +/** All Etra (`releaseType=isExtra`) tickets for a day, grouped by shop then truck (no 2F/4F split in UI). */ +data class WorkbenchEtraShopLaneGroup( + val shopCode: String?, + val shopName: String?, + val lanes: List, ) data class AssignByLaneRequest( val userId: Long, @@ -59,7 +70,9 @@ data class AssignByLaneRequest( val truckDepartureTime: String?, // 可选:限定出车时间 val truckLanceCode: String , val loadingSequence: Int? = null, - val requiredDate: LocalDate? // 必填:车道编号 + val requiredDate: LocalDate?, // 必填:车道编号 + /** When `isExtra`, assignment candidates are limited to `releaseType = isExtra` rows. */ + val releaseType: String? = null, ) data class DoPickOrderSummaryItem( val truckDepartureTime: java.time.LocalTime?, diff --git a/src/main/java/com/ffii/fpsms/modules/deliveryOrder/web/models/ReleaseDoRequest.kt b/src/main/java/com/ffii/fpsms/modules/deliveryOrder/web/models/ReleaseDoRequest.kt index 8ecd928..5c827a8 100644 --- a/src/main/java/com/ffii/fpsms/modules/deliveryOrder/web/models/ReleaseDoRequest.kt +++ b/src/main/java/com/ffii/fpsms/modules/deliveryOrder/web/models/ReleaseDoRequest.kt @@ -21,7 +21,8 @@ data class ReleaseDoResult( val truckDepartureTime: LocalTime?, val truckLanceCode: String?, - val loadingSequence: Int? + val loadingSequence: Int?, + val isExtra: Boolean = false, ) data class SearchDeliveryOrderInfoRequest( val code: String?, @@ -31,8 +32,8 @@ data class SearchDeliveryOrderInfoRequest( val pageSize: Int?, val pageNum: Int?, val truckLanceCode: String?, - /** `ALL`/`All`/null:P06B+P07+P06D;`2F`:P07+P06D;`4F`:P06B。車線-X 亦依供應商歸屬出現在對應樓層。 */ + /** `ALL`/`All`/null:P06B+P07+P06D+P06Y;`2F`:P07+P06D+P06Y ;`4F`:P06B。車線-X 亦依供應商歸屬出現在對應樓層。 */ val floor: String? = null, - /** null:不篩 isEtra;true/false:只顯示加單或非加單 DO */ - val isEtra: Boolean? = null, + /** null:不篩 isExtra;true/false:只顯示加單或非加單 DO */ + val isExtra: Boolean? = null, ) diff --git a/src/main/java/com/ffii/fpsms/modules/deliveryOrder/web/models/SaveDeliveryOrderRequest.kt b/src/main/java/com/ffii/fpsms/modules/deliveryOrder/web/models/SaveDeliveryOrderRequest.kt index bc89a77..6a1bcda 100644 --- a/src/main/java/com/ffii/fpsms/modules/deliveryOrder/web/models/SaveDeliveryOrderRequest.kt +++ b/src/main/java/com/ffii/fpsms/modules/deliveryOrder/web/models/SaveDeliveryOrderRequest.kt @@ -20,7 +20,7 @@ data class SaveDeliveryOrderRequest( val handlerId: Long?, val m18BeId: Long?, val deleted: Boolean? = false, - val isEtra: Boolean? = false, + val isExtra: Boolean? = false, ) data class SaveDeliveryOrderStatusRequest( diff --git a/src/main/java/com/ffii/fpsms/modules/jobOrder/service/JobOrderPlanStartAutoService.kt b/src/main/java/com/ffii/fpsms/modules/jobOrder/service/JobOrderPlanStartAutoService.kt new file mode 100644 index 0000000..b1872c1 --- /dev/null +++ b/src/main/java/com/ffii/fpsms/modules/jobOrder/service/JobOrderPlanStartAutoService.kt @@ -0,0 +1,243 @@ +package com.ffii.fpsms.modules.jobOrder.service + +import com.ffii.fpsms.modules.jobOrder.entity.JobOrder +import com.ffii.fpsms.modules.jobOrder.entity.JobOrderRepository +import com.ffii.fpsms.modules.jobOrder.enums.JobOrderStatus +import com.ffii.fpsms.modules.pickOrder.entity.PickOrder +import com.ffii.fpsms.modules.pickOrder.entity.PickOrderRepository +import com.ffii.fpsms.modules.productProcess.entity.ProductProcess +import com.ffii.fpsms.modules.productProcess.entity.ProductProcessRepository +import com.ffii.fpsms.modules.productProcess.enums.ProductProcessStatus +import com.ffii.fpsms.modules.stock.entity.StockInLineRepository +import com.ffii.fpsms.modules.stock.service.StockInLineService +import org.slf4j.LoggerFactory +import org.springframework.stereotype.Service +import org.springframework.transaction.support.TransactionTemplate +import java.time.LocalDateTime +import java.util.concurrent.atomic.AtomicBoolean + +/** + * Daily batch after plan day ends: at run time (default 00:00:15), process job orders whose + * [JobOrder.planStart] fell on the previous calendar day. + * + * - Branch A: no pick submitted, product process pending → hide job order. + * - Branch B: pick submitted, product process still pending → reschedule to today 00:00:00 and renumber. + */ +@Service +open class JobOrderPlanStartAutoService( + private val jobOrderRepository: JobOrderRepository, + private val pickOrderRepository: PickOrderRepository, + private val productProcessRepository: ProductProcessRepository, + private val stockInLineRepository: StockInLineRepository, + private val jobOrderService: JobOrderService, + private val stockInLineService: StockInLineService, + private val transactionTemplate: TransactionTemplate, +) { + private val logger = LoggerFactory.getLogger(javaClass) + private val inFlight = AtomicBoolean(false) + + data class JobOrderPlanStartAutoReport( + val runAt: LocalDateTime, + val targetPlanDayFrom: LocalDateTime, + val targetPlanDayToExclusive: LocalDateTime, + val candidates: Int = 0, + val hidden: Int = 0, + val rescheduled: Int = 0, + val skipped: Int = 0, + val errors: Int = 0, + ) + + open fun runAutoProcess(runAt: LocalDateTime = LocalDateTime.now()): JobOrderPlanStartAutoReport { + if (!inFlight.compareAndSet(false, true)) { + logger.warn("Job order plan-start auto process skipped: previous run still in flight") + val targetDay = runAt.toLocalDate().minusDays(1) + return JobOrderPlanStartAutoReport( + runAt = runAt, + targetPlanDayFrom = targetDay.atStartOfDay(), + targetPlanDayToExclusive = targetDay.plusDays(1).atStartOfDay(), + ) + } + try { + return runAutoProcessInternal(runAt) + } finally { + inFlight.set(false) + } + } + + private fun runAutoProcessInternal(runAt: LocalDateTime): JobOrderPlanStartAutoReport { + val targetDay = runAt.toLocalDate().minusDays(1) + val from = targetDay.atStartOfDay() + val toExclusive = targetDay.plusDays(1).atStartOfDay() + val newPlanStart = runAt.toLocalDate().atStartOfDay() + + var hidden = 0 + var rescheduled = 0 + var skipped = 0 + var errors = 0 + + val jobOrders = jobOrderRepository + .findByDeletedFalseAndPlanStartFromBeforeExclusiveOrderByIdAsc(from, toExclusive) + .filter { isEligibleCandidate(it) } + + val joIds = jobOrders.mapNotNull { it.id } + val pickOrdersByJoId = loadPickOrdersByJobOrderId(joIds) + val productProcessesByJoId = loadProductProcessesByJobOrderId(joIds) + + logger.info( + "Job order plan-start auto: runAt={}, targetPlanDay=[{}, {}), candidates={}", + runAt, + from, + toExclusive, + jobOrders.size, + ) + + for (jo in jobOrders) { + val joId = jo.id ?: continue + try { + when ( + classify( + jo, + pickOrdersByJoId[joId].orEmpty(), + productProcessesByJoId[joId], + ) + ) { + Branch.HIDE -> { + transactionTemplate.executeWithoutResult { + applyHide(jo, runAt) + } + hidden++ + } + Branch.RESCHEDULE -> { + transactionTemplate.executeWithoutResult { + applyReschedule(jo, productProcessesByJoId[joId], newPlanStart, runAt) + } + rescheduled++ + } + Branch.SKIP -> skipped++ + } + } catch (e: Exception) { + errors++ + logger.error("Job order plan-start auto failed for joId={} code={}: {}", joId, jo.code, e.message, e) + } + } + + val report = JobOrderPlanStartAutoReport( + runAt = runAt, + targetPlanDayFrom = from, + targetPlanDayToExclusive = toExclusive, + candidates = jobOrders.size, + hidden = hidden, + rescheduled = rescheduled, + skipped = skipped, + errors = errors, + ) + logger.info("Job order plan-start auto finished: {}", report) + return report + } + + private fun isEligibleCandidate(jo: JobOrder): Boolean { + if (jo.isHidden == true) return false + if (jo.status == JobOrderStatus.COMPLETED) return false + return true + } + + private enum class Branch { + HIDE, + RESCHEDULE, + SKIP, + } + + private fun classify( + jo: JobOrder, + pickOrders: List, + productProcess: ProductProcess?, + ): Branch { + if (!isProductProcessPendingNotStarted(productProcess)) { + return Branch.SKIP + } + val maxSubmittedLines = pickOrders.maxOfOrNull { it.submittedLines ?: 0 } ?: 0 + return when { + maxSubmittedLines == 0 -> Branch.HIDE + maxSubmittedLines > 0 -> Branch.RESCHEDULE + else -> Branch.SKIP + } + } + + private fun isProductProcessPendingNotStarted(productProcess: ProductProcess?): Boolean { + if (productProcess == null) return false + if (productProcess.deleted) return false + if (productProcess.status != ProductProcessStatus.PENDING) return false + if (productProcess.startTime != null) return false + return true + } + + private fun loadPickOrdersByJobOrderId(jobOrderIds: List): Map> { + if (jobOrderIds.isEmpty()) return emptyMap() + return pickOrderRepository + .findAllByJobOrder_IdInAndDeletedFalseOrderByJobOrder_IdAscCreatedDesc(jobOrderIds) + .groupBy { it.jobOrder?.id ?: -1L } + .filterKeys { it > 0L } + } + + private fun loadProductProcessesByJobOrderId(jobOrderIds: List): Map { + if (jobOrderIds.isEmpty()) return emptyMap() + return productProcessRepository + .findByJobOrder_IdInAndDeletedIsFalse(jobOrderIds) + .mapNotNull { pp -> pp.jobOrder?.id?.let { it to pp } } + .groupBy { it.first } + .mapValues { (_, entries) -> + entries.map { it.second }.firstOrNull { isProductProcessPendingNotStarted(it) } + ?: entries.map { it.second }.first() + } + .filterValues { it != null } + .mapValues { it.value!! } + } + + private fun applyHide(jo: JobOrder, runAt: LocalDateTime) { + jo.isHidden = true + appendRemarks(jo, "[auto ${runAt.toLocalDate()}] hidden: overdue plan day, no pick submitted, process pending") + jobOrderRepository.save(jo) + logger.info("Job order plan-start auto hid joId={} code={}", jo.id, jo.code) + } + + private fun applyReschedule( + jo: JobOrder, + productProcess: ProductProcess?, + newPlanStart: LocalDateTime, + runAt: LocalDateTime, + ) { + val pp = productProcess?.takeIf { isProductProcessPendingNotStarted(it) } + ?: throw IllegalStateException("Product process not pending for reschedule, joId=${jo.id}") + + val newCode = jobOrderService.assignJobNo(newPlanStart) + jo.planStart = newPlanStart + jo.code = newCode + appendRemarks( + jo, + "[auto ${runAt.toLocalDate()}] rescheduled from overdue plan day; pick started, process pending", + ) + jobOrderRepository.save(jo) + + pp.date = newPlanStart.toLocalDate() + productProcessRepository.save(pp) + + val sil = jo.id?.let { stockInLineRepository.findFirstByJobOrder_IdAndDeletedFalse(it) } + if (sil != null) { + sil.lotNo = stockInLineService.assignLotNoForJo(newPlanStart.toLocalDate()) + sil.productLotNo = newCode + stockInLineRepository.save(sil) + } + + logger.info( + "Job order plan-start auto rescheduled joId={} newCode={} newPlanStart={}", + jo.id, + newCode, + newPlanStart, + ) + } + + private fun appendRemarks(jo: JobOrder, snippet: String) { + val existing = jo.remarks?.trim().orEmpty() + jo.remarks = if (existing.isEmpty()) snippet else "$existing | $snippet" + } +} diff --git a/src/main/java/com/ffii/fpsms/modules/jobOrder/service/PlasticBagPrinterService.kt b/src/main/java/com/ffii/fpsms/modules/jobOrder/service/PlasticBagPrinterService.kt index fcad435..78dcb59 100644 --- a/src/main/java/com/ffii/fpsms/modules/jobOrder/service/PlasticBagPrinterService.kt +++ b/src/main/java/com/ffii/fpsms/modules/jobOrder/service/PlasticBagPrinterService.kt @@ -1367,15 +1367,18 @@ class PlasticBagPrinterService( } val qrValue = zplEscape(qrPayload) - // Must match python Bag2.py generate_zpl_dataflex() + // Must match python Bag3.py generate_zpl_dataflex() field layout / fonts. val fontRegular = "E:STXihei.ttf" val fontBold = "E:STXihei.ttf" + // Match python Bag3.py DataFlex defaults: narrower ^PW so job preview is not mostly empty on the right (^A@R fields are tall, not wide). + val labelPw = 400 + val labelLl = 500 return """ ^XA ^CI28 - ^PW700 - ^LL500 + ^PW$labelPw + ^LL$labelLl ^PO N ^FO10,20 ^BQN,2,4^FDQA,$qrValue^FS diff --git a/src/main/java/com/ffii/fpsms/modules/logistic/entity/Logistic.kt b/src/main/java/com/ffii/fpsms/modules/logistic/entity/Logistic.kt new file mode 100644 index 0000000..f96f0fc --- /dev/null +++ b/src/main/java/com/ffii/fpsms/modules/logistic/entity/Logistic.kt @@ -0,0 +1,33 @@ +package com.ffii.fpsms.modules.logistic.entity + +import com.ffii.core.entity.BaseEntity +import jakarta.persistence.Column +import jakarta.persistence.Entity +import jakarta.persistence.Table +import jakarta.validation.constraints.NotNull +import jakarta.validation.constraints.Size + +@Entity +@Table(name = "logistic") +open class Logistic : BaseEntity() { + + @field:NotNull + @field:Size(max = 255) + @Column(name = "logisticName", nullable = false, length = 255) + open var logisticName: String? = null + + @field:NotNull + @field:Size(max = 50) + @Column(name = "carPlate", nullable = false, length = 50) + open var carPlate: String? = null + + @field:NotNull + @field:Size(max = 255) + @Column(name = "driverName", nullable = false, length = 255) + open var driverName: String? = null + + @field:NotNull + @Column(name = "driverNumber", nullable = false) + open var driverNumber: Int? = null +} + diff --git a/src/main/java/com/ffii/fpsms/modules/logistic/entity/LogisticRepository.kt b/src/main/java/com/ffii/fpsms/modules/logistic/entity/LogisticRepository.kt new file mode 100644 index 0000000..4304fbb --- /dev/null +++ b/src/main/java/com/ffii/fpsms/modules/logistic/entity/LogisticRepository.kt @@ -0,0 +1,12 @@ +package com.ffii.fpsms.modules.logistic.entity + +import com.ffii.core.support.AbstractRepository +import org.springframework.stereotype.Repository + +@Repository +interface LogisticRepository : AbstractRepository { + fun findAllByDeletedFalseOrderByIdAsc(): List + fun findByIdAndDeletedFalse(id: Long): Logistic? + fun findByCarPlateAndDeletedFalse(carPlate: String): Logistic? +} + diff --git a/src/main/java/com/ffii/fpsms/modules/logistic/service/LogisticService.kt b/src/main/java/com/ffii/fpsms/modules/logistic/service/LogisticService.kt new file mode 100644 index 0000000..807d4c5 --- /dev/null +++ b/src/main/java/com/ffii/fpsms/modules/logistic/service/LogisticService.kt @@ -0,0 +1,82 @@ +package com.ffii.fpsms.modules.logistic.service + +import com.ffii.fpsms.modules.logistic.entity.Logistic +import com.ffii.fpsms.modules.logistic.entity.LogisticRepository +import com.ffii.fpsms.modules.logistic.web.models.SaveLogisticRequest +import jakarta.transaction.Transactional +import org.springframework.stereotype.Service +import org.springframework.web.server.ResponseStatusException +import org.springframework.http.HttpStatus + +@Service +open class LogisticService( + private val logisticRepository: LogisticRepository, +) { + open fun findAll(): List { + return logisticRepository.findAllByDeletedFalseOrderByIdAsc() + } + + open fun findById(id: Long): Logistic? { + return logisticRepository.findByIdAndDeletedFalse(id) + } + + open fun requireById(id: Long): Logistic { + return logisticRepository.findByIdAndDeletedFalse(id) + ?: throw ResponseStatusException(HttpStatus.NOT_FOUND, "Logistic not found with id: $id") + } + + @Transactional + open fun save(request: SaveLogisticRequest): Logistic { + val entity = request.id?.let { requireById(it) } ?: Logistic() + + entity.apply { + logisticName = request.logisticName.trim() + carPlate = request.carPlate.trim() + driverName = request.driverName.trim() + driverNumber = request.driverNumber + } + + return logisticRepository.save(entity) + } + + /** + * 批次「新增」物流主檔:同一交易內寫入,任一筆失敗則整批 rollback。 + * 供看板一次儲存多筆暫存主檔,避免逐筆 POST 中途失敗留下孤兒列。 + */ + @Transactional + open fun saveBatchCreate(requests: List): List { + if (requests.isEmpty()) return emptyList() + if (requests.size > 100) { + throw ResponseStatusException( + HttpStatus.BAD_REQUEST, + "Batch size exceeds limit (100)", + ) + } + requests.forEach { r -> + if (r.id != null) { + throw ResponseStatusException( + HttpStatus.BAD_REQUEST, + "save-batch only accepts new rows (id must be null)", + ) + } + } + return requests.map { req -> + val entity = Logistic().apply { + logisticName = req.logisticName.trim() + carPlate = req.carPlate.trim() + driverName = req.driverName.trim() + driverNumber = req.driverNumber + } + logisticRepository.save(entity) + } + } + + @Transactional + open fun deleteById(id: Long): String { + val entity = requireById(id) + entity.deleted = true + logisticRepository.save(entity) + return "Logistic deleted successfully with id: $id" + } +} + diff --git a/src/main/java/com/ffii/fpsms/modules/logistic/web/LogisticController.kt b/src/main/java/com/ffii/fpsms/modules/logistic/web/LogisticController.kt new file mode 100644 index 0000000..2e65e06 --- /dev/null +++ b/src/main/java/com/ffii/fpsms/modules/logistic/web/LogisticController.kt @@ -0,0 +1,65 @@ +package com.ffii.fpsms.modules.logistic.web + +import com.ffii.fpsms.modules.logistic.service.LogisticService +import com.ffii.fpsms.modules.logistic.web.models.DeleteLogisticRequest +import com.ffii.fpsms.modules.logistic.web.models.LogisticResponse +import com.ffii.fpsms.modules.logistic.web.models.SaveLogisticRequest +import com.ffii.fpsms.modules.logistic.web.models.SaveLogisticsBatchRequest +import com.ffii.fpsms.modules.master.web.models.MessageResponse +import jakarta.validation.Valid +import org.springframework.http.ResponseEntity +import org.springframework.web.bind.annotation.* + +@RestController +@RequestMapping("/logistic") +class LogisticController( + private val logisticService: LogisticService, +) { + @GetMapping("/all") + fun findAll(): List { + return logisticService.findAll().map { it.toResponse() } + } + + @GetMapping("/{id}") + fun findById(@PathVariable id: Long): LogisticResponse { + return logisticService.requireById(id).toResponse() + } + + @PostMapping("/save") + fun save(@Valid @RequestBody request: SaveLogisticRequest): LogisticResponse { + return logisticService.save(request).toResponse() + } + + /** 批次新增主檔;單一 transaction,與 [save] 分開避免誤用 id 更新混進批次。 */ + @PostMapping("/save-batch") + fun saveBatch(@Valid @RequestBody body: SaveLogisticsBatchRequest): List { + return logisticService.saveBatchCreate(body.items).map { it.toResponse() } + } + + @PostMapping("/delete") + fun delete(@Valid @RequestBody request: DeleteLogisticRequest): ResponseEntity { + val result = logisticService.deleteById(request.id) + return ResponseEntity.ok( + MessageResponse( + id = request.id, + name = null, + code = null, + type = "logistic", + message = result, + errorPosition = null, + entity = null, + ) + ) + } + + private fun com.ffii.fpsms.modules.logistic.entity.Logistic.toResponse(): LogisticResponse { + return LogisticResponse( + id = this.id ?: 0L, + logisticName = this.logisticName ?: "", + carPlate = this.carPlate ?: "", + driverName = this.driverName ?: "", + driverNumber = this.driverNumber ?: 0, + ) + } +} + diff --git a/src/main/java/com/ffii/fpsms/modules/logistic/web/models/DeleteLogisticRequest.kt b/src/main/java/com/ffii/fpsms/modules/logistic/web/models/DeleteLogisticRequest.kt new file mode 100644 index 0000000..3b0eb42 --- /dev/null +++ b/src/main/java/com/ffii/fpsms/modules/logistic/web/models/DeleteLogisticRequest.kt @@ -0,0 +1,9 @@ +package com.ffii.fpsms.modules.logistic.web.models + +import jakarta.validation.constraints.NotNull + +data class DeleteLogisticRequest( + @field:NotNull + val id: Long, +) + diff --git a/src/main/java/com/ffii/fpsms/modules/logistic/web/models/LogisticResponse.kt b/src/main/java/com/ffii/fpsms/modules/logistic/web/models/LogisticResponse.kt new file mode 100644 index 0000000..c991245 --- /dev/null +++ b/src/main/java/com/ffii/fpsms/modules/logistic/web/models/LogisticResponse.kt @@ -0,0 +1,10 @@ +package com.ffii.fpsms.modules.logistic.web.models + +data class LogisticResponse( + val id: Long, + val logisticName: String, + val carPlate: String, + val driverName: String, + val driverNumber: Int, +) + diff --git a/src/main/java/com/ffii/fpsms/modules/logistic/web/models/SaveLogisticRequest.kt b/src/main/java/com/ffii/fpsms/modules/logistic/web/models/SaveLogisticRequest.kt new file mode 100644 index 0000000..e82763b --- /dev/null +++ b/src/main/java/com/ffii/fpsms/modules/logistic/web/models/SaveLogisticRequest.kt @@ -0,0 +1,21 @@ +package com.ffii.fpsms.modules.logistic.web.models + +import jakarta.validation.constraints.NotBlank +import jakarta.validation.constraints.NotNull +import jakarta.validation.constraints.Size + +data class SaveLogisticRequest( + val id: Long? = null, + @field:NotBlank + @field:Size(max = 255) + val logisticName: String, + @field:NotBlank + @field:Size(max = 50) + val carPlate: String, + @field:NotBlank + @field:Size(max = 255) + val driverName: String, + @field:NotNull + val driverNumber: Int, +) + diff --git a/src/main/java/com/ffii/fpsms/modules/logistic/web/models/SaveLogisticsBatchRequest.kt b/src/main/java/com/ffii/fpsms/modules/logistic/web/models/SaveLogisticsBatchRequest.kt new file mode 100644 index 0000000..57445dd --- /dev/null +++ b/src/main/java/com/ffii/fpsms/modules/logistic/web/models/SaveLogisticsBatchRequest.kt @@ -0,0 +1,12 @@ + package com.ffii.fpsms.modules.logistic.web.models + +import jakarta.validation.Valid +import jakarta.validation.constraints.NotEmpty +import jakarta.validation.constraints.Size + +data class SaveLogisticsBatchRequest( + @field:NotEmpty + @field:Size(max = 100) + @field:Valid + val items: List, +) diff --git a/src/main/java/com/ffii/fpsms/modules/master/entity/ShopRepository.kt b/src/main/java/com/ffii/fpsms/modules/master/entity/ShopRepository.kt index 6986125..5fae1eb 100644 --- a/src/main/java/com/ffii/fpsms/modules/master/entity/ShopRepository.kt +++ b/src/main/java/com/ffii/fpsms/modules/master/entity/ShopRepository.kt @@ -6,6 +6,7 @@ import com.ffii.fpsms.modules.master.entity.projections.ShopCombo import com.ffii.fpsms.modules.master.enums.ShopType import com.ffii.fpsms.modules.pickOrder.entity.Truck import org.springframework.data.jpa.repository.Query +import org.springframework.data.repository.query.Param import org.springframework.stereotype.Repository @Repository @@ -30,6 +31,15 @@ interface ShopRepository : AbstractRepository { fun findByCode(code: String): Shop? + @Query( + """ + SELECT s FROM Shop s + WHERE s.deleted = false + AND s.code IN :codes + """ + ) + fun findAllByCodeInAndDeletedIsFalse(@Param("codes") codes: Collection): List + @Query( nativeQuery = true, value = """ diff --git a/src/main/java/com/ffii/fpsms/modules/master/service/BomService.kt b/src/main/java/com/ffii/fpsms/modules/master/service/BomService.kt index 47bc322..b10e26f 100644 --- a/src/main/java/com/ffii/fpsms/modules/master/service/BomService.kt +++ b/src/main/java/com/ffii/fpsms/modules/master/service/BomService.kt @@ -34,6 +34,15 @@ import java.util.Comparator import com.ffii.fpsms.modules.jobOrder.entity.JobOrderRepository import com.ffii.fpsms.modules.productProcess.entity.ProductProcessRepository import org.springframework.transaction.annotation.Transactional +import com.fasterxml.jackson.databind.ObjectMapper +import com.ffii.fpsms.m18.service.M18BomForShopService +import com.ffii.fpsms.m18.model.M18BomShopSyncTriggerResult +import com.ffii.fpsms.m18.model.GoodsReceiptNoteResponse +import com.ffii.fpsms.m18.entity.M18BomShopSyncLog +import com.ffii.fpsms.m18.entity.M18BomShopSyncLogRepository +import com.ffii.fpsms.modules.common.SettingNames +import com.ffii.fpsms.modules.settings.entity.Settings +import com.ffii.fpsms.modules.settings.service.SettingsService @Service open class BomService( @@ -52,6 +61,10 @@ open class BomService( private val itemUomService: ItemUomService, private val jobOrderRepository: JobOrderRepository, private val productProcessRepository: ProductProcessRepository, + private val m18BomForShopService: M18BomForShopService, + private val m18BomShopSyncLogRepository: M18BomShopSyncLogRepository, + private val objectMapper: ObjectMapper, + private val settingsService: SettingsService, @Value("\${bom.import.temp-dir:\${java.io.tmpdir}/fpsms-bom-import}") private val bomImportTempDir: String, ) { open fun uploadBomFiles(files: List): BomUploadResponse { @@ -119,6 +132,29 @@ open class BomService( ?: bomRepository.findAllByItemIdAndDeletedIsFalse(itemId).firstOrNull() } + /** Resolve BOM header for a finished-good item code ([Items.code] on [Bom.item]). */ + open fun findBomSummaryByItemCode(itemCodeTrimmed: String): BomIdByItemCodeResponse { + val code = itemCodeTrimmed.trim() + val item = itemsRepository.findByCodeAndDeletedFalse(code) + ?: return BomIdByItemCodeResponse( + itemCode = code, + message = "Item not found for code", + ) + val bom = findByItemId(item.id!!) + ?: return BomIdByItemCodeResponse( + itemCode = code, + itemId = item.id, + message = "No BOM linked to this item", + ) + return BomIdByItemCodeResponse( + itemCode = code, + itemId = item.id, + bomId = bom.id, + bomCode = bom.code, + bomM18Id = bom.m18Id, + ) + } + open fun saveBom(request: SaveBomRequest): SaveBomResponse { val item = request.code.let { itemsService.findByM18BomCode(it) } ?: request.itemId?.let { itemsService.findById(it) } @@ -371,6 +407,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() diff --git a/src/main/java/com/ffii/fpsms/modules/master/service/ItemUomService.kt b/src/main/java/com/ffii/fpsms/modules/master/service/ItemUomService.kt index adc2179..275b80c 100644 --- a/src/main/java/com/ffii/fpsms/modules/master/service/ItemUomService.kt +++ b/src/main/java/com/ffii/fpsms/modules/master/service/ItemUomService.kt @@ -282,6 +282,20 @@ open class ItemUomService( return finalizePreciseStockQty(stockUnit, stockQty) } + /** + * Convert quantity from [uomId] (must exist on `item_uom` for [itemId]) to the item's **base unit** quantity. + * Returns null when no `item_uom` row links the item to that UOM. + */ + open fun convertQtyToBaseQtyPrecise(itemId: Long, uomId: Long, sourceQty: BigDecimal): BigDecimal? { + val itemUom = findFirstByItemIdAndUomId(itemId, uomId) ?: return null + val one = BigDecimal.ONE + val calcScale = 10 + return sourceQty + .multiply(itemUom.ratioN ?: one) + .divide(itemUom.ratioD ?: one, calcScale, RoundingMode.HALF_UP) + .stripTrailingZeros() + } + // See if need to update the response open fun saveItemUom(request: ItemUomRequest): ItemUom { val itemUom = request.m18Id?.let { itemUomRespository.findFirstByM18IdOrderByIdDesc(it) } diff --git a/src/main/java/com/ffii/fpsms/modules/master/web/BomController.kt b/src/main/java/com/ffii/fpsms/modules/master/web/BomController.kt index 79be196..5e97b94 100644 --- a/src/main/java/com/ffii/fpsms/modules/master/web/BomController.kt +++ b/src/main/java/com/ffii/fpsms/modules/master/web/BomController.kt @@ -29,6 +29,8 @@ import com.ffii.fpsms.modules.master.web.models.ImportBomRequestPayload import com.ffii.fpsms.modules.master.web.models.BomDetailResponse import com.ffii.fpsms.modules.master.web.models.EditBomRequest import com.ffii.fpsms.modules.master.web.models.BomExcelCheckProgress +import com.ffii.fpsms.modules.master.web.models.BomIdByItemCodeResponse +import com.ffii.core.exception.BadRequestException import java.util.logging.Logger import java.nio.file.Files import org.springframework.core.io.FileSystemResource @@ -120,6 +122,16 @@ fun downloadBomFormatIssueLog( // fun exportProblematicBom() { // return bomService.importBOM() // } + + /** Testing: FPSMS BOM id by finished-good item code (same item as BOM header). */ + @GetMapping("/by-item-code") + fun getBomByItemCode(@RequestParam code: String): BomIdByItemCodeResponse { + if (code.isBlank()) { + throw BadRequestException("query parameter code is required") + } + return bomService.findBomSummaryByItemCode(code.trim()) + } + @GetMapping("/{id}/detail") fun getBomDetail(@PathVariable id: Long): BomDetailResponse { return bomService.getBomDetail(id) diff --git a/src/main/java/com/ffii/fpsms/modules/master/web/models/BomIdByItemCodeResponse.kt b/src/main/java/com/ffii/fpsms/modules/master/web/models/BomIdByItemCodeResponse.kt new file mode 100644 index 0000000..da7f821 --- /dev/null +++ b/src/main/java/com/ffii/fpsms/modules/master/web/models/BomIdByItemCodeResponse.kt @@ -0,0 +1,14 @@ +package com.ffii.fpsms.modules.master.web.models + +/** + * Testing / lookup: resolve FPSMS BOM from finished-good [item] code (bom.item → [Items.code]). + */ +data class BomIdByItemCodeResponse( + val itemCode: String, + val itemId: Long? = null, + val bomId: Long? = null, + val bomCode: String? = null, + val bomM18Id: Long? = null, + /** e.g. item not found, or no BOM for item */ + val message: String? = null, +) diff --git a/src/main/java/com/ffii/fpsms/modules/pickOrder/entity/Truck.kt b/src/main/java/com/ffii/fpsms/modules/pickOrder/entity/Truck.kt index 976ac97..14a5417 100644 --- a/src/main/java/com/ffii/fpsms/modules/pickOrder/entity/Truck.kt +++ b/src/main/java/com/ffii/fpsms/modules/pickOrder/entity/Truck.kt @@ -1,6 +1,7 @@ package com.ffii.fpsms.modules.pickOrder.entity import com.ffii.core.entity.BaseEntity +import com.ffii.fpsms.modules.logistic.entity.Logistic import com.ffii.fpsms.modules.master.entity.Shop import jakarta.persistence.* import jakarta.validation.constraints.NotNull @@ -42,4 +43,8 @@ open class Truck : BaseEntity() { @Column(name = "remark") open var remark: String? = null -} \ No newline at end of file + @ManyToOne(fetch = FetchType.LAZY) + @JoinColumn(name = "logisticId") + open var logistic: Logistic? = null + +} diff --git a/src/main/java/com/ffii/fpsms/modules/pickOrder/entity/TruckLaneVersion.kt b/src/main/java/com/ffii/fpsms/modules/pickOrder/entity/TruckLaneVersion.kt new file mode 100644 index 0000000..bf3e5f6 --- /dev/null +++ b/src/main/java/com/ffii/fpsms/modules/pickOrder/entity/TruckLaneVersion.kt @@ -0,0 +1,19 @@ +package com.ffii.fpsms.modules.pickOrder.entity + +import com.ffii.core.entity.BaseEntity +import jakarta.persistence.* +import jakarta.validation.constraints.Size + +@Entity +@Table(name = "truck_lane_version") +open class TruckLaneVersion : BaseEntity() { + + @field:Size(max = 100) + @Column(name = "truckLanceCode", nullable = true, length = 100) + open var truckLanceCode: String? = null + + @field:Size(max = 500) + @Column(name = "note", length = 500) + open var note: String? = null +} + diff --git a/src/main/java/com/ffii/fpsms/modules/pickOrder/entity/TruckLaneVersionLine.kt b/src/main/java/com/ffii/fpsms/modules/pickOrder/entity/TruckLaneVersionLine.kt new file mode 100644 index 0000000..bf83c01 --- /dev/null +++ b/src/main/java/com/ffii/fpsms/modules/pickOrder/entity/TruckLaneVersionLine.kt @@ -0,0 +1,55 @@ +package com.ffii.fpsms.modules.pickOrder.entity + +import com.ffii.core.entity.BaseEntity +import jakarta.persistence.* +import jakarta.validation.constraints.NotNull +import jakarta.validation.constraints.Size + +@Entity +@Table(name = "truck_lane_version_line") +open class TruckLaneVersionLine : BaseEntity() { + + @ManyToOne(fetch = FetchType.LAZY) + @JoinColumn(name = "truckLaneVersionId", nullable = false) + open var truckLaneVersion: TruckLaneVersion? = null + + @field:NotNull + @Column(name = "truckRowId", nullable = false) + open var truckRowId: Long? = null + + @field:Size(max = 100) + @Column(name = "truckLanceCode", length = 100) + open var truckLanceCode: String? = null + + @field:Size(max = 50) + @Column(name = "shopCode", length = 50) + open var shopCode: String? = null + + @field:Size(max = 255) + @Column(name = "branchName", length = 255) + open var branchName: String? = null + + @field:Size(max = 255) + @Column(name = "districtReference", length = 255) + open var districtReference: String? = null + + @Column(name = "loadingSequence") + open var loadingSequence: Int? = null + + @field:Size(max = 30) + @Column(name = "departureTime", length = 30) + open var departureTime: String? = null + + @field:NotNull + @field:Size(max = 10) + @Column(name = "storeId", nullable = false, length = 10) + open var storeId: String? = null + + @field:Size(max = 255) + @Column(name = "remark", length = 255) + open var remark: String? = null + + @Column(name = "logisticId") + open var logisticId: Long? = null +} + diff --git a/src/main/java/com/ffii/fpsms/modules/pickOrder/entity/TruckLaneVersionLineRepository.kt b/src/main/java/com/ffii/fpsms/modules/pickOrder/entity/TruckLaneVersionLineRepository.kt new file mode 100644 index 0000000..7963ae8 --- /dev/null +++ b/src/main/java/com/ffii/fpsms/modules/pickOrder/entity/TruckLaneVersionLineRepository.kt @@ -0,0 +1,10 @@ +package com.ffii.fpsms.modules.pickOrder.entity + +import com.ffii.core.support.AbstractRepository +import org.springframework.stereotype.Repository + +@Repository +interface TruckLaneVersionLineRepository : AbstractRepository { + fun findAllByTruckLaneVersion_IdAndDeletedFalseOrderByIdAsc(truckLaneVersionId: Long): List +} + diff --git a/src/main/java/com/ffii/fpsms/modules/pickOrder/entity/TruckLaneVersionRepository.kt b/src/main/java/com/ffii/fpsms/modules/pickOrder/entity/TruckLaneVersionRepository.kt new file mode 100644 index 0000000..f69ecc6 --- /dev/null +++ b/src/main/java/com/ffii/fpsms/modules/pickOrder/entity/TruckLaneVersionRepository.kt @@ -0,0 +1,12 @@ +package com.ffii.fpsms.modules.pickOrder.entity + +import com.ffii.core.support.AbstractRepository +import org.springframework.stereotype.Repository + +@Repository +interface TruckLaneVersionRepository : AbstractRepository { + fun findAllByTruckLanceCodeAndDeletedFalseOrderByCreatedDesc(truckLanceCode: String): List + fun findAllByDeletedFalseOrderByCreatedDesc(): List + fun findByIdAndDeletedFalse(id: Long): TruckLaneVersion? +} + diff --git a/src/main/java/com/ffii/fpsms/modules/pickOrder/entity/TruckRepository.kt b/src/main/java/com/ffii/fpsms/modules/pickOrder/entity/TruckRepository.kt index c6d4277..116724e 100644 --- a/src/main/java/com/ffii/fpsms/modules/pickOrder/entity/TruckRepository.kt +++ b/src/main/java/com/ffii/fpsms/modules/pickOrder/entity/TruckRepository.kt @@ -1,6 +1,8 @@ package com.ffii.fpsms.modules.pickOrder.entity import com.ffii.core.support.AbstractRepository +import com.ffii.fpsms.modules.logistic.entity.Logistic +import org.springframework.data.jpa.repository.Modifying import org.springframework.data.jpa.repository.Query import org.springframework.data.repository.query.Param import org.springframework.stereotype.Repository @@ -32,8 +34,81 @@ interface TruckRepository : AbstractRepository { fun findByTruckLanceCode(truckLanceCode: String): Truck? @Query("SELECT t FROM Truck t WHERE t.truckLanceCode = :truckLanceCode AND t.deleted = false") fun findAllByTruckLanceCodeAndDeletedFalse(@Param("truckLanceCode") truckLanceCode: String): List + + /** + * Same lane group as `findAllUniqueTruckLanceCodeAndRemarkCombinations`: + * remark NULL / blank belong to one bucket; non-blank matches exactly. + */ + @Query( + """ + SELECT DISTINCT t FROM Truck t + LEFT JOIN FETCH t.logistic + LEFT JOIN FETCH t.shop + WHERE t.truckLanceCode = :truckLanceCode + AND t.deleted = false + AND ( + (:blankRemark = true AND (t.remark IS NULL OR trim(t.remark) = '')) + OR (:blankRemark = false AND trim(t.remark) = :exactRemark) + ) + """ + ) + fun findAllByTruckLanceCodeAndRemarkAndDeletedFalse( + @Param("truckLanceCode") truckLanceCode: String, + @Param("blankRemark") blankRemark: Boolean, + @Param("exactRemark") exactRemark: String?, + ): List + + /** + * RouteBoard O(1) load: return all truck rows used by lanes, with logistic pre-fetched. + * Frontend groups by (truckLanceCode, normalizedRemark) where normalizedRemark is: + * - NULL / blank => "" + * - else TRIM(remark) + */ + @Query( + """ + SELECT t FROM Truck t + LEFT JOIN FETCH t.logistic + LEFT JOIN FETCH t.shop + WHERE t.deleted = false + AND t.truckLanceCode IS NOT NULL + AND trim(t.truckLanceCode) <> '' + ORDER BY t.truckLanceCode ASC, + CASE WHEN t.remark IS NULL OR trim(t.remark) = '' THEN '' ELSE trim(t.remark) END ASC, + t.loadingSequence ASC, + t.id ASC + """ + ) + fun findAllForRouteBoard(): List + + /** + * 單一 UPDATE 寫入整條 lane 的 logistic,避免先 JOIN FETCH 載入再逐列 save(大車線會極慢)。 + */ + @Modifying(clearAutomatically = true, flushAutomatically = true) + @Query( + """ + UPDATE Truck t SET t.logistic = :logistic + WHERE t.truckLanceCode = :truckLanceCode + AND t.deleted = false + AND ( + (:blankRemark = true AND (t.remark IS NULL OR trim(t.remark) = '')) + OR (:blankRemark = false AND trim(t.remark) = :exactRemark) + ) + """, + ) + fun bulkUpdateLogisticForLaneGroup( + @Param("logistic") logistic: Logistic?, + @Param("truckLanceCode") truckLanceCode: String, + @Param("blankRemark") blankRemark: Boolean, + @Param("exactRemark") exactRemark: String?, + ): Int + + fun findAllByTruckLanceCodeAndStoreIdAndDeletedFalse(truckLanceCode: String, storeId: String): List fun findByShopNameAndStoreIdAndTruckLanceCode(shopName: String, storeId: String, truckLanceCode: String): Truck? - fun findByShopCodeAndStoreId(shopCode: String, storeId: String): Truck? + /** 同店同樓層重複列時取 id 最小一筆,避免 NonUniqueResultException */ + fun findFirstByShopCodeAndStoreIdAndDeletedFalseOrderByIdAsc( + shopCode: String, + storeId: String, + ): Truck? fun findByShopIdAndStoreId(shopId: Long, storeId: String): Truck? @@ -60,15 +135,17 @@ fun findByShopIdAndStoreIdAndDayOfWeek( SELECT t.* FROM truck t INNER JOIN ( - SELECT TruckLanceCode, remark, MIN(id) as min_id + SELECT TruckLanceCode, + COALESCE(NULLIF(TRIM(remark), ''), '') AS remark_norm, + MIN(id) AS min_id FROM truck WHERE deleted = false AND TruckLanceCode IS NOT NULL - GROUP BY TruckLanceCode, remark + GROUP BY TruckLanceCode, COALESCE(NULLIF(TRIM(remark), ''), '') ) AS unique_combos ON t.id = unique_combos.min_id WHERE t.deleted = false - ORDER BY t.TruckLanceCode, t.remark + ORDER BY t.TruckLanceCode, COALESCE(NULLIF(TRIM(t.remark), ''), '') """ ) fun findAllUniqueTruckLanceCodeAndRemarkCombinations(): List diff --git a/src/main/java/com/ffii/fpsms/modules/pickOrder/service/HierarchicalFgPayloadAssembler.kt b/src/main/java/com/ffii/fpsms/modules/pickOrder/service/HierarchicalFgPayloadAssembler.kt index 5eb8f01..170fff1 100644 --- a/src/main/java/com/ffii/fpsms/modules/pickOrder/service/HierarchicalFgPayloadAssembler.kt +++ b/src/main/java/com/ffii/fpsms/modules/pickOrder/service/HierarchicalFgPayloadAssembler.kt @@ -240,6 +240,9 @@ ORDER BY "id" to row["stockOutLineId"], "status" to row["stockOutLineStatus"], "qty" to row["stockOutLineQty"], + "requiredQty" to row["requiredQty"], + "suggestedPickLotQty" to row["requiredQty"], + "suggestedPickLotId" to row["suggestedPickLotId"], "lotId" to lotId, "lotNo" to (row["lotNo"] ?: ""), "location" to (row["location"] ?: ""), diff --git a/src/main/java/com/ffii/fpsms/modules/pickOrder/service/RouteLaneExcelSupport.kt b/src/main/java/com/ffii/fpsms/modules/pickOrder/service/RouteLaneExcelSupport.kt new file mode 100644 index 0000000..2537a54 --- /dev/null +++ b/src/main/java/com/ffii/fpsms/modules/pickOrder/service/RouteLaneExcelSupport.kt @@ -0,0 +1,202 @@ +package com.ffii.fpsms.modules.pickOrder.service + +import org.apache.poi.ss.usermodel.BorderStyle +import org.apache.poi.ss.usermodel.FillPatternType +import org.apache.poi.ss.usermodel.HorizontalAlignment +import org.apache.poi.ss.usermodel.IndexedColors +import org.apache.poi.ss.usermodel.Sheet +import org.apache.poi.ss.usermodel.VerticalAlignment +import org.apache.poi.ss.usermodel.Workbook +import org.apache.poi.ss.util.CellRangeAddress +import org.apache.poi.ss.util.WorkbookUtil +import org.apache.poi.xssf.usermodel.XSSFCellStyle +import org.apache.poi.xssf.usermodel.XSSFFont +import org.apache.poi.xssf.usermodel.XSSFWorkbook +import java.net.URLDecoder +import java.nio.charset.StandardCharsets + +/** + * MTMS 車線 Excel(PDF 圖1):每個車線一個 worksheet,格式版本 MTMS_ROUTE_V1。 + * laneId 與前端 [encodeLaneId] 一致:`encodeURIComponent(code)|encodeURIComponent(remark)`。 + */ +object RouteLaneExcelSupport { + const val FORMAT_MARKER = "MTMS_ROUTE_V1" + const val SEP = "|" + + /** 0-based row indices */ + const val ROW_MARKER = 0 + const val ROW_STORE = 1 + const val ROW_DEPARTURE_DEFAULT = 2 + const val ROW_HEADER = 3 + const val ROW_FIRST_DATA = 4 + + const val COL_META_A = 0 + const val COL_META_B = 1 + const val COL_META_C = 2 + + const val COL_AREA_PLATE = 0 + const val COL_SHOP_NAME = 1 + const val COL_BRAND = 2 + const val COL_SHOP_CODE = 3 + const val COL_SCHEDULE = 4 + const val COL_DEPARTURE_ROW = 5 + + fun decodeLaneId(laneId: String): Pair? { + val i = laneId.indexOf(SEP) + if (i < 0) return null + return try { + val code = URLDecoder.decode(laneId.substring(0, i), StandardCharsets.UTF_8).trim() + val rem = URLDecoder.decode(laneId.substring(i + SEP.length), StandardCharsets.UTF_8).trim() + if (code.isEmpty()) return null + code to if (rem.isEmpty()) null else rem + } catch (_: Exception) { + null + } + } + + fun plateLabel(groupIndexZeroBased: Int): String { + val n = groupIndexZeroBased + 1 + val digits = arrayOf("一", "二", "三", "四", "五", "六", "七", "八", "九", "十") + val cn = when { + n in 1..10 -> digits[n - 1] + n in 11..19 -> "十" + digits[n - 11] + else -> "$n" + } + return "板$cn" + } + + fun uniqueSheetName(workbook: Workbook, truckLanceCode: String, remark: String?): String { + val remarkPart = remark?.trim()?.takeIf { it.isNotEmpty() }?.let { "_${it.take(8)}" } ?: "" + val raw = (truckLanceCode.take(22) + remarkPart).take(31) + var base = WorkbookUtil.createSafeSheetName(raw).take(31) + if (base.isEmpty()) base = "Lane" + var name = base + var i = 0 + while (workbook.getSheet(name) != null) { + val suffix = "_$i" + val truncated = base.take((31 - suffix.length).coerceAtLeast(1)) + name = WorkbookUtil.createSafeSheetName(truncated + suffix).take(31) + i++ + } + return name + } + + private data class RouteLaneExportStyles( + val metaKey: XSSFCellStyle, + val metaValue: XSSFCellStyle, + val header: XSSFCellStyle, + val data: XSSFCellStyle, + val dataAlt: XSSFCellStyle, + ) + + private fun buildExportStyles(wb: XSSFWorkbook): RouteLaneExportStyles { + fun XSSFCellStyle.borders() { + borderTop = BorderStyle.THIN + borderBottom = BorderStyle.THIN + borderLeft = BorderStyle.THIN + borderRight = BorderStyle.THIN + } + + val metaKey = (wb.createCellStyle() as XSSFCellStyle).apply { + alignment = HorizontalAlignment.LEFT + verticalAlignment = VerticalAlignment.CENTER + fillForegroundColor = IndexedColors.GREY_40_PERCENT.index + fillPattern = FillPatternType.SOLID_FOREGROUND + borders() + val f = wb.createFont() as XSSFFont + f.bold = true + f.fontHeightInPoints = 11 + setFont(f) + } + val metaValue = (wb.createCellStyle() as XSSFCellStyle).apply { + alignment = HorizontalAlignment.LEFT + verticalAlignment = VerticalAlignment.CENTER + fillForegroundColor = IndexedColors.WHITE.index + fillPattern = FillPatternType.SOLID_FOREGROUND + borders() + val f = wb.createFont() + f.fontHeightInPoints = 11 + setFont(f) + } + val headerFont = (wb.createFont() as XSSFFont).apply { + bold = true + fontHeightInPoints = 11 + color = IndexedColors.WHITE.index + } + val header = (wb.createCellStyle() as XSSFCellStyle).apply { + alignment = HorizontalAlignment.CENTER + verticalAlignment = VerticalAlignment.CENTER + fillForegroundColor = IndexedColors.ROYAL_BLUE.index + fillPattern = FillPatternType.SOLID_FOREGROUND + borders() + setFont(headerFont) + } + val data = (wb.createCellStyle() as XSSFCellStyle).apply { + alignment = HorizontalAlignment.LEFT + verticalAlignment = VerticalAlignment.CENTER + wrapText = true + borders() + val f = wb.createFont() + f.fontHeightInPoints = 11 + setFont(f) + } + val dataAlt = (wb.createCellStyle() as XSSFCellStyle).apply { + cloneStyleFrom(data) + fillPattern = FillPatternType.SOLID_FOREGROUND + fillForegroundColor = IndexedColors.GREY_25_PERCENT.index + } + return RouteLaneExportStyles(metaKey, metaValue, header, data, dataAlt) + } + + /** + * 表頭/邊框/隔行底色/欄寬/凍結首列資料之上/AutoFilter。不改儲存格值(import 仍讀 raw)。 + */ + fun applyRouteLaneExportFinishing( + sheet: Sheet, + wb: XSSFWorkbook, + firstDataRow: Int, + lastDataRow: Int, + ) { + val st = buildExportStyles(wb) + + for (r in intArrayOf(ROW_MARKER, ROW_STORE, ROW_DEPARTURE_DEFAULT)) { + val row = sheet.getRow(r) ?: continue + for (c in 0..COL_META_C) { + val cell = row.getCell(c) ?: continue + cell.cellStyle = if (c == COL_META_A) st.metaKey else st.metaValue + } + } + + val headerRow = sheet.getRow(ROW_HEADER) + if (headerRow != null) { + for (c in COL_AREA_PLATE..COL_DEPARTURE_ROW) { + headerRow.getCell(c)?.cellStyle = st.header + } + } + + if (lastDataRow >= firstDataRow) { + for (r in firstDataRow..lastDataRow) { + val alt = (r - firstDataRow) % 2 == 1 + val style = if (alt) st.dataAlt else st.data + val row = sheet.getRow(r) ?: continue + for (c in COL_AREA_PLATE..COL_DEPARTURE_ROW) { + row.getCell(c)?.cellStyle = style + } + } + } + + sheet.setColumnWidth(COL_AREA_PLATE, 14 * 256) + sheet.setColumnWidth(COL_SHOP_NAME, 28 * 256) + sheet.setColumnWidth(COL_BRAND, 14 * 256) + sheet.setColumnWidth(COL_SHOP_CODE, 12 * 256) + sheet.setColumnWidth(COL_SCHEDULE, 12 * 256) + sheet.setColumnWidth(COL_DEPARTURE_ROW, 12 * 256) + + sheet.createFreezePane(0, ROW_FIRST_DATA) + + val filterLast = if (lastDataRow >= firstDataRow) lastDataRow else ROW_HEADER + sheet.setAutoFilter( + CellRangeAddress(ROW_HEADER, filterLast, COL_AREA_PLATE, COL_DEPARTURE_ROW), + ) + } +} diff --git a/src/main/java/com/ffii/fpsms/modules/pickOrder/service/RouteReportExcelSupport.kt b/src/main/java/com/ffii/fpsms/modules/pickOrder/service/RouteReportExcelSupport.kt new file mode 100644 index 0000000..f22a40a --- /dev/null +++ b/src/main/java/com/ffii/fpsms/modules/pickOrder/service/RouteReportExcelSupport.kt @@ -0,0 +1,359 @@ +package com.ffii.fpsms.modules.pickOrder.service + +import org.apache.poi.ss.usermodel.BorderStyle +import org.apache.poi.ss.usermodel.FillPatternType +import org.apache.poi.ss.usermodel.HorizontalAlignment +import org.apache.poi.ss.usermodel.IndexedColors +import org.apache.poi.ss.usermodel.Sheet +import org.apache.poi.ss.usermodel.VerticalAlignment +import org.apache.poi.ss.util.CellRangeAddress +import org.apache.poi.ss.util.RegionUtil +import org.apache.poi.xssf.usermodel.XSSFCellStyle +import org.apache.poi.xssf.usermodel.XSSFFont +import org.apache.poi.xssf.usermodel.XSSFWorkbook + +object RouteReportExcelSupport { + const val SHEET_NAME = "車線Report" + const val BLOCK_WIDTH = 2 // 每間物流公司一個 block:2 欄 + + data class Styles( + val title: XSSFCellStyle, + val titlePreparedBy: XSSFCellStyle, + val company: XSSFCellStyle, + val plate: XSSFCellStyle, + val timeHeader: XSSFCellStyle, + val laneLeft: XSSFCellStyle, + val laneFill: XSSFCellStyle, + val district: XSSFCellStyle, + val shopNo: XSSFCellStyle, + val shopText: XSSFCellStyle, + val total: XSSFCellStyle, + val driverLabel: XSSFCellStyle, + val driverValue: XSSFCellStyle, + ) + + private fun borders(st: XSSFCellStyle, border: BorderStyle = BorderStyle.THIN) { + st.borderTop = border + st.borderBottom = border + st.borderLeft = border + st.borderRight = border + } + + fun buildStyles(wb: XSSFWorkbook): Styles { + fun font( + size: Short, + bold: Boolean = false, + color: Short? = null, + ): XSSFFont { + val f = wb.createFont() as XSSFFont + f.fontHeightInPoints = size + f.bold = bold + if (color != null) f.color = color + return f + } + + val title = (wb.createCellStyle() as XSSFCellStyle).apply { + alignment = HorizontalAlignment.CENTER + verticalAlignment = VerticalAlignment.CENTER + borders(this, BorderStyle.MEDIUM) + setFont(font(16, bold = true)) + } + + val titlePreparedBy = (wb.createCellStyle() as XSSFCellStyle).apply { + alignment = HorizontalAlignment.RIGHT + verticalAlignment = VerticalAlignment.CENTER + borders(this, BorderStyle.MEDIUM) + setFont(font(11, bold = true)) + } + + val company = (wb.createCellStyle() as XSSFCellStyle).apply { + alignment = HorizontalAlignment.CENTER + verticalAlignment = VerticalAlignment.CENTER + fillForegroundColor = IndexedColors.GREY_25_PERCENT.index + fillPattern = FillPatternType.SOLID_FOREGROUND + borders(this, BorderStyle.MEDIUM) + setFont(font(12, bold = true)) + } + + val plate = (wb.createCellStyle() as XSSFCellStyle).apply { + alignment = HorizontalAlignment.CENTER + verticalAlignment = VerticalAlignment.CENTER + borders(this, BorderStyle.THIN) + setFont(font(11, bold = true)) + } + + val timeHeader = (wb.createCellStyle() as XSSFCellStyle).apply { + alignment = HorizontalAlignment.CENTER + verticalAlignment = VerticalAlignment.CENTER + fillForegroundColor = IndexedColors.LIGHT_YELLOW.index + fillPattern = FillPatternType.SOLID_FOREGROUND + borders(this, BorderStyle.MEDIUM) + setFont(font(11, bold = true)) + } + + val laneLeft = (wb.createCellStyle() as XSSFCellStyle).apply { + alignment = HorizontalAlignment.CENTER + verticalAlignment = VerticalAlignment.CENTER + fillForegroundColor = IndexedColors.GREY_40_PERCENT.index + fillPattern = FillPatternType.SOLID_FOREGROUND + borders(this, BorderStyle.MEDIUM) + setFont(font(11, bold = true, color = IndexedColors.WHITE.index)) + } + + val laneFill = (wb.createCellStyle() as XSSFCellStyle).apply { + alignment = HorizontalAlignment.LEFT + verticalAlignment = VerticalAlignment.CENTER + fillForegroundColor = IndexedColors.GREY_25_PERCENT.index + fillPattern = FillPatternType.SOLID_FOREGROUND + borders(this, BorderStyle.MEDIUM) + setFont(font(11, bold = true)) + } + + val district = (wb.createCellStyle() as XSSFCellStyle).apply { + alignment = HorizontalAlignment.LEFT + verticalAlignment = VerticalAlignment.CENTER + fillForegroundColor = IndexedColors.LIGHT_CORNFLOWER_BLUE.index + fillPattern = FillPatternType.SOLID_FOREGROUND + borders(this, BorderStyle.THIN) + setFont(font(11, bold = true)) + } + + val shopNo = (wb.createCellStyle() as XSSFCellStyle).apply { + alignment = HorizontalAlignment.RIGHT + verticalAlignment = VerticalAlignment.TOP + borders(this, BorderStyle.THIN) + setFont(font(11, bold = true)) + } + + val shopText = (wb.createCellStyle() as XSSFCellStyle).apply { + alignment = HorizontalAlignment.LEFT + verticalAlignment = VerticalAlignment.TOP + wrapText = true + borders(this, BorderStyle.THIN) + setFont(font(11)) + } + + val total = (wb.createCellStyle() as XSSFCellStyle).apply { + alignment = HorizontalAlignment.LEFT + verticalAlignment = VerticalAlignment.CENTER + fillForegroundColor = IndexedColors.GREY_25_PERCENT.index + fillPattern = FillPatternType.SOLID_FOREGROUND + borders(this, BorderStyle.MEDIUM) + setFont(font(11, bold = true)) + } + + val driverLabel = (wb.createCellStyle() as XSSFCellStyle).apply { + alignment = HorizontalAlignment.CENTER + verticalAlignment = VerticalAlignment.CENTER + fillForegroundColor = IndexedColors.GREY_40_PERCENT.index + fillPattern = FillPatternType.SOLID_FOREGROUND + borders(this, BorderStyle.MEDIUM) + setFont(font(11, bold = true, color = IndexedColors.WHITE.index)) + } + + val driverValue = (wb.createCellStyle() as XSSFCellStyle).apply { + alignment = HorizontalAlignment.LEFT + verticalAlignment = VerticalAlignment.CENTER + borders(this, BorderStyle.MEDIUM) + setFont(font(11, bold = true)) + } + + return Styles( + title = title, + titlePreparedBy = titlePreparedBy, + company = company, + plate = plate, + timeHeader = timeHeader, + laneLeft = laneLeft, + laneFill = laneFill, + district = district, + shopNo = shopNo, + shopText = shopText, + total = total, + driverLabel = driverLabel, + driverValue = driverValue, + ) + } + + private fun ensureRow(sheet: Sheet, r: Int) = sheet.getRow(r) ?: sheet.createRow(r) + private fun ensureCell(sheet: Sheet, r: Int, c: Int) = + ensureRow(sheet, r).let { row -> row.getCell(c) ?: row.createCell(c) } + + fun styleRange( + sheet: Sheet, + row: Int, + firstCol: Int, + lastCol: Int, + style: XSSFCellStyle, + ) { + for (c in firstCol..lastCol) { + ensureCell(sheet, row, c).cellStyle = style + } + } + + fun mergeAndStyle( + sheet: Sheet, + row: Int, + firstCol: Int, + lastCol: Int, + style: XSSFCellStyle, + border: BorderStyle = BorderStyle.MEDIUM, + ) { + for (c in firstCol..lastCol) { + ensureCell(sheet, row, c).cellStyle = style + } + // POI 不允許 merge 單一 cell(需 2+ cells)。此時只套 style + cell border 即可。 + if (firstCol == lastCol) return + val region = CellRangeAddress(row, row, firstCol, lastCol) + sheet.addMergedRegion(region) + RegionUtil.setBorderTop(border, region, sheet) + RegionUtil.setBorderBottom(border, region, sheet) + RegionUtil.setBorderLeft(border, region, sheet) + RegionUtil.setBorderRight(border, region, sheet) + } + + fun applyColumnWidths(sheet: Sheet, blockIndex: Int) { + val base = blockIndex * BLOCK_WIDTH + sheet.setColumnWidth(base + 0, 10 * 256) + sheet.setColumnWidth(base + 1, 26 * 256) + } + + fun writeTitle( + sheet: Sheet, + st: Styles, + titleText: String, + preparedByText: String, + totalBlocks: Int, + ) { + val lastCol = (totalBlocks * BLOCK_WIDTH - 1).coerceAtLeast(0) + val r = 0 + // 預留右邊 2 欄顯示「製表: xxx」 + val preparedCols = 1.coerceAtMost(lastCol + 1) + val preparedFirstCol = (lastCol - preparedCols + 1).coerceAtLeast(0) + val titleLastCol = (preparedFirstCol - 1).coerceAtLeast(0) + + if (preparedFirstCol == 0) { + // 欄位不足:整行仍以 title style 輸出(避免 merge 範圍倒轉) + mergeAndStyle(sheet, r, 0, lastCol, st.title, BorderStyle.MEDIUM) + ensureCell(sheet, r, 0).setCellValue("$titleText $preparedByText") + } else { + mergeAndStyle(sheet, r, 0, titleLastCol, st.title, BorderStyle.MEDIUM) + ensureCell(sheet, r, 0).setCellValue(titleText) + + mergeAndStyle(sheet, r, preparedFirstCol, lastCol, st.titlePreparedBy, BorderStyle.MEDIUM) + ensureCell(sheet, r, preparedFirstCol).setCellValue(preparedByText) + } + sheet.getRow(r)?.heightInPoints = 26f + } + + data class BlockMeta( + val companyName: String, + val plate: String, + val driverName: String, + val driverNumber: String, + ) + + /** + * @return 最後寫到的 row index(含) + */ + fun writeCompanyBlock( + sheet: Sheet, + st: Styles, + blockIndex: Int, + startRow: Int, + meta: BlockMeta, + groups: List, + totalShopCount: Int, + ): Int { + val baseCol = blockIndex * BLOCK_WIDTH + applyColumnWidths(sheet, blockIndex) + + var r = startRow + + // 公司名 + mergeAndStyle(sheet, r, baseCol, baseCol + 1, st.company, BorderStyle.MEDIUM) + ensureCell(sheet, r, baseCol).setCellValue(meta.companyName) + sheet.getRow(r)?.heightInPoints = 18f + r++ + + // 車牌 + mergeAndStyle(sheet, r, baseCol, baseCol + 1, st.plate, BorderStyle.THIN) + ensureCell(sheet, r, baseCol).setCellValue(meta.plate) + r++ + + for (tg in groups) { + mergeAndStyle(sheet, r, baseCol, baseCol + 1, st.timeHeader, BorderStyle.MEDIUM) + ensureCell(sheet, r, baseCol).setCellValue(tg.timeLabel) + r++ + + for (lg in tg.lanes) { + // 車線標題:左一格強調,右三格補底 + ensureCell(sheet, r, baseCol).apply { + cellStyle = st.laneLeft + setCellValue(lg.laneCode) + } + // 2 欄版:右側只剩 1 格(不 merge) + ensureCell(sheet, r, baseCol + 1).cellStyle = st.laneFill + r++ + + for (dg in lg.districts) { + mergeAndStyle(sheet, r, baseCol, baseCol + 1, st.district, BorderStyle.THIN) + ensureCell(sheet, r, baseCol).setCellValue(dg.districtLabel) + r++ + + var idx = 1 + for (s in dg.shops) { + ensureCell(sheet, r, baseCol).apply { + cellStyle = st.shopNo + setCellValue("$idx.") + } + // shop row 不做 merge:避免 merged regions 爆量導致寫檔/開檔變慢 + styleRange(sheet, r, baseCol + 1, baseCol + 1, st.shopText) + ensureCell(sheet, r, baseCol + 1).setCellValue(s) + val lines = (s.count { it == '\n' } + 1).coerceAtLeast(1) + val h = (16f * lines).coerceIn(18f, 72f) + sheet.getRow(r)?.heightInPoints = h + r++ + idx++ + } + } + } + } + + // 分店數目 + mergeAndStyle(sheet, r, baseCol, baseCol + 1, st.total, BorderStyle.MEDIUM) + ensureCell(sheet, r, baseCol).setCellValue("分店數目:$totalShopCount") + r++ + + // 車長 / driver + ensureCell(sheet, r, baseCol).cellStyle = st.driverLabel + ensureCell(sheet, r, baseCol).setCellValue("車長") + ensureCell(sheet, r, baseCol + 1).cellStyle = st.driverValue + ensureCell(sheet, r, baseCol + 1).setCellValue(meta.driverName) + r++ + + // driver number + // 2 欄版:電話/司機號碼跨兩欄合併成一格(像截圖的大白格) + mergeAndStyle(sheet, r, baseCol, baseCol + 1, st.driverValue, BorderStyle.MEDIUM) + ensureCell(sheet, r, baseCol).setCellValue(meta.driverNumber) + r++ + + return r - 1 + } + + data class TimeGroup( + val timeLabel: String, + val lanes: List, + ) + + data class LaneGroup( + val laneCode: String, + val districts: List, + ) + + data class DistrictGroup( + val districtLabel: String, + val shops: List, + ) +} + diff --git a/src/main/java/com/ffii/fpsms/modules/pickOrder/service/TruckLaneVersionReportExcelSupport.kt b/src/main/java/com/ffii/fpsms/modules/pickOrder/service/TruckLaneVersionReportExcelSupport.kt new file mode 100644 index 0000000..6122846 --- /dev/null +++ b/src/main/java/com/ffii/fpsms/modules/pickOrder/service/TruckLaneVersionReportExcelSupport.kt @@ -0,0 +1,194 @@ +package com.ffii.fpsms.modules.pickOrder.service + +import org.apache.poi.ss.usermodel.BorderStyle +import org.apache.poi.ss.usermodel.FillPatternType +import org.apache.poi.ss.usermodel.HorizontalAlignment +import org.apache.poi.ss.usermodel.IndexedColors +import org.apache.poi.ss.usermodel.Sheet +import org.apache.poi.ss.usermodel.VerticalAlignment +import org.apache.poi.ss.util.CellRangeAddress +import org.apache.poi.ss.util.RegionUtil +import org.apache.poi.xssf.usermodel.XSSFCellStyle +import org.apache.poi.xssf.usermodel.XSSFFont +import org.apache.poi.xssf.usermodel.XSSFWorkbook + +object TruckLaneVersionReportExcelSupport { + const val SUMMARY_SHEET = "版本異動報告" + + private data class Styles( + val title: XSSFCellStyle, + val metaKey: XSSFCellStyle, + val metaVal: XSSFCellStyle, + val header: XSSFCellStyle, + val normal: XSSFCellStyle, + val added: XSSFCellStyle, + val deleted: XSSFCellStyle, + val moved: XSSFCellStyle, + val edited: XSSFCellStyle, + val highlight: XSSFCellStyle, + ) + + private fun buildStyles(wb: XSSFWorkbook): Styles { + fun font(size: Short, bold: Boolean = false, color: Short? = null): XSSFFont { + val f = wb.createFont() as XSSFFont + f.fontHeightInPoints = size + f.bold = bold + if (color != null) f.color = color + return f + } + + fun style( + align: HorizontalAlignment, + vAlign: VerticalAlignment = VerticalAlignment.CENTER, + bg: Short? = null, + bold: Boolean = false, + size: Short = 11, + border: BorderStyle = BorderStyle.THIN, + ): XSSFCellStyle { + return (wb.createCellStyle() as XSSFCellStyle).apply { + alignment = align + verticalAlignment = vAlign + borderTop = border + borderBottom = border + borderLeft = border + borderRight = border + if (bg != null) { + fillForegroundColor = bg + fillPattern = FillPatternType.SOLID_FOREGROUND + } + setFont(font(size, bold = bold)) + wrapText = true + } + } + + val title = style(HorizontalAlignment.CENTER, bg = IndexedColors.WHITE.index, bold = true, size = 16, border = BorderStyle.MEDIUM) + val metaKey = style(HorizontalAlignment.LEFT, bg = IndexedColors.GREY_25_PERCENT.index, bold = true, border = BorderStyle.THIN) + val metaVal = style(HorizontalAlignment.LEFT, bg = IndexedColors.WHITE.index, bold = false, border = BorderStyle.THIN) + val header = (wb.createCellStyle() as XSSFCellStyle).apply { + alignment = HorizontalAlignment.CENTER + verticalAlignment = VerticalAlignment.CENTER + fillForegroundColor = IndexedColors.ROYAL_BLUE.index + fillPattern = FillPatternType.SOLID_FOREGROUND + borderTop = BorderStyle.MEDIUM + borderBottom = BorderStyle.MEDIUM + borderLeft = BorderStyle.MEDIUM + borderRight = BorderStyle.MEDIUM + setFont(font(11, bold = true, color = IndexedColors.WHITE.index)) + } + val normal = style(HorizontalAlignment.LEFT) + val added = style(HorizontalAlignment.LEFT, bg = IndexedColors.LIGHT_GREEN.index, border = BorderStyle.THIN) + val deleted = style(HorizontalAlignment.LEFT, bg = IndexedColors.ROSE.index, border = BorderStyle.THIN) + val moved = style(HorizontalAlignment.LEFT, bg = IndexedColors.LIGHT_YELLOW.index, border = BorderStyle.THIN) + val edited = style(HorizontalAlignment.LEFT, bg = IndexedColors.GREY_25_PERCENT.index, border = BorderStyle.THIN) + val highlight = style(HorizontalAlignment.LEFT, bg = IndexedColors.LIGHT_ORANGE.index, border = BorderStyle.THIN, bold = true) + return Styles(title, metaKey, metaVal, header, normal, added, deleted, moved, edited, highlight) + } + + private fun ensureRow(sheet: Sheet, r: Int) = sheet.getRow(r) ?: sheet.createRow(r) + private fun cell(sheet: Sheet, r: Int, c: Int) = ensureRow(sheet, r).getCell(c) ?: ensureRow(sheet, r).createCell(c) + + private fun mergeRow(sheet: Sheet, r: Int, c0: Int, c1: Int, style: XSSFCellStyle) { + for (c in c0..c1) cell(sheet, r, c).cellStyle = style + if (c0 == c1) return + val region = CellRangeAddress(r, r, c0, c1) + sheet.addMergedRegion(region) + RegionUtil.setBorderTop(BorderStyle.MEDIUM, region, sheet) + RegionUtil.setBorderBottom(BorderStyle.MEDIUM, region, sheet) + RegionUtil.setBorderLeft(BorderStyle.MEDIUM, region, sheet) + RegionUtil.setBorderRight(BorderStyle.MEDIUM, region, sheet) + } + + data class SummaryMeta( + val title: String, + val editor: String, + val created: String, + val fromVersionId: Long, + val toVersionId: Long, + val note: String?, + val statsText: String, + ) + + enum class RowType { ADDED, DELETED, MOVED, EDITED } + + data class SummaryRow( + val type: RowType, + val shopName: String, + val shopCode: String, + val fromLane: String, + val toLane: String, + val changeText: String, + /** 欄位名集合,用於高亮「變更資訊」cell */ + val changedFields: Set = emptySet(), + ) + + fun writeSummarySheet(wb: XSSFWorkbook, meta: SummaryMeta, rows: List) { + val st = buildStyles(wb) + val sheet = wb.createSheet(SUMMARY_SHEET) + + // column widths + sheet.setColumnWidth(0, 10 * 256) // type + sheet.setColumnWidth(1, 22 * 256) // shop + sheet.setColumnWidth(2, 12 * 256) // code + sheet.setColumnWidth(3, 18 * 256) // from + sheet.setColumnWidth(4, 18 * 256) // to + sheet.setColumnWidth(5, 60 * 256) // text + + var r = 0 + mergeRow(sheet, r, 0, 5, st.title) + cell(sheet, r, 0).setCellValue(meta.title) + sheet.getRow(r)?.heightInPoints = 26f + r++ + + fun metaRow(k: String, v: String) { + cell(sheet, r, 0).apply { cellStyle = st.metaKey; setCellValue(k) } + mergeRow(sheet, r, 1, 5, st.metaVal) + cell(sheet, r, 1).setCellValue(v) + r++ + } + + metaRow("編輯者", meta.editor) + metaRow("建立時間", meta.created) + metaRow("版本", "from #${meta.fromVersionId} → to #${meta.toVersionId}") + metaRow("摘要", meta.statsText) + if (!meta.note.isNullOrBlank()) metaRow("備註", meta.note.trim()) + + r++ + + // header + val headerRowIndex = r + val headers = listOf("類型", "分店", "代碼", "From 車線", "To 車線", "變更資訊") + for (c in headers.indices) { + cell(sheet, r, c).apply { cellStyle = st.header; setCellValue(headers[c]) } + } + sheet.getRow(r)?.heightInPoints = 18f + r++ + + for (row in rows) { + val baseStyle = + when (row.type) { + RowType.ADDED -> st.added + RowType.DELETED -> st.deleted + RowType.MOVED -> st.moved + RowType.EDITED -> st.edited + } + + fun set(c: Int, v: String, highlight: Boolean = false) { + cell(sheet, r, c).apply { + cellStyle = if (highlight) st.highlight else baseStyle + setCellValue(v) + } + } + + set(0, row.type.name) + set(1, row.shopName) + set(2, row.shopCode) + set(3, row.fromLane, highlight = row.type == RowType.MOVED || row.type == RowType.ADDED || row.type == RowType.DELETED) + set(4, row.toLane, highlight = row.type == RowType.MOVED || row.type == RowType.ADDED || row.type == RowType.DELETED) + set(5, row.changeText, highlight = row.changedFields.isNotEmpty()) + r++ + } + + sheet.createFreezePane(0, headerRowIndex + 1) + } +} + diff --git a/src/main/java/com/ffii/fpsms/modules/pickOrder/service/TruckLaneVersionRouteReportExcelSupport.kt b/src/main/java/com/ffii/fpsms/modules/pickOrder/service/TruckLaneVersionRouteReportExcelSupport.kt new file mode 100644 index 0000000..ebd98b7 --- /dev/null +++ b/src/main/java/com/ffii/fpsms/modules/pickOrder/service/TruckLaneVersionRouteReportExcelSupport.kt @@ -0,0 +1,300 @@ +package com.ffii.fpsms.modules.pickOrder.service + +import org.apache.poi.ss.usermodel.BorderStyle +import org.apache.poi.ss.usermodel.FillPatternType +import org.apache.poi.ss.usermodel.HorizontalAlignment +import org.apache.poi.ss.usermodel.IndexedColors +import org.apache.poi.ss.usermodel.Sheet +import org.apache.poi.ss.usermodel.VerticalAlignment +import org.apache.poi.ss.util.CellRangeAddress +import org.apache.poi.ss.util.RegionUtil +import org.apache.poi.xssf.usermodel.XSSFCellStyle +import org.apache.poi.xssf.usermodel.XSSFFont +import org.apache.poi.xssf.usermodel.XSSFWorkbook + +/** + * 版本 Log 用:輸出「車線報告」版面(同正常 RouteReport),但把異動的 shop row 高亮。 + * + * 2 欄 block:左序號 / label、右內容。 + */ +object TruckLaneVersionRouteReportExcelSupport { + const val SHEET_NAME = "車線報告(版本)" + const val BLOCK_WIDTH = 2 + + data class Styles( + val title: XSSFCellStyle, + val titlePreparedBy: XSSFCellStyle, + val company: XSSFCellStyle, + val plate: XSSFCellStyle, + val timeHeader: XSSFCellStyle, + val laneLeft: XSSFCellStyle, + val laneFill: XSSFCellStyle, + val district: XSSFCellStyle, + val shopNo: XSSFCellStyle, + val shopText: XSSFCellStyle, + val shopNoChanged: XSSFCellStyle, + val shopTextChanged: XSSFCellStyle, + val total: XSSFCellStyle, + val driverLabel: XSSFCellStyle, + val driverValue: XSSFCellStyle, + ) + + fun buildStyles(wb: XSSFWorkbook): Styles { + fun font(size: Short, bold: Boolean = false, color: Short? = null): XSSFFont { + val f = wb.createFont() as XSSFFont + f.fontHeightInPoints = size + f.bold = bold + if (color != null) f.color = color + return f + } + + fun borders(st: XSSFCellStyle, border: BorderStyle) { + st.borderTop = border + st.borderBottom = border + st.borderLeft = border + st.borderRight = border + } + + fun baseCell( + align: HorizontalAlignment, + bg: Short? = null, + bold: Boolean = false, + size: Short = 11, + border: BorderStyle = BorderStyle.THIN, + wrap: Boolean = false, + ): XSSFCellStyle { + return (wb.createCellStyle() as XSSFCellStyle).apply { + alignment = align + verticalAlignment = VerticalAlignment.CENTER + borders(this, border) + if (bg != null) { + fillForegroundColor = bg + fillPattern = FillPatternType.SOLID_FOREGROUND + } + setFont(font(size, bold = bold)) + wrapText = wrap + } + } + + val title = baseCell(HorizontalAlignment.CENTER, bold = true, size = 16, border = BorderStyle.MEDIUM) + val titlePreparedBy = baseCell(HorizontalAlignment.RIGHT, bold = true, size = 11, border = BorderStyle.MEDIUM) + val company = baseCell( + HorizontalAlignment.CENTER, + bg = IndexedColors.GREY_25_PERCENT.index, + bold = true, + size = 12, + border = BorderStyle.MEDIUM, + ) + val plate = baseCell(HorizontalAlignment.CENTER, bold = true, border = BorderStyle.THIN) + val timeHeader = baseCell( + HorizontalAlignment.CENTER, + bg = IndexedColors.LIGHT_YELLOW.index, + bold = true, + border = BorderStyle.MEDIUM, + ) + val laneLeft = baseCell( + HorizontalAlignment.CENTER, + bg = IndexedColors.GREY_40_PERCENT.index, + bold = true, + border = BorderStyle.MEDIUM, + ).apply { setFont(font(11, bold = true, color = IndexedColors.WHITE.index)) } + + val laneFill = baseCell( + HorizontalAlignment.LEFT, + bg = IndexedColors.GREY_25_PERCENT.index, + bold = true, + border = BorderStyle.MEDIUM, + ) + + val district = baseCell( + HorizontalAlignment.LEFT, + bg = IndexedColors.LIGHT_CORNFLOWER_BLUE.index, + bold = true, + border = BorderStyle.THIN, + ) + + val shopNo = baseCell(HorizontalAlignment.RIGHT, border = BorderStyle.THIN).apply { + verticalAlignment = VerticalAlignment.TOP + setFont(font(11, bold = true)) + } + val shopText = baseCell(HorizontalAlignment.LEFT, border = BorderStyle.THIN, wrap = true).apply { + verticalAlignment = VerticalAlignment.TOP + } + + val shopNoChanged = (wb.createCellStyle() as XSSFCellStyle).apply { + cloneStyleFrom(shopNo) + fillForegroundColor = IndexedColors.LIGHT_ORANGE.index + fillPattern = FillPatternType.SOLID_FOREGROUND + } + val shopTextChanged = (wb.createCellStyle() as XSSFCellStyle).apply { + cloneStyleFrom(shopText) + fillForegroundColor = IndexedColors.LIGHT_ORANGE.index + fillPattern = FillPatternType.SOLID_FOREGROUND + val f = wb.createFont() + f.fontHeightInPoints = 11 + f.bold = true + setFont(f) + } + + val total = baseCell(HorizontalAlignment.LEFT, bg = IndexedColors.GREY_25_PERCENT.index, bold = true, border = BorderStyle.MEDIUM) + val driverLabel = baseCell( + HorizontalAlignment.CENTER, + bg = IndexedColors.GREY_40_PERCENT.index, + bold = true, + border = BorderStyle.MEDIUM, + ).apply { setFont(font(11, bold = true, color = IndexedColors.WHITE.index)) } + val driverValue = baseCell(HorizontalAlignment.LEFT, bold = true, border = BorderStyle.MEDIUM) + + return Styles( + title = title, + titlePreparedBy = titlePreparedBy, + company = company, + plate = plate, + timeHeader = timeHeader, + laneLeft = laneLeft, + laneFill = laneFill, + district = district, + shopNo = shopNo, + shopText = shopText, + shopNoChanged = shopNoChanged, + shopTextChanged = shopTextChanged, + total = total, + driverLabel = driverLabel, + driverValue = driverValue, + ) + } + + private fun ensureRow(sheet: Sheet, r: Int) = sheet.getRow(r) ?: sheet.createRow(r) + private fun cell(sheet: Sheet, r: Int, c: Int) = ensureRow(sheet, r).getCell(c) ?: ensureRow(sheet, r).createCell(c) + + private fun mergeAndStyle(sheet: Sheet, r: Int, c0: Int, c1: Int, style: XSSFCellStyle, border: BorderStyle) { + for (c in c0..c1) cell(sheet, r, c).cellStyle = style + if (c0 == c1) return + val region = CellRangeAddress(r, r, c0, c1) + sheet.addMergedRegion(region) + RegionUtil.setBorderTop(border, region, sheet) + RegionUtil.setBorderBottom(border, region, sheet) + RegionUtil.setBorderLeft(border, region, sheet) + RegionUtil.setBorderRight(border, region, sheet) + } + + fun applyColumnWidths(sheet: Sheet, blockIndex: Int) { + val base = blockIndex * BLOCK_WIDTH + sheet.setColumnWidth(base + 0, 10 * 256) + sheet.setColumnWidth(base + 1, 30 * 256) + } + + data class BlockMeta( + val companyName: String, + val plate: String, + val driverName: String, + val driverNumber: String, + ) + + data class ShopRow( + val truckRowId: Long, + val text: String, + val changed: Boolean, + ) + + data class DistrictGroup( + val district: String, + val shops: List, + ) + + data class LaneGroup( + val laneLabel: String, + val districts: List, + ) + + data class TimeGroup( + val timeLabel: String, + val lanes: List, + ) + + fun writeTitle(sheet: Sheet, st: Styles, titleText: String, preparedByText: String, totalBlocks: Int) { + val lastCol = (totalBlocks * BLOCK_WIDTH - 1).coerceAtLeast(0) + val r = 0 + val preparedCols = 1.coerceAtMost(lastCol + 1) + val preparedFirstCol = (lastCol - preparedCols + 1).coerceAtLeast(0) + val titleLastCol = (preparedFirstCol - 1).coerceAtLeast(0) + + if (preparedFirstCol == 0) { + mergeAndStyle(sheet, r, 0, lastCol, st.title, BorderStyle.MEDIUM) + cell(sheet, r, 0).setCellValue("$titleText $preparedByText") + } else { + mergeAndStyle(sheet, r, 0, titleLastCol, st.title, BorderStyle.MEDIUM) + cell(sheet, r, 0).setCellValue(titleText) + mergeAndStyle(sheet, r, preparedFirstCol, lastCol, st.titlePreparedBy, BorderStyle.MEDIUM) + cell(sheet, r, preparedFirstCol).setCellValue(preparedByText) + } + sheet.getRow(r)?.heightInPoints = 26f + } + + fun writeCompanyBlock( + sheet: Sheet, + st: Styles, + blockIndex: Int, + startRow: Int, + meta: BlockMeta, + groups: List, + totalShopCount: Int, + ): Int { + val baseCol = blockIndex * BLOCK_WIDTH + applyColumnWidths(sheet, blockIndex) + var r = startRow + + mergeAndStyle(sheet, r, baseCol, baseCol + 1, st.company, BorderStyle.MEDIUM) + cell(sheet, r, baseCol).setCellValue(meta.companyName) + r++ + + mergeAndStyle(sheet, r, baseCol, baseCol + 1, st.plate, BorderStyle.THIN) + cell(sheet, r, baseCol).setCellValue(meta.plate) + r++ + + for (tg in groups) { + mergeAndStyle(sheet, r, baseCol, baseCol + 1, st.timeHeader, BorderStyle.MEDIUM) + cell(sheet, r, baseCol).setCellValue(tg.timeLabel) + r++ + + for (lg in tg.lanes) { + cell(sheet, r, baseCol).apply { cellStyle = st.laneLeft; setCellValue(lg.laneLabel) } + cell(sheet, r, baseCol + 1).cellStyle = st.laneFill + r++ + + for (dg in lg.districts) { + mergeAndStyle(sheet, r, baseCol, baseCol + 1, st.district, BorderStyle.THIN) + cell(sheet, r, baseCol).setCellValue(dg.district) + r++ + + var idx = 1 + for (s in dg.shops) { + val noStyle = if (s.changed) st.shopNoChanged else st.shopNo + val txtStyle = if (s.changed) st.shopTextChanged else st.shopText + cell(sheet, r, baseCol).apply { cellStyle = noStyle; setCellValue("$idx.") } + cell(sheet, r, baseCol + 1).apply { cellStyle = txtStyle; setCellValue(s.text) } + val lines = (s.text.count { it == '\n' } + 1).coerceAtLeast(1) + sheet.getRow(r)?.heightInPoints = (16f * lines).coerceIn(18f, 90f) + r++ + idx++ + } + } + } + } + + mergeAndStyle(sheet, r, baseCol, baseCol + 1, st.total, BorderStyle.MEDIUM) + cell(sheet, r, baseCol).setCellValue("分店數目:$totalShopCount") + r++ + + cell(sheet, r, baseCol).apply { cellStyle = st.driverLabel; setCellValue("車長") } + cell(sheet, r, baseCol + 1).apply { cellStyle = st.driverValue; setCellValue(meta.driverName) } + r++ + + mergeAndStyle(sheet, r, baseCol, baseCol + 1, st.driverValue, BorderStyle.MEDIUM) + cell(sheet, r, baseCol).setCellValue(meta.driverNumber) + r++ + + return r - 1 + } +} + diff --git a/src/main/java/com/ffii/fpsms/modules/pickOrder/service/TruckLaneVersionService.kt b/src/main/java/com/ffii/fpsms/modules/pickOrder/service/TruckLaneVersionService.kt new file mode 100644 index 0000000..41fc24b --- /dev/null +++ b/src/main/java/com/ffii/fpsms/modules/pickOrder/service/TruckLaneVersionService.kt @@ -0,0 +1,308 @@ +package com.ffii.fpsms.modules.pickOrder.service + +import com.ffii.fpsms.modules.logistic.entity.LogisticRepository +import com.ffii.fpsms.modules.pickOrder.entity.* +import com.ffii.fpsms.modules.pickOrder.web.models.LogisticMasterDiffLine +import com.ffii.fpsms.modules.pickOrder.web.models.* +import jakarta.transaction.Transactional +import org.springframework.http.HttpStatus +import org.springframework.stereotype.Service +import org.springframework.web.server.ResponseStatusException +import java.time.LocalTime + +@Service +open class TruckLaneVersionService( + private val truckRepository: TruckRepository, + private val truckLaneVersionRepository: TruckLaneVersionRepository, + private val truckLaneVersionLineRepository: TruckLaneVersionLineRepository, + private val logisticRepository: LogisticRepository, +) { + private fun toResponse(v: TruckLaneVersion): TruckLaneVersionResponse = + TruckLaneVersionResponse( + id = v.id ?: 0, + truckLanceCode = v.truckLanceCode ?: "", + note = v.note, + created = v.created?.toString(), + modifiedBy = v.modifiedBy, + ) + + /** + * 全看板 snapshot:`TruckLaneVersion.truckLanceCode` 為空(建立 snapshot 時未指定單線)。 + * 另:若 line 上出現多種 `truckLanceCode`,視為全看板誤標成單線的舊資料,仍應對「整個 findAllForRouteBoard」做 extras 軟刪。 + */ + private fun isFullBoardSnapshot( + version: TruckLaneVersion, + lines: List, + ): Boolean { + if (version.truckLanceCode.isNullOrBlank()) return true + val distinctLaneCodes = + lines.mapNotNull { it.truckLanceCode?.trim()?.takeIf { c -> c.isNotEmpty() } }.distinct() + return distinctLaneCodes.size > 1 + } + + @Transactional + open fun createSnapshot(request: CreateTruckLaneSnapshotRequest): TruckLaneVersionResponse { + val lane = request.truckLanceCode?.trim()?.takeIf { it.isNotEmpty() } + + val version = TruckLaneVersion().apply { + this.truckLanceCode = lane + this.note = request.note?.trim() + } + val savedVersion = truckLaneVersionRepository.save(version) + + val rows = + if (lane != null) { + truckRepository.findAllByTruckLanceCodeAndDeletedFalse(lane) + } else { + truckRepository.findAllForRouteBoard() + } + val lines = rows.map { t -> + TruckLaneVersionLine().apply { + this.truckLaneVersion = savedVersion + this.truckRowId = t.id + this.truckLanceCode = t.truckLanceCode + this.shopCode = t.shopCode + this.branchName = t.shopName + this.districtReference = t.districtReference + this.loadingSequence = t.loadingSequence + this.departureTime = t.departureTime?.toString() + this.storeId = t.storeId?.trim()?.takeIf { it.isNotEmpty() } ?: "-" + this.remark = t.remark + this.logisticId = t.logistic?.id + } + } + if (lines.isNotEmpty()) { + truckLaneVersionLineRepository.saveAll(lines) + } + + return toResponse(savedVersion) + } + + open fun listVersionsByLane(truckLanceCode: String): List { + val lane = truckLanceCode.trim() + return truckLaneVersionRepository + .findAllByTruckLanceCodeAndDeletedFalseOrderByCreatedDesc(lane) + .map(::toResponse) + } + + open fun listAllVersions(): List { + return truckLaneVersionRepository + .findAllByDeletedFalseOrderByCreatedDesc() + .map(::toResponse) + } + + open fun getVersionLines(versionId: Long): List { + return truckLaneVersionLineRepository + .findAllByTruckLaneVersion_IdAndDeletedFalseOrderByIdAsc(versionId) + .map { + TruckLaneVersionLineResponse( + truckRowId = it.truckRowId ?: 0, + truckLanceCode = it.truckLanceCode, + shopCode = it.shopCode, + branchName = it.branchName, + districtReference = it.districtReference, + loadingSequence = it.loadingSequence, + departureTime = it.departureTime, + storeId = it.storeId ?: "", + remark = it.remark, + logisticId = it.logisticId, + ) + } + } + + open fun diff(fromVersionId: Long, toVersionId: Long): TruckLaneVersionDiffResponse { + val fromLines = truckLaneVersionLineRepository + .findAllByTruckLaneVersion_IdAndDeletedFalseOrderByIdAsc(fromVersionId) + val toLines = truckLaneVersionLineRepository + .findAllByTruckLaneVersion_IdAndDeletedFalseOrderByIdAsc(toVersionId) + + val fromByRow = fromLines.associateBy { it.truckRowId ?: -1 } + val toByRow = toLines.associateBy { it.truckRowId ?: -1 } + + val allKeys = (fromByRow.keys + toByRow.keys).filter { it > 0 }.sorted() + val changed = mutableListOf() + + fun s(v: Any?): String? = v?.toString() + + allKeys.forEach { key -> + val a = fromByRow[key] + val b = toByRow[key] + val changes = mutableListOf() + + if (s(a?.truckLanceCode) != s(b?.truckLanceCode)) changes.add(DiffFieldChange("truckLanceCode", s(a?.truckLanceCode), s(b?.truckLanceCode))) + if (s(a?.shopCode) != s(b?.shopCode)) changes.add(DiffFieldChange("shopCode", s(a?.shopCode), s(b?.shopCode))) + if (s(a?.branchName) != s(b?.branchName)) changes.add(DiffFieldChange("branchName", s(a?.branchName), s(b?.branchName))) + if (s(a?.districtReference) != s(b?.districtReference)) changes.add(DiffFieldChange("districtReference", s(a?.districtReference), s(b?.districtReference))) + if (s(a?.loadingSequence) != s(b?.loadingSequence)) changes.add(DiffFieldChange("loadingSequence", s(a?.loadingSequence), s(b?.loadingSequence))) + if (s(a?.departureTime) != s(b?.departureTime)) changes.add(DiffFieldChange("departureTime", s(a?.departureTime), s(b?.departureTime))) + if (s(a?.storeId) != s(b?.storeId)) changes.add(DiffFieldChange("storeId", s(a?.storeId), s(b?.storeId))) + if (s(a?.remark) != s(b?.remark)) changes.add(DiffFieldChange("remark", s(a?.remark), s(b?.remark))) + if (s(a?.logisticId) != s(b?.logisticId)) changes.add(DiffFieldChange("logisticId", s(a?.logisticId), s(b?.logisticId))) + + if (changes.isNotEmpty()) { + changed.add( + TruckLaneVersionDiffLine( + truckRowId = key, + shopCode = b?.shopCode ?: a?.shopCode, + changes = changes, + 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 { + val fromAt = fromVersion.created ?: return emptyList() + val toAt = toVersion.created ?: return emptyList() + if (!toAt.isAfter(fromAt)) return emptyList() + + fun inOpenInterval(ts: java.time.LocalDateTime?): Boolean { + if (ts == null) return false + return ts.isAfter(fromAt) && !ts.isAfter(toAt) + } + + val out = ArrayList() + for (l in logisticRepository.findAllByDeletedFalseOrderByIdAsc()) { + val id = l.id ?: continue + val name = l.logisticName?.trim().orEmpty().ifEmpty { "—" } + val plate = l.carPlate?.trim().orEmpty().ifEmpty { "—" } + val created = l.created + val modified = l.modified + + if (inOpenInterval(created)) { + out.add( + LogisticMasterDiffLine( + logisticId = id, + type = "ADDED", + logisticName = name, + carPlate = plate, + changeText = "新增物流公司:$name($plate)", + ), + ) + continue + } + + if (created != null && !created.isAfter(fromAt) && inOpenInterval(modified)) { + out.add( + LogisticMasterDiffLine( + logisticId = id, + type = "EDITED", + logisticName = name, + carPlate = plate, + changeText = "修改物流公司:$name($plate)", + ), + ) + } + } + return out + } + + @Transactional + open fun updateNote(versionId: Long, note: String?): TruckLaneVersionResponse { + val v = truckLaneVersionRepository.findByIdAndDeletedFalse(versionId) + ?: throw ResponseStatusException(HttpStatus.NOT_FOUND, "Version not found: $versionId") + val trimmed = note?.trim()?.takeIf { it.isNotEmpty() } + v.note = trimmed + return toResponse(truckLaneVersionRepository.save(v)) + } + + @Transactional + open fun restore(versionId: Long): String { + val version = truckLaneVersionRepository.findByIdAndDeletedFalse(versionId) + ?: throw ResponseStatusException(HttpStatus.NOT_FOUND, "Version not found: $versionId") + + val lines = truckLaneVersionLineRepository.findAllByTruckLaneVersion_IdAndDeletedFalseOrderByIdAsc(versionId) + if (lines.isEmpty()) return "No lines to restore for versionId=$versionId" + + val snapshottedIds = lines.mapNotNull { it.truckRowId }.filter { it > 0 }.toSet() + if (snapshottedIds.isEmpty()) { + return "No valid truckRowIds in snapshot for versionId=$versionId" + } + + val fullBoard = isFullBoardSnapshot(version, lines) + if (fullBoard) { + val currentAll = truckRepository.findAllForRouteBoard() + val extras = currentAll.filter { t -> t.id != null && t.id !in snapshottedIds } + extras.forEach { it.deleted = true } + if (extras.isNotEmpty()) { + truckRepository.saveAll(extras) + } + } else { + val lane = version.truckLanceCode!!.trim() + if (lane.isNotEmpty()) { + val currentLane = truckRepository.findAllByTruckLanceCodeAndDeletedFalse(lane) + val extras = currentLane.filter { t -> t.id != null && t.id !in snapshottedIds } + extras.forEach { it.deleted = true } + if (extras.isNotEmpty()) { + truckRepository.saveAll(extras) + } + } + } + + val trucksById = truckRepository.findAllById(snapshottedIds.toList()).associateBy { it.id } + + val updated = lines.mapNotNull { line -> + val truckId = line.truckRowId ?: return@mapNotNull null + if (truckId <= 0) return@mapNotNull null + val truck = trucksById[truckId] ?: return@mapNotNull null + + truck.deleted = false + truck.apply { + // Restore only the fields we snapshot. + this.truckLanceCode = line.truckLanceCode ?: version.truckLanceCode + this.loadingSequence = line.loadingSequence + this.districtReference = line.districtReference + val sid = line.storeId?.trim()?.takeUnless { it.isEmpty() || it == "-" } + if (sid != null) this.storeId = sid + this.shopCode = line.shopCode + this.shopName = line.branchName + this.remark = line.remark + this.departureTime = + line.departureTime?.trim()?.takeIf { it.isNotEmpty() }?.let { LocalTime.parse(it) } + val lid = line.logisticId + this.logistic = + if (lid != null && lid > 0) { + logisticRepository.findByIdAndDeletedFalse(lid) + } else { + null + } + } + } + if (updated.isNotEmpty()) { + truckRepository.saveAll(updated) + } + + createSnapshot( + CreateTruckLaneSnapshotRequest( + truckLanceCode = null, + note = "restore from versionId=$versionId", + ) + ) + + return "Restored versionId=$versionId" + } +} diff --git a/src/main/java/com/ffii/fpsms/modules/pickOrder/service/TruckService.kt b/src/main/java/com/ffii/fpsms/modules/pickOrder/service/TruckService.kt index a473ae3..5d60425 100644 --- a/src/main/java/com/ffii/fpsms/modules/pickOrder/service/TruckService.kt +++ b/src/main/java/com/ffii/fpsms/modules/pickOrder/service/TruckService.kt @@ -5,11 +5,17 @@ import com.ffii.core.support.JdbcDao import com.ffii.core.utils.ExcelUtils import org.apache.poi.ss.usermodel.Sheet import org.apache.poi.ss.usermodel.Workbook +import org.apache.poi.xssf.usermodel.XSSFWorkbook import org.springframework.stereotype.Service +import com.ffii.fpsms.modules.logistic.entity.Logistic +import com.ffii.fpsms.modules.logistic.entity.LogisticRepository import com.ffii.fpsms.modules.pickOrder.entity.Truck +import com.ffii.fpsms.modules.pickOrder.entity.TruckLaneVersionLineRepository +import com.ffii.fpsms.modules.pickOrder.entity.TruckLaneVersionRepository import com.ffii.fpsms.modules.pickOrder.entity.TruckRepository import com.ffii.fpsms.modules.pickOrder.web.models.SaveTruckRequest import com.ffii.fpsms.modules.pickOrder.web.models.CreateTruckWithoutShopRequest +import com.ffii.fpsms.modules.pickOrder.web.models.UpdateLaneLogisticRequest import com.ffii.fpsms.modules.pickOrder.web.models.UpdateTruckShopDetailsRequest import java.time.LocalTime import java.time.format.DateTimeFormatter @@ -17,6 +23,14 @@ import com.ffii.fpsms.modules.master.entity.ShopRepository import com.ffii.fpsms.modules.master.entity.projections.ShopAndTruck import com.ffii.fpsms.modules.pickOrder.web.models.SaveTruckLane import jakarta.transaction.Transactional +import java.io.ByteArrayOutputStream +import java.text.Collator +import java.time.LocalDate +import java.util.Locale +import com.ffii.fpsms.modules.pickOrder.web.models.DiffFieldChange +import com.ffii.fpsms.modules.pickOrder.web.models.ParseRouteLanesExcelResponse +import com.ffii.fpsms.modules.pickOrder.web.models.RouteLaneImportPreviewRow +import com.ffii.fpsms.modules.pickOrder.web.models.TruckLaneVersionDiffLine @Service @@ -24,7 +38,17 @@ open class TruckService( private val jdbcDao: JdbcDao, private val truckRepository: TruckRepository, private val shopRepository: ShopRepository, + private val logisticRepository: LogisticRepository, + private val truckLaneVersionRepository: TruckLaneVersionRepository, + private val truckLaneVersionLineRepository: TruckLaneVersionLineRepository, ) : AbstractBaseEntityService(jdbcDao, truckRepository) { + + private fun logisticRefOrNull(id: Long?): Logistic? { + if (id == null) return null + return logisticRepository.findById(id).orElseThrow { + IllegalArgumentException("Logistic not found with id: $id") + } + } open fun saveTruck(request: SaveTruckRequest): Truck { val truck = request.id?.let { truckRepository.findById(it).orElse(null) @@ -39,41 +63,98 @@ open class TruckService( this.truckLanceCode = request.truckLanceCode this.departureTime = request.departureTime this.shop = shop - this.shopName = request.shopName + this.shopName = normalizeTruckShopDisplayName(request.shopName) this.shopCode = request.shopCode this.loadingSequence = request.loadingSequence this.remark = request.remark + this.districtReference = request.districtReference + this.logistic = logisticRefOrNull(request.logisticId) } return truckRepository.save(truck); } + /** + * 同 (truckLanceCode, remark) 桶內僅用來佔位的列:`shop` 為 null 且店名/代碼為空或舊版 Unassign。 + * 新增店鋪時應 **先 UPDATE 此列**,避免再 INSERT 一筆造成多條「空列」。 + */ + private fun isLanePlaceholderTruck(truck: Truck): Boolean { + if (truck.shop != null) return false + val nm = truck.shopName?.trim().orEmpty() + val cd = truck.shopCode?.trim().orEmpty() + if (nm.isEmpty() && cd.isEmpty()) return true + if (nm.equals("unassign", ignoreCase = true) || cd.equals("unassign", ignoreCase = true)) return true + if (nm.equals("unassigned", ignoreCase = true) || cd.equals("unassigned", ignoreCase = true)) return true + return false + } + + /** 與 [findAllByTruckLanceCodeAndRemarkAndDeletedFalse] 相同的 remark 桶規則 */ + private fun trucksInSameLaneBucket( + truckLanceCode: String, + storeId: String, + remark: String?, + ): List { + val bucketRemark = if (storeId == "4F") remark?.trim()?.takeIf { it.isNotEmpty() } else null + val trimmed = bucketRemark?.trim().orEmpty() + val blankRemark = trimmed.isEmpty() + return truckRepository.findAllByTruckLanceCodeAndRemarkAndDeletedFalse( + truckLanceCode.trim(), + blankRemark, + if (blankRemark) null else trimmed, + ) + } + + private fun softDeleteTruckRow(t: Truck) { + t.deleted = true + truckRepository.save(t) + } + private fun parseDepartureTime(timeStr: String?): LocalTime? { if (timeStr.isNullOrBlank()) return null return try { val cleaned = timeStr.trim().uppercase().replace(" ", "") - // 处理 3:00AM / 5:30PM 这类 12 小时制 + // 12 小時制:3:00AM、5:30:15PM(含秒) if (cleaned.contains("AM") || cleaned.contains("PM")) { val isPM = cleaned.contains("PM") val timePart = cleaned.replace("AM", "").replace("PM", "") val parts = timePart.split(":") - if (parts.size == 2) { - var hour = parts[0].toInt() - val minute = parts[1].toIntOrNull() ?: 0 - - if (isPM && hour != 12) hour += 12 - if (!isPM && hour == 12) hour = 0 - - LocalTime.of(hour, minute) - } else null - } else { - // 处理 17:30 / 3:00 这类 24 小时制 + when (parts.size) { + 2 -> { + var hour = parts[0].toInt() + val minute = parts[1].toIntOrNull() ?: 0 + if (isPM && hour != 12) hour += 12 + if (!isPM && hour == 12) hour = 0 + return LocalTime.of(hour, minute) + } + 3 -> { + var hour = parts[0].toInt() + val minute = parts[1].toIntOrNull() ?: 0 + val second = parts[2].toIntOrNull() ?: 0 + if (isPM && hour != 12) hour += 12 + if (!isPM && hour == 12) hour = 0 + return LocalTime.of(hour, minute, second) + } + } + } + // 24 小時制:須接受匯出欄位 formatDepartureForExcel 的 HH:mm:ss(如 17:30:00) + val t = timeStr.trim() + try { + LocalTime.parse(t) + } catch (_: Exception) { try { - LocalTime.parse(timeStr.trim(), DateTimeFormatter.ofPattern("H:mm")) + LocalTime.parse(t, DateTimeFormatter.ofPattern("H:mm:ss")) } catch (_: Exception) { - LocalTime.parse(timeStr.trim(), DateTimeFormatter.ofPattern("HH:mm")) + try { + LocalTime.parse(t, DateTimeFormatter.ofPattern("HH:mm:ss")) + } catch (_: Exception) { + try { + LocalTime.parse(t, DateTimeFormatter.ofPattern("H:mm")) + } catch (_: Exception) { + LocalTime.parse(t, DateTimeFormatter.ofPattern("HH:mm")) + } + } } } } catch (e: Exception) { @@ -99,6 +180,118 @@ open class TruckService( return letterPart + normalizedNumber } + /** MTMS 車線匯入:判斷 truck 列是否與 Excel 指向同一 shop(shopId 或 shopCode 多寫法)。 */ + private fun truckRowMatchesImportShop( + truck: Truck, + shopId: Long, + shopCodeRaw: String, + normalizedShopCode: String, + ): Boolean { + if (truck.shop?.id != null && truck.shop?.id == shopId) return true + val tc = truck.shopCode?.trim().orEmpty() + if (tc.isEmpty()) return false + val rawTrim = shopCodeRaw.trim() + return tc == rawTrim || + tc == normalizedShopCode || + normalizeShopCode(tc) == normalizedShopCode + } + + /** + * 同 (truckLanceCode, remark) 桶內同店至多一筆:保留 id 最小,其餘 soft delete。 + * 桶內無則 fallback 全域 findFirst(搬移他線列進當前桶)。 + */ + private fun resolveExistingTruckForRouteLaneImport( + truckLanceCode: String, + storeId: String, + laneRemark: String?, + shopId: Long, + shopCodeRaw: String, + normalizedShopCode: String, + ): Truck? { + val bucket = trucksInSameLaneBucket(truckLanceCode, storeId, laneRemark) + val matched = + bucket + .filter { truckRowMatchesImportShop(it, shopId, shopCodeRaw, normalizedShopCode) } + .sortedBy { it.id ?: Long.MAX_VALUE } + if (matched.isEmpty()) { + return truckRepository.findFirstByShopCodeAndStoreIdAndDeletedFalseOrderByIdAsc( + shopCodeRaw, + storeId, + ) + ?: truckRepository.findFirstByShopCodeAndStoreIdAndDeletedFalseOrderByIdAsc( + normalizedShopCode, + storeId, + ) + } + val primary = matched.first() + for (dup in matched.drop(1)) { + logger.warn( + "Route lane import: soft-delete duplicate truck id=${dup.id} shopCode=${dup.shopCode} " + + "storeId=$storeId lane=$truckLanceCode; keep id=${primary.id}", + ) + softDeleteTruckRow(dup) + } + val pid = primary.id ?: return primary + return truckRepository.findById(pid).orElse(primary) + } + + /** + * MTMS 車線匯入:Excel「板」欄 → `districtReference`。 + * 空白、`未分類`、舊版合成「板一…」皆視同未分類(存 null);其餘 trim 後原樣寫入。 + */ + private fun normalizeDistrictReferenceForRouteLaneImport(plateColumn: String): String? { + val t = plateColumn.trim() + if (t.isEmpty() || t == "未分類") return null + for (i in 0 until 64) { + if (t == RouteLaneExcelSupport.plateLabel(i)) return null + } + return t + } + + /** + * M18 / combo 店名可能是 `CF001 - 雞檔-健威坊店`;`truck.ShopName` 應存分店短名(如 `健威`)。 + * 規則:若為 SKU 前綴開頭或分段數≥3,取最後一段並去掉尾綴「坊店」。 + */ + private fun normalizeTruckShopDisplayName(raw: String?): String { + if (raw.isNullOrBlank()) return "" + val s = raw.trim() + val parts = s.split(Regex("\\s*-\\s*")).map { it.trim() }.filter { it.isNotEmpty() } + if (parts.size < 2) return s + val first = parts[0] + val codeSkuLike = first.matches(Regex("^[A-Za-z]{1,6}\\d+$")) + val takeLast = parts.size >= 3 || codeSkuLike + if (!takeLast) return s + var last = parts.last() + if (last.endsWith("坊店")) { + last = last.removeSuffix("坊店").trim() + } + return last.ifEmpty { s } + } + + /** + * MTMS「品牌」欄:M18 全名多為 `店鋪編 - 品牌 - 分店短名/…` 或 `編號 - 品牌-分店名`; + * 與 [normalizeTruckShopDisplayName] 用同一套 ` - ` 分段,取品牌段(非首段之 SKU 前綴、非最末段之顯示名)。 + */ + private fun deriveBrandFromShopFullName(raw: String?): String { + if (raw.isNullOrBlank()) return "" + val s = raw.trim() + val parts = s.split(Regex("\\s*-\\s*")).map { it.trim() }.filter { it.isNotEmpty() } + if (parts.size >= 3) { + return parts[1] + } + if (parts.size == 2) { + val first = parts[0] + val codeSkuLike = first.matches(Regex("^[A-Za-z]{1,6}\\d+$")) + if (codeSkuLike) { + val sub = parts[1].split('-').map { it.trim() }.filter { it.isNotEmpty() } + if (sub.size >= 2) { + return sub[0] + } + } + } + return "" + } + open fun importExcel(workbook: Workbook?): String { logger.info("--------- Start - Import Warehouse Excel -------"); @@ -121,7 +314,7 @@ open class TruckService( val START_ROW_INDEX = 3; logger.info("Total rows in sheet: ${sheet.lastRowNum + 1}, Processing from row ${START_ROW_INDEX + 1} to ${sheet.lastRowNum + 1}"); // Start Import - for (i in START_ROW_INDEX.. { @@ -269,6 +489,20 @@ open class TruckService( return truckRepository.findAllByTruckLanceCodeAndDeletedFalse(truckLanceCode) } + open fun findAllByTruckLanceCodeAndRemarkAndDeletedFalse(truckLanceCode: String, remark: String?): List { + val trimmed = remark?.trim() ?: "" + val blankRemark = trimmed.isEmpty() + return truckRepository.findAllByTruckLanceCodeAndRemarkAndDeletedFalse( + truckLanceCode, + blankRemark, + if (blankRemark) null else trimmed, + ) + } + + open fun findAllForRouteBoard(): List { + return truckRepository.findAllForRouteBoard() + } + open fun findAllUniqueShopNamesAndCodesFromTrucks(): List> { return truckRepository.findAllUniqueShopNamesAndCodesFromTrucks() } @@ -304,7 +538,7 @@ open class TruckService( // Always use shopName and shopCode from request (from truck table), not from shop entity // This allows truck table to have different shop names/codes than shop table if (request.shopName != null) { - truck.shopName = request.shopName + truck.shopName = normalizeTruckShopDisplayName(request.shopName) } if (request.shopCode != null) { @@ -325,25 +559,1071 @@ open class TruckService( return truckRepository.save(truck) } + @Transactional + open fun updateLogisticForEntireLane(request: UpdateLaneLogisticRequest): Int { + val trimmedCode = request.truckLanceCode.trim() + val trimmedRemark = request.remark?.trim() ?: "" + val blankRemark = trimmedRemark.isEmpty() + val logistic = logisticRefOrNull(request.logisticId) + val updated = truckRepository.bulkUpdateLogisticForLaneGroup( + logistic, + trimmedCode, + blankRemark, + if (blankRemark) null else trimmedRemark, + ) + if (updated == 0) { + throw IllegalArgumentException("No truck rows for lane: $trimmedCode") + } + return updated + } + @Transactional open fun createTruckWithoutShop(request: CreateTruckWithoutShopRequest): Truck { - // Create a new truck without a shop - val truck = Truck() - + val laneRows = trucksInSameLaneBucket( + request.truckLanceCode, + request.store_id, + request.remark, + ) + val placeholders = laneRows.filter { isLanePlaceholderTruck(it) } + .sortedBy { it.id ?: Long.MAX_VALUE } + val primary = placeholders.firstOrNull() + val truck = primary ?: Truck() + truck.apply { this.storeId = request.store_id this.truckLanceCode = request.truckLanceCode this.departureTime = request.departureTime this.shop = null - this.shopName = "Unassign" - this.shopCode = "Unassign" + /** 僅佔位列:不寫假店名(避免 DB / 報表出現 Unassign) */ + this.shopName = null + this.shopCode = null this.loadingSequence = request.loadingSequence this.districtReference = request.districtReference // Only set remark if store_id is "4F", otherwise set to null this.remark = if (request.store_id == "4F") request.remark else null + this.logistic = logisticRefOrNull(request.logisticId) } - - return truckRepository.save(truck) + + val saved = truckRepository.save(truck) + if (primary != null) { + placeholders.drop(1).forEach { softDeleteTruckRow(it) } + } + return saved + } + + private fun formatDepartureForExcel(t: LocalTime?): String { + if (t == null) return "" + return DateTimeFormatter.ofPattern("HH:mm:ss").format(t) + } + + /** 依 loading 順序切出連續同 district 的段(每段日後對應一個「板」標題列)。 */ + private fun splitDistrictSegments(trucks: List): List> { + if (trucks.isEmpty()) return emptyList() + val out = mutableListOf>() + var cur = mutableListOf() + var prev: String? = null + for (t in trucks) { + val d = t.districtReference?.trim().orEmpty() + if (prev != null && d != prev) { + out.add(cur) + cur = mutableListOf() + } + cur.add(t) + prev = d + } + out.add(cur) + return out + } + + /** + * 匯出用:每段給定「板」欄顯示文字後,依中文排序段順序;段內維持原 loading 順序。 + * 無區域代碼(未分類)的段固定輸出「未分類」(與前端 RouteBoard 一致);多段未分類時同字串需靠列順序區分。 + */ + private fun sortDistrictSegmentsForPlateColumnExport( + trucksInLoadingOrder: List, + ): List>> { + val segments = splitDistrictSegments(trucksInLoadingOrder) + val labeled = segments.map { seg -> + val d = seg.first().districtReference?.trim().orEmpty() + val label = if (d.isNotEmpty()) d else "未分類" + label to seg + } + val coll = Collator.getInstance(Locale.forLanguageTag("zh-HK")) + coll.strength = Collator.TERTIARY + return labeled.sortedWith { a, b -> coll.compare(a.first, b.first) } + } + + /** + * PDF 圖1:一個 workbook 內每個車線一個 sheet(MTMS_ROUTE_V1)。 + */ + open fun exportRouteLanesExcelBytes(laneIds: List): ByteArray { + val wb = XSSFWorkbook() + return ByteArrayOutputStream().use { bos -> + try { + val distinctIds = laneIds.distinct().filter { it.isNotBlank() } + if (distinctIds.isEmpty()) { + throw IllegalArgumentException("laneIds is empty") + } + for (laneId in distinctIds) { + val key = RouteLaneExcelSupport.decodeLaneId(laneId) + ?: throw IllegalArgumentException("Invalid lane id: $laneId") + val (code, remark) = key + val trucks = findAllByTruckLanceCodeAndRemarkAndDeletedFalse(code, remark) + .sortedWith(compareBy({ it.loadingSequence ?: 0 }, { it.id ?: 0L })) + if (trucks.isEmpty()) continue + + val sheet = wb.createSheet( + RouteLaneExcelSupport.uniqueSheetName(wb, code, remark), + ) + + var rr = sheet.createRow(RouteLaneExcelSupport.ROW_MARKER) + rr.createCell(RouteLaneExcelSupport.COL_META_A) + .setCellValue(RouteLaneExcelSupport.FORMAT_MARKER) + rr.createCell(RouteLaneExcelSupport.COL_META_B).setCellValue(code) + rr.createCell(RouteLaneExcelSupport.COL_META_C).setCellValue(remark ?: "") + + rr = sheet.createRow(RouteLaneExcelSupport.ROW_STORE) + rr.createCell(RouteLaneExcelSupport.COL_META_A).setCellValue("樓層") + rr.createCell(RouteLaneExcelSupport.COL_META_B) + .setCellValue(trucks.first().storeId ?: "") + + rr = sheet.createRow(RouteLaneExcelSupport.ROW_DEPARTURE_DEFAULT) + rr.createCell(RouteLaneExcelSupport.COL_META_A).setCellValue("出車時間") + rr.createCell(RouteLaneExcelSupport.COL_META_B).setCellValue( + formatDepartureForExcel(trucks.first().departureTime), + ) + + rr = sheet.createRow(RouteLaneExcelSupport.ROW_HEADER) + rr.createCell(RouteLaneExcelSupport.COL_AREA_PLATE).setCellValue("板") + rr.createCell(RouteLaneExcelSupport.COL_SHOP_NAME).setCellValue("店鋪名稱") + rr.createCell(RouteLaneExcelSupport.COL_BRAND).setCellValue("品牌") + rr.createCell(RouteLaneExcelSupport.COL_SHOP_CODE).setCellValue("店鋪編號") + rr.createCell(RouteLaneExcelSupport.COL_SCHEDULE).setCellValue("此店車期") + rr.createCell(RouteLaneExcelSupport.COL_DEPARTURE_ROW).setCellValue("出車時間") + + val segmentsForRows = sortDistrictSegmentsForPlateColumnExport(trucks) + var rowNum = RouteLaneExcelSupport.ROW_FIRST_DATA + for ((label, seg) in segmentsForRows) { + for ((idx, t) in seg.withIndex()) { + val colA = if (idx == 0) label else "" + val dataRow = sheet.createRow(rowNum++) + dataRow.createCell(RouteLaneExcelSupport.COL_AREA_PLATE).setCellValue(colA) + dataRow.createCell(RouteLaneExcelSupport.COL_SHOP_NAME).setCellValue(t.shopName ?: "") + dataRow.createCell(RouteLaneExcelSupport.COL_BRAND) + .setCellValue(deriveBrandFromShopFullName(t.shop?.name)) + dataRow.createCell(RouteLaneExcelSupport.COL_SHOP_CODE).setCellValue(t.shopCode ?: "") + dataRow.createCell(RouteLaneExcelSupport.COL_SCHEDULE).setCellValue(t.remark ?: "") + dataRow.createCell(RouteLaneExcelSupport.COL_DEPARTURE_ROW).setCellValue( + formatDepartureForExcel(t.departureTime), + ) + } + } + RouteLaneExcelSupport.applyRouteLaneExportFinishing( + sheet, + wb, + RouteLaneExcelSupport.ROW_FIRST_DATA, + rowNum - 1, + ) + } + if (wb.numberOfSheets == 0) { + throw IllegalArgumentException("No lane data to export (check lane ids / empty lanes)") + } + wb.write(bos) + bos.toByteArray() + } finally { + wb.close() + } + } + } + + fun buildRouteReportFilename(): String { + val d = LocalDate.now().format(DateTimeFormatter.ofPattern("yyyy-MM-dd")) + return "車線Report_${d}.xlsx" + } + + private fun normalizeRemarkForLaneGroup(raw: String?): String { + val s = raw?.trim().orEmpty() + return s + } + + private fun laneKey(truckLanceCode: String?, remark: String?): Pair { + val code = truckLanceCode?.trim().orEmpty() + val rem = normalizeRemarkForLaneGroup(remark) + return code to rem + } + + /** + * 圖2:車線 Report(單一 sheet,每間物流公司一個水平區塊)。 + * laneIds 若不空,會限制只匯出指定 lane group(與前端 encodeLaneId 對應)。 + */ + open fun exportRouteReportExcelBytes( + laneIds: List, + preparedBy: String = "—", + ): ByteArray { + val wb = XSSFWorkbook() + return ByteArrayOutputStream().use { bos -> + try { + val trucksAll = truckRepository.findAllForRouteBoard() + val distinctLaneIds = laneIds.distinct().filter { it.isNotBlank() } + val decoded = + distinctLaneIds.mapNotNull { RouteLaneExcelSupport.decodeLaneId(it) } + if (distinctLaneIds.isNotEmpty() && decoded.isEmpty()) { + throw IllegalArgumentException("Invalid laneIds") + } + val filterKeys: Set> = + decoded + .map { (code, remark) -> + code.trim() to normalizeRemarkForLaneGroup(remark) + } + .toSet() + + val trucks = + if (filterKeys.isEmpty()) { + trucksAll + } else { + trucksAll.filter { t -> + val (c, r) = laneKey(t.truckLanceCode, t.remark) + filterKeys.contains(c to r) + } + } + + val coll = Collator.getInstance(Locale.forLanguageTag("zh-HK")).apply { + strength = Collator.TERTIARY + } + data class CompanyKey(val id: Long?, val name: String) + + val byCompanyId = trucks.groupBy { t -> + val id = t.logistic?.id + val nameRaw = t.logistic?.logisticName?.trim().orEmpty() + val name = if (nameRaw.isNotEmpty()) nameRaw else "未命名物流" + CompanyKey(id, name) + } + + val companies = byCompanyId.entries + .filter { it.value.isNotEmpty() } + .sortedWith { a, b -> + val c = coll.compare(a.key.name, b.key.name) + if (c != 0) c else (a.key.id ?: Long.MIN_VALUE).compareTo(b.key.id ?: Long.MIN_VALUE) + } + if (companies.isEmpty()) { + throw IllegalArgumentException("No lane data to export") + } + + val sheet = wb.createSheet(RouteReportExcelSupport.SHEET_NAME) + val st = RouteReportExcelSupport.buildStyles(wb) + + val today = LocalDate.now().format(DateTimeFormatter.ofPattern("yyyy-MM-dd")) + RouteReportExcelSupport.writeTitle( + sheet, + st, + "新車綫($today)更改車線", + "製表: $preparedBy", + companies.size, + ) + + val timeFmt = DateTimeFormatter.ofPattern("HH:mm") + + var blockIndex = 0 + for ((ck, list) in companies) { + val sorted = list.sortedWith( + compareBy( + { it.truckLanceCode?.trim().orEmpty() }, + { it.districtReference?.trim().orEmpty() }, + { it.loadingSequence ?: 0 }, + { it.id ?: 0L }, + ), + ) + + val metaTruck = sorted.first() + val logistic = metaTruck.logistic + val plate = logistic?.carPlate?.trim().orEmpty() + val driverName = logistic?.driverName?.trim().orEmpty() + val driverNumber = logistic?.driverNumber?.toString().orEmpty() + + // 先以 lane group(truckLanceCode + normalized remark)聚合,避免同 lane 因為某些 row 的 departureTime 為 null/不一致而被拆散 + data class LaneKey(val code: String, val remark: String) + data class LaneBucket( + val key: LaneKey, + val time: LocalTime, + val trucks: List, + ) + + val laneBuckets = sorted + .groupBy { t -> + val (c, r) = laneKey(t.truckLanceCode, t.remark) + LaneKey(c, r) + } + .entries + .map { (k, laneTrucks) -> + val laneTime = laneTrucks.firstNotNullOfOrNull { it.departureTime } ?: LocalTime.MIDNIGHT + LaneBucket(k, laneTime, laneTrucks) + } + + val timeGroups = laneBuckets + .groupBy { it.time } + .toSortedMap() + .map { (time, bucketsAtTime) -> + val lanes = bucketsAtTime + .sortedWith { a, b -> + val c1 = coll.compare(a.key.code, b.key.code) + if (c1 != 0) c1 else coll.compare(a.key.remark, b.key.remark) + } + .map { bucket -> + val laneLabel = + if (bucket.key.remark.isNotEmpty()) { + "${bucket.key.code}-${bucket.key.remark}" + } else { + bucket.key.code + }.ifEmpty { "—" } + + val districts = bucket.trucks + .groupBy { it.districtReference?.trim().orEmpty() } + .mapKeys { (k) -> if (k.isNotEmpty()) k else "未分類" } + .toList() + .sortedWith { a, b -> + if (a.first == "未分類") -1 + else if (b.first == "未分類") 1 + else coll.compare(a.first, b.first) + } + .map { (district, dTrucks) -> + val shops = dTrucks + .sortedWith(compareBy({ it.loadingSequence ?: 0 }, { it.id ?: 0L })) + .map { t -> + val name = t.shopName?.trim().takeUnless { it.isNullOrBlank() } + ?: t.shopCode?.trim().takeUnless { it.isNullOrBlank() } + ?: "—" + val brand = deriveBrandFromShopFullName(t.shop?.name).trim() + val code = t.shopCode?.trim().orEmpty() + val lines = ArrayList(3) + lines.add(name) + if (brand.isNotEmpty()) lines.add(brand) + if (code.isNotEmpty() && code != name) lines.add(code) + lines.joinToString("\n") + } + RouteReportExcelSupport.DistrictGroup(district, shops) + } + RouteReportExcelSupport.LaneGroup(laneLabel, districts) + } + RouteReportExcelSupport.TimeGroup(time.format(timeFmt), lanes) + } + + val distinctShopCount = sorted + .map { + val code = it.shopCode?.trim().orEmpty() + if (code.isNotEmpty()) "code:$code" else "id:${it.id}" + } + .toSet() + .size + + RouteReportExcelSupport.writeCompanyBlock( + sheet, + st, + blockIndex, + 1, + RouteReportExcelSupport.BlockMeta( + companyName = ck.name, + plate = plate, + driverName = driverName, + driverNumber = driverNumber, + ), + timeGroups, + distinctShopCount, + ) + blockIndex++ + } + + wb.write(bos) + bos.toByteArray() + } finally { + wb.close() + } + } + } + + data class ExportTruckLaneVersionReportInput( + val fromVersionId: Long, + val toVersionId: Long, + val preparedBy: String, + ) + + /** + * 匯出「版本 Log 車線報告」: + * - Sheet1:版本異動報告(高亮 + 文字說明) + * - 其餘 sheets:每車線一個 worksheet(MTMS_ROUTE_V1)— 內容來自 toVersion 快照 lines + */ + open fun exportTruckLaneVersionReportExcelBytes(input: ExportTruckLaneVersionReportInput): ByteArray { + val wb = XSSFWorkbook() + return ByteArrayOutputStream().use { bos -> + try { + if (input.fromVersionId == input.toVersionId) { + throw IllegalArgumentException("fromVersionId and toVersionId must be different") + } + val fromV = truckLaneVersionRepository.findByIdAndDeletedFalse(input.fromVersionId) + ?: throw IllegalArgumentException("Version not found: ${input.fromVersionId}") + val toV = truckLaneVersionRepository.findByIdAndDeletedFalse(input.toVersionId) + ?: throw IllegalArgumentException("Version not found: ${input.toVersionId}") + + // 避免 from/to 顛倒導致新增/刪除/移動語意反轉 + if (fromV.created != null && toV.created != null && fromV.created!!.isAfter(toV.created)) { + throw IllegalArgumentException("fromVersionId must be older than toVersionId") + } + + val fromLines = truckLaneVersionLineRepository + .findAllByTruckLaneVersion_IdAndDeletedFalseOrderByIdAsc(input.fromVersionId) + val toLines = truckLaneVersionLineRepository + .findAllByTruckLaneVersion_IdAndDeletedFalseOrderByIdAsc(input.toVersionId) + + // 大檔保護:避免 OOM / timeout + val maxLines = 80_000 + val maxLaneSheets = 300 + if (fromLines.size + toLines.size > maxLines) { + throw IllegalArgumentException("Snapshot too large to export (lines>${maxLines})") + } + + val logisticIds = (fromLines.mapNotNull { it.logisticId } + toLines.mapNotNull { it.logisticId }).toSet() + val logisticNameById = + if (logisticIds.isEmpty()) { + emptyMap() + } else { + logisticRepository.findAllById(logisticIds).associate { (it.id ?: -1L) to (it.logisticName ?: "") } + } + + val diffLines = buildVersionDiffLines(fromLines, toLines) + val summaryRows = buildSummaryRows(fromLines, toLines, diffLines, logisticNameById) + + val createdDate = (toV.created?.toString() ?: "").take(10) + val title = "車線報告(${createdDate.ifBlank { "—" }})更改車線" + val editor = (toV.modifiedBy ?: input.preparedBy).trim().ifBlank { input.preparedBy } + val created = toV.created?.toString() ?: "—" + + val stats = summarizeSummaryRows(summaryRows) + val statsText = + "新增 ${stats.added} · 移動 ${stats.moved} · 刪除 ${stats.deleted} · 欄位變更 ${stats.fieldChanged}" + + TruckLaneVersionReportExcelSupport.writeSummarySheet( + wb, + TruckLaneVersionReportExcelSupport.SummaryMeta( + title = title, + editor = editor, + created = created, + fromVersionId = input.fromVersionId, + toVersionId = input.toVersionId, + note = toV.note, + statsText = statsText, + ), + summaryRows, + ) + + // Sheet2:同正常車線報告版面,但把異動 row 高亮(資料來自 toVersion 快照) + val logisticsByIdForVersionReport = if (logisticIds.isEmpty()) emptyMap() else + logisticRepository.findAllById(logisticIds).associateBy { it.id ?: -1L } + appendVersionRouteReportSheet( + wb, + createdDate.ifBlank { "—" }, + editor, + toLines, + diffLines, + logisticNameById, + logisticsByIdForVersionReport, + ) + + // lane sheets (MTMS_ROUTE_V1) based on toLines snapshot + appendLaneSheetsFromSnapshotLines(wb, toLines, maxLaneSheets) + + wb.write(bos) + bos.toByteArray() + } finally { + wb.close() + } + } + } + + private data class SummaryStats( + val added: Int, + val moved: Int, + val deleted: Int, + val fieldChanged: Int, + ) + + private fun summarizeSummaryRows(rows: List): SummaryStats { + var added = 0 + var moved = 0 + var deleted = 0 + var fieldChanged = 0 + for (r in rows) { + when (r.type) { + TruckLaneVersionReportExcelSupport.RowType.ADDED -> added++ + TruckLaneVersionReportExcelSupport.RowType.MOVED -> moved++ + TruckLaneVersionReportExcelSupport.RowType.DELETED -> deleted++ + TruckLaneVersionReportExcelSupport.RowType.EDITED -> {} + } + if (r.changedFields.isNotEmpty()) fieldChanged++ + } + return SummaryStats(added, moved, deleted, fieldChanged) + } + + private fun laneLabel(code: String?, remark: String?): String { + val c = code?.trim().orEmpty() + val r = remark?.trim().orEmpty() + if (c.isEmpty() && r.isEmpty()) return "—" + if (r.isEmpty()) return c + return "$c-$r" + } + + private fun buildVersionDiffLines( + fromLines: List, + toLines: List, + ): List { + val fromByRow = fromLines.associateBy { it.truckRowId ?: -1 } + val toByRow = toLines.associateBy { it.truckRowId ?: -1 } + val allKeys = (fromByRow.keys + toByRow.keys).filter { it > 0 }.sorted() + val changed = ArrayList() + + fun s(v: Any?): String? = v?.toString() + + for (key in allKeys) { + val a = fromByRow[key] + val b = toByRow[key] + val changes = ArrayList() + + if (s(a?.truckLanceCode) != s(b?.truckLanceCode)) changes.add(DiffFieldChange("truckLanceCode", s(a?.truckLanceCode), s(b?.truckLanceCode))) + if (s(a?.shopCode) != s(b?.shopCode)) changes.add(DiffFieldChange("shopCode", s(a?.shopCode), s(b?.shopCode))) + if (s(a?.branchName) != s(b?.branchName)) changes.add(DiffFieldChange("branchName", s(a?.branchName), s(b?.branchName))) + if (s(a?.districtReference) != s(b?.districtReference)) changes.add(DiffFieldChange("districtReference", s(a?.districtReference), s(b?.districtReference))) + if (s(a?.loadingSequence) != s(b?.loadingSequence)) changes.add(DiffFieldChange("loadingSequence", s(a?.loadingSequence), s(b?.loadingSequence))) + if (s(a?.departureTime) != s(b?.departureTime)) changes.add(DiffFieldChange("departureTime", s(a?.departureTime), s(b?.departureTime))) + if (s(a?.storeId) != s(b?.storeId)) changes.add(DiffFieldChange("storeId", s(a?.storeId), s(b?.storeId))) + if (s(a?.remark) != s(b?.remark)) changes.add(DiffFieldChange("remark", s(a?.remark), s(b?.remark))) + if (s(a?.logisticId) != s(b?.logisticId)) changes.add(DiffFieldChange("logisticId", s(a?.logisticId), s(b?.logisticId))) + + if (changes.isNotEmpty()) { + changed.add( + TruckLaneVersionDiffLine( + truckRowId = key, + shopCode = b?.shopCode ?: a?.shopCode, + changes = changes, + truckLanceCode = b?.truckLanceCode ?: a?.truckLanceCode, + remark = b?.remark ?: a?.remark, + ), + ) + } + } + return changed + } + + private val FIELD_LABEL = mapOf( + "departureTime" to "發車時段", + "loadingSequence" to "裝載順序", + "branchName" to "分店名稱", + "districtReference" to "區域", + "shopCode" to "店鋪代碼", + "storeId" to "樓層/店別", + "remark" to "備註", + "truckLanceCode" to "車線代碼", + "logisticId" to "物流公司", + ) + + private fun buildSummaryRows( + fromLines: List, + toLines: List, + diffs: List, + logisticNameById: Map, + ): List { + val fromByRow = fromLines.associateBy { it.truckRowId ?: -1 } + val toByRow = toLines.associateBy { it.truckRowId ?: -1 } + + fun shopName(line: com.ffii.fpsms.modules.pickOrder.entity.TruckLaneVersionLine?): String { + val n = line?.branchName?.trim().orEmpty() + return if (n.isNotEmpty()) n else (line?.shopCode?.trim().orEmpty().ifEmpty { "—" }) + } + + val out = ArrayList() + for (d in diffs) { + val a = fromByRow[d.truckRowId] + val b = toByRow[d.truckRowId] + val fromLane = laneLabel(a?.truckLanceCode, a?.remark) + val toLane = laneLabel(b?.truckLanceCode, b?.remark) + val fromEmpty = fromLane == "—" + val toEmpty = toLane == "—" + val type = + if (fromEmpty && !toEmpty) TruckLaneVersionReportExcelSupport.RowType.ADDED + else if (!fromEmpty && toEmpty) TruckLaneVersionReportExcelSupport.RowType.DELETED + else if (fromLane != toLane) TruckLaneVersionReportExcelSupport.RowType.MOVED + else TruckLaneVersionReportExcelSupport.RowType.EDITED + + val changedFields = d.changes + .map { it.field } + .filterNot { it == "truckLanceCode" || it == "remark" } + .toSet() + val textBits = ArrayList() + if (type == TruckLaneVersionReportExcelSupport.RowType.ADDED) { + textBits.add("新增到 $toLane") + } else if (type == TruckLaneVersionReportExcelSupport.RowType.DELETED) { + textBits.add("自 $fromLane 移除") + } else if (type == TruckLaneVersionReportExcelSupport.RowType.MOVED) { + textBits.add("由 $fromLane → $toLane") + } + for (c in d.changes) { + if (c.field == "truckLanceCode" || c.field == "remark") continue + val label = FIELD_LABEL[c.field] ?: c.field + val from = + if (c.field == "logisticId") { + val id = c.from?.trim()?.toLongOrNull() + val name = id?.let { logisticNameById[it] }?.trim().orEmpty() + name.ifEmpty { c.from?.trim().takeUnless { it.isNullOrBlank() } ?: "—" } + } else { + c.from?.trim().takeUnless { it.isNullOrBlank() } ?: "—" + } + val to = + if (c.field == "logisticId") { + val id = c.to?.trim()?.toLongOrNull() + val name = id?.let { logisticNameById[it] }?.trim().orEmpty() + name.ifEmpty { c.to?.trim().takeUnless { it.isNullOrBlank() } ?: "—" } + } else { + c.to?.trim().takeUnless { it.isNullOrBlank() } ?: "—" + } + if (from != to) textBits.add("$label:$from → $to") + } + + out.add( + TruckLaneVersionReportExcelSupport.SummaryRow( + type = type, + shopName = shopName(b ?: a), + shopCode = (b?.shopCode ?: a?.shopCode ?: "").trim(), + fromLane = fromLane, + toLane = toLane, + changeText = textBits.joinToString(";").ifEmpty { "欄位變更" }, + changedFields = changedFields, + ), + ) + } + return out + } + + private fun appendLaneSheetsFromSnapshotLines( + wb: XSSFWorkbook, + toLines: List, + maxLaneSheets: Int, + ) { + data class LaneKey(val code: String, val remark: String) + fun keyOf(l: com.ffii.fpsms.modules.pickOrder.entity.TruckLaneVersionLine): LaneKey { + val c = l.truckLanceCode?.trim().orEmpty() + val r = normalizeRemarkForLaneGroup(l.remark) + return LaneKey(c, r) + } + + val codes = toLines.mapNotNull { it.shopCode?.trim()?.takeIf { s -> s.isNotEmpty() } }.toSet() + val shopNameByCode = if (codes.isEmpty()) emptyMap() else + shopRepository.findAllByCodeInAndDeletedIsFalse(codes).associateBy({ it.code ?: "" }, { it.name ?: "" }) + + fun deriveBrandByShopCode(code: String?): String { + val c = code?.trim().orEmpty() + if (c.isEmpty()) return "" + val full = shopNameByCode[c].orEmpty() + return deriveBrandFromShopFullName(full) + } + + fun parseLocalTimeOrNull(raw: String?): LocalTime? = parseDepartureTime(raw) + + // group by lane + val byLane = toLines + .filter { !it.truckLanceCode.isNullOrBlank() } + .groupBy(::keyOf) + .entries + .sortedWith(compareBy({ it.key.code }, { it.key.remark })) + + if (byLane.size > maxLaneSheets) { + throw IllegalArgumentException("Too many lane sheets to export (>${maxLaneSheets})") + } + + for ((k, linesRaw) in byLane) { + val lines = linesRaw.sortedWith(compareBy({ it.loadingSequence ?: 0 }, { it.truckRowId ?: 0L }, { it.id ?: 0L })) + if (lines.isEmpty()) continue + val sheetName = RouteLaneExcelSupport.uniqueSheetName(wb, k.code, k.remark.ifEmpty { null }) + val sheet = wb.createSheet(sheetName) + + // marker/meta + var rr = sheet.createRow(RouteLaneExcelSupport.ROW_MARKER) + rr.createCell(RouteLaneExcelSupport.COL_META_A).setCellValue(RouteLaneExcelSupport.FORMAT_MARKER) + rr.createCell(RouteLaneExcelSupport.COL_META_B).setCellValue(k.code) + rr.createCell(RouteLaneExcelSupport.COL_META_C).setCellValue(k.remark) + + rr = sheet.createRow(RouteLaneExcelSupport.ROW_STORE) + rr.createCell(RouteLaneExcelSupport.COL_META_A).setCellValue("樓層") + rr.createCell(RouteLaneExcelSupport.COL_META_B).setCellValue(lines.first().storeId ?: "") + + rr = sheet.createRow(RouteLaneExcelSupport.ROW_DEPARTURE_DEFAULT) + rr.createCell(RouteLaneExcelSupport.COL_META_A).setCellValue("出車時間") + val deptDefault = parseLocalTimeOrNull(lines.firstNotNullOfOrNull { it.departureTime }) + rr.createCell(RouteLaneExcelSupport.COL_META_B).setCellValue(formatDepartureForExcel(deptDefault)) + + rr = sheet.createRow(RouteLaneExcelSupport.ROW_HEADER) + rr.createCell(RouteLaneExcelSupport.COL_AREA_PLATE).setCellValue("板") + rr.createCell(RouteLaneExcelSupport.COL_SHOP_NAME).setCellValue("店鋪名稱") + rr.createCell(RouteLaneExcelSupport.COL_BRAND).setCellValue("品牌") + rr.createCell(RouteLaneExcelSupport.COL_SHOP_CODE).setCellValue("店鋪編號") + rr.createCell(RouteLaneExcelSupport.COL_SCHEDULE).setCellValue("此店車期") + rr.createCell(RouteLaneExcelSupport.COL_DEPARTURE_ROW).setCellValue("出車時間") + + // segments by district changes in loading order + val segments = ArrayList>() + var cur = ArrayList() + var prev = "" + for (l in lines) { + val d = l.districtReference?.trim().orEmpty() + if (cur.isNotEmpty() && d != prev) { + segments.add(cur) + cur = ArrayList() + } + cur.add(l) + prev = d + } + if (cur.isNotEmpty()) segments.add(cur) + + val coll = Collator.getInstance(Locale.forLanguageTag("zh-HK")).apply { strength = Collator.TERTIARY } + val labeled = segments.map { seg -> + val d = seg.first().districtReference?.trim().orEmpty() + val label = if (d.isNotEmpty()) d else "未分類" + label to seg + }.sortedWith { a, b -> coll.compare(a.first, b.first) } + + var rowNum = RouteLaneExcelSupport.ROW_FIRST_DATA + for ((label, seg) in labeled) { + for ((idx, l) in seg.withIndex()) { + val colA = if (idx == 0) label else "" + val dataRow = sheet.createRow(rowNum++) + dataRow.createCell(RouteLaneExcelSupport.COL_AREA_PLATE).setCellValue(colA) + dataRow.createCell(RouteLaneExcelSupport.COL_SHOP_NAME).setCellValue(l.branchName ?: "") + dataRow.createCell(RouteLaneExcelSupport.COL_BRAND).setCellValue(deriveBrandByShopCode(l.shopCode)) + dataRow.createCell(RouteLaneExcelSupport.COL_SHOP_CODE).setCellValue(l.shopCode ?: "") + dataRow.createCell(RouteLaneExcelSupport.COL_SCHEDULE).setCellValue(l.remark ?: "") + val dept = parseLocalTimeOrNull(l.departureTime) ?: deptDefault + dataRow.createCell(RouteLaneExcelSupport.COL_DEPARTURE_ROW).setCellValue(formatDepartureForExcel(dept)) + } + } + + RouteLaneExcelSupport.applyRouteLaneExportFinishing( + sheet, + wb, + RouteLaneExcelSupport.ROW_FIRST_DATA, + rowNum - 1, + ) + } + } + + private fun appendVersionRouteReportSheet( + wb: XSSFWorkbook, + createdDate: String, + editor: String, + toLines: List, + diffLines: List, + logisticNameById: Map, + logisticsById: Map, + ) { + val diffByRowId = diffLines.associateBy { it.truckRowId } + val logisticChangeByRowId = diffLines.mapNotNull { d -> + d.changes.firstOrNull { it.field == "logisticId" }?.let { d.truckRowId to it } + }.toMap() + + val changedFieldsByRowId: Map> = diffLines.associate { d -> + d.truckRowId to d.changes.map { it.field }.toSet() + } + val changedRowIds = changedFieldsByRowId.keys.toSet() + + val shopCodes = toLines.mapNotNull { it.shopCode?.trim()?.takeIf { s -> s.isNotEmpty() } }.toSet() + val shopNameByCode = if (shopCodes.isEmpty()) emptyMap() else + shopRepository.findAllByCodeInAndDeletedIsFalse(shopCodes).associateBy({ it.code ?: "" }, { it.name ?: "" }) + + fun brandByShopCode(code: String?): String { + val c = code?.trim().orEmpty() + if (c.isEmpty()) return "" + return deriveBrandFromShopFullName(shopNameByCode[c].orEmpty()).trim() + } + + fun laneLabel(code: String?, remark: String?): String { + val c = code?.trim().orEmpty() + val r = remark?.trim().orEmpty() + if (c.isEmpty() && r.isEmpty()) return "—" + return if (r.isEmpty()) c else "$c-$r" + } + + fun timeLabel(raw: String?): String { + val t = parseDepartureTime(raw) ?: return "00:00" + return DateTimeFormatter.ofPattern("HH:mm").format(t) + } + + // group by logisticId + val linesWithLane = toLines.filter { !it.truckLanceCode.isNullOrBlank() } + data class CompanyKey(val id: Long?, val name: String) + val byCompany = linesWithLane.groupBy { l -> + val id = l.logisticId + val logi = if (id != null) logisticsById[id] else null + val name = logi?.logisticName?.trim().orEmpty().ifEmpty { "未命名物流" } + CompanyKey(id, name) + }.entries.sortedBy { it.key.name } + + val st = TruckLaneVersionRouteReportExcelSupport.buildStyles(wb) + val sheet = wb.createSheet(TruckLaneVersionRouteReportExcelSupport.SHEET_NAME) + TruckLaneVersionRouteReportExcelSupport.writeTitle( + sheet, + st, + "車線報告($createdDate)更改車線", + "製表: $editor", + byCompany.size.coerceAtLeast(1), + ) + + var blockIndex = 0 + for ((ck, list) in byCompany) { + val metaLogi = ck.id?.let { logisticsById[it] } + val plate = metaLogi?.carPlate?.trim().orEmpty() + val driverName = metaLogi?.driverName?.trim().orEmpty() + val driverNumber = metaLogi?.driverNumber?.toString().orEmpty() + + // lane buckets by (code+remark) to avoid split by inconsistent times + data class LaneKey(val code: String, val remark: String) + data class LaneBucket(val key: LaneKey, val time: String, val lines: List) + val laneBuckets = list.groupBy { l -> + LaneKey(l.truckLanceCode?.trim().orEmpty(), normalizeRemarkForLaneGroup(l.remark)) + }.map { (k, ls) -> + val t = ls.firstNotNullOfOrNull { it.departureTime } ?: "" + LaneBucket(k, timeLabel(t), ls) + } + + val timeGroups = laneBuckets.groupBy { it.time }.toSortedMap().map { (time, bucketsAtTime) -> + val lanes = bucketsAtTime + .sortedWith(compareBy({ it.key.code }, { it.key.remark })) + .map { bucket -> + val lane = laneLabel(bucket.key.code, bucket.key.remark) + val districts = bucket.lines + .groupBy { it.districtReference?.trim().orEmpty().ifEmpty { "未分類" } } + .toSortedMap { a, b -> + if (a == "未分類") -1 + else if (b == "未分類") 1 + else a.compareTo(b) + } + .map { (district, dLines) -> + val shops = dLines + .sortedWith(compareBy({ it.loadingSequence ?: 0 }, { it.truckRowId ?: 0L }, { it.id ?: 0L })) + .map { l -> + val id = l.truckRowId ?: -1L + val changed = changedRowIds.contains(id) + val name = l.branchName?.trim().orEmpty().ifEmpty { l.shopCode?.trim().orEmpty().ifEmpty { "—" } } + val brand = brandByShopCode(l.shopCode) + val code = l.shopCode?.trim().orEmpty() + val extra = changedFieldsByRowId[id]?.let { fields -> + val shown = fields + .filterNot { it == "truckLanceCode" || it == "remark" || it == "logisticId" } + .map { FIELD_LABEL[it] ?: it } + if (shown.isNotEmpty()) "變更: ${shown.joinToString(",")}" else null + } + val logisticChange = logisticChangeByRowId[id]?.let { ch -> + val fromName = ch.from?.trim()?.toLongOrNull() + ?.let { logisticNameById[it] } + ?.trim() + .orEmpty() + .ifEmpty { ch.from?.trim().orEmpty() } + .ifEmpty { "—" } + val toName = ch.to?.trim()?.toLongOrNull() + ?.let { logisticNameById[it] } + ?.trim() + .orEmpty() + .ifEmpty { ch.to?.trim().orEmpty() } + .ifEmpty { "—" } + if (fromName != toName) "物流公司:$fromName → $toName" else null + } + val linesText = ArrayList(4) + linesText.add(name) + if (brand.isNotEmpty()) linesText.add(brand) + if (code.isNotEmpty() && code != name) linesText.add(code) + if (!extra.isNullOrBlank()) linesText.add(extra) + if (!logisticChange.isNullOrBlank()) linesText.add(logisticChange) + TruckLaneVersionRouteReportExcelSupport.ShopRow( + truckRowId = id, + text = linesText.joinToString("\n"), + changed = changed, + ) + } + TruckLaneVersionRouteReportExcelSupport.DistrictGroup(district, shops) + } + TruckLaneVersionRouteReportExcelSupport.LaneGroup(lane, districts) + } + TruckLaneVersionRouteReportExcelSupport.TimeGroup(time, lanes) + } + + val distinctShopCount = list.mapNotNull { it.shopCode?.trim()?.takeIf { s -> s.isNotEmpty() } }.toSet().size + + TruckLaneVersionRouteReportExcelSupport.writeCompanyBlock( + sheet, + st, + blockIndex, + 1, + TruckLaneVersionRouteReportExcelSupport.BlockMeta( + companyName = ck.name, + plate = plate, + driverName = driverName, + driverNumber = driverNumber, + ), + timeGroups, + distinctShopCount, + ) + blockIndex++ + } + } + + /** Parse MTMS_ROUTE_V1 workbook without writing to DB (for staged import preview). */ + open fun parseRouteLanesExcel(workbook: Workbook?): ParseRouteLanesExcelResponse { + if (workbook == null) { + return ParseRouteLanesExcelResponse(0, 0, emptyList()) + } + var sheetsProcessed = 0 + val previewRows = mutableListOf() + for (si in 0 until workbook.numberOfSheets) { + val sheet = workbook.getSheetAt(si) + val row0 = sheet.getRow(RouteLaneExcelSupport.ROW_MARKER) ?: continue + val marker = ExcelUtils.getStringValue(row0.getCell(RouteLaneExcelSupport.COL_META_A)).trim() + if (marker != RouteLaneExcelSupport.FORMAT_MARKER) { + logger.warn("Skip sheet ${sheet.sheetName}: not ${RouteLaneExcelSupport.FORMAT_MARKER}") + continue + } + val truckLanceCode = + ExcelUtils.getStringValue(row0.getCell(RouteLaneExcelSupport.COL_META_B)).trim() + if (truckLanceCode.isEmpty()) { + logger.warn("Skip sheet ${sheet.sheetName}: empty truckLanceCode") + continue + } + val remarkCell = + ExcelUtils.getStringValue(row0.getCell(RouteLaneExcelSupport.COL_META_C)).trim() + val laneRemark = remarkCell.ifEmpty { null } + + val rowStore = sheet.getRow(RouteLaneExcelSupport.ROW_STORE) + val storeId = + ExcelUtils.getStringValue(rowStore?.getCell(RouteLaneExcelSupport.COL_META_B)).trim() + if (storeId.isEmpty()) { + logger.warn("Skip sheet ${sheet.sheetName}: empty store id") + continue + } + + val rowDeptRow = sheet.getRow(RouteLaneExcelSupport.ROW_DEPARTURE_DEFAULT) + val defaultDeptStr = + ExcelUtils.getStringValue(rowDeptRow?.getCell(RouteLaneExcelSupport.COL_META_B)).trim() + val defaultDept = parseDepartureTime(defaultDeptStr) + + var currentDistrict = "" + var seq = 1 + for (i in RouteLaneExcelSupport.ROW_FIRST_DATA..sheet.lastRowNum) { + val row = sheet.getRow(i) ?: continue + val c0 = + ExcelUtils.getStringValue(row.getCell(RouteLaneExcelSupport.COL_AREA_PLATE)).trim() + if (c0.isNotEmpty()) { + currentDistrict = c0 + } + val shopName = + ExcelUtils.getStringValue(row.getCell(RouteLaneExcelSupport.COL_SHOP_NAME)).trim() + val shopCodeRaw = + ExcelUtils.getStringValue(row.getCell(RouteLaneExcelSupport.COL_SHOP_CODE)).trim() + if (shopCodeRaw.isEmpty()) { + continue + } + + val scheduleRemark = + ExcelUtils.getStringValue(row.getCell(RouteLaneExcelSupport.COL_SCHEDULE)).trim() + val rowDeptStr = + ExcelUtils.getStringValue(row.getCell(RouteLaneExcelSupport.COL_DEPARTURE_ROW)).trim() + val departure = parseDepartureTime(rowDeptStr) ?: defaultDept + if (departure == null) { + logger.warn("Sheet ${sheet.sheetName} row ${i + 1}: skipped — invalid departure") + continue + } + + val normalizedShopCode = normalizeShopCode(shopCodeRaw) + val allShops = shopRepository.findAllByDeletedIsFalse() + val shop = allShops.firstOrNull { it.code == shopCodeRaw } + ?: allShops.firstOrNull { it.code == normalizedShopCode } + if (shop == null) { + logger.warn( + "Sheet ${sheet.sheetName} row ${i + 1}: no shop for code '$shopCodeRaw' (normalized '$normalizedShopCode')", + ) + continue + } + + val effectiveRemark = + if (storeId == "4F") { + when { + scheduleRemark.isNotEmpty() -> scheduleRemark + laneRemark != null && laneRemark.isNotEmpty() -> laneRemark + else -> null + } + } else { + null + } + + val existingTruck = + resolveExistingTruckForRouteLaneImport( + truckLanceCode.trim(), + storeId, + laneRemark, + shop.id!!, + shopCodeRaw, + normalizedShopCode, + ) + val logisticId = existingTruck?.logistic?.id + + previewRows.add( + RouteLaneImportPreviewRow( + truckRowId = existingTruck?.id, + truckLanceCode = truckLanceCode, + remark = effectiveRemark, + storeId = storeId, + departureTime = departure.toString(), + shopId = shop.id!!, + shopName = shopName.ifEmpty { shop.name ?: "" }, + shopCode = normalizedShopCode, + loadingSequence = seq, + districtReference = normalizeDistrictReferenceForRouteLaneImport(currentDistrict), + logisticId = logisticId, + ), + ) + seq++ + } + sheetsProcessed++ + } + return ParseRouteLanesExcelResponse( + sheetCount = sheetsProcessed, + rowCount = previewRows.size, + rows = previewRows, + ) + } + + private fun saveTruckFromImportPreview(row: RouteLaneImportPreviewRow) { + val departure = parseDepartureTime(row.departureTime) + ?: throw IllegalArgumentException("Invalid departureTime: ${row.departureTime}") + saveTruck( + SaveTruckRequest( + id = row.truckRowId, + store_id = row.storeId, + truckLanceCode = row.truckLanceCode, + departureTime = departure, + shopId = row.shopId, + shopName = row.shopName, + shopCode = row.shopCode, + loadingSequence = row.loadingSequence, + remark = row.remark, + districtReference = row.districtReference, + logisticId = row.logisticId, + ), + ) + } + + /** 不做單一大 transaction:逐列 [saveTruck] 各自提交,部分列失敗時前面仍保留 */ + open fun importRouteLanesExcel(workbook: Workbook?): String { + if (workbook == null) { + return "Import Excel failure" + } + val parsed = parseRouteLanesExcel(workbook) + for (row in parsed.rows) { + saveTruckFromImportPreview(row) + } + return "Import Excel success: ${parsed.sheetCount} sheet(s), ${parsed.rowCount} row(s)" } } \ No newline at end of file diff --git a/src/main/java/com/ffii/fpsms/modules/pickOrder/web/TruckController.kt b/src/main/java/com/ffii/fpsms/modules/pickOrder/web/TruckController.kt index 65e0694..9df44fa 100644 --- a/src/main/java/com/ffii/fpsms/modules/pickOrder/web/TruckController.kt +++ b/src/main/java/com/ffii/fpsms/modules/pickOrder/web/TruckController.kt @@ -7,7 +7,11 @@ import org.springframework.web.bind.ServletRequestBindingException import jakarta.servlet.http.HttpServletRequest import org.apache.poi.ss.usermodel.Workbook import org.apache.poi.xssf.usermodel.XSSFWorkbook +import org.springframework.http.ContentDisposition +import org.springframework.http.HttpHeaders +import org.springframework.http.MediaType import org.springframework.http.ResponseEntity +import java.nio.charset.StandardCharsets import org.springframework.web.bind.annotation.* import org.springframework.web.multipart.MultipartHttpServletRequest @@ -17,12 +21,19 @@ import com.ffii.fpsms.modules.pickOrder.web.models.UpdateTruckShopDetailsRequest import com.ffii.fpsms.modules.pickOrder.service.TruckService import com.ffii.fpsms.modules.pickOrder.entity.TruckRepository import com.ffii.fpsms.modules.pickOrder.web.models.SaveTruckLane +import com.ffii.fpsms.modules.pickOrder.web.models.TruckLaneCombinationResponse +import com.ffii.fpsms.modules.pickOrder.web.models.ExportRouteLanesRequest +import com.ffii.fpsms.modules.pickOrder.web.models.ExportRouteReportRequest +import com.ffii.fpsms.modules.pickOrder.web.models.ExportTruckLaneVersionReportExcelRequest +import com.ffii.fpsms.modules.pickOrder.web.models.UpdateLaneLogisticRequest +import com.ffii.fpsms.modules.pickOrder.web.models.ParseRouteLanesExcelResponse import com.ffii.fpsms.modules.pickOrder.web.models.deleteTruckLane +import com.ffii.fpsms.modules.pickOrder.web.models.toLaneCombinationResponse import jakarta.validation.Valid @RestController @RequestMapping("/truck") -class TruckController( +open class TruckController( private val truckService: TruckService, private val truckRepository: TruckRepository, ) { @@ -80,6 +91,142 @@ class TruckController( } } + /** + * PDF 圖1:多車線匯出;每個 laneId(encodeLaneId)一個 worksheet,格式 MTMS_ROUTE_V1。 + */ + @PostMapping( + "/exportRouteLanesExcel", + consumes = [MediaType.APPLICATION_JSON_VALUE], + produces = ["application/vnd.openxmlformats-officedocument.spreadsheetml.sheet"], + ) + fun exportRouteLanesExcel(@RequestBody req: ExportRouteLanesRequest): ResponseEntity { + val bytes = truckService.exportRouteLanesExcelBytes(req.laneIds) + val filename = "MTMS_車線_${System.currentTimeMillis()}.xlsx" + val disposition = ContentDisposition.attachment() + .filename(filename, StandardCharsets.UTF_8) + .build() + return ResponseEntity.ok() + .header(HttpHeaders.CONTENT_DISPOSITION, disposition.toString()) + .contentType(MediaType.parseMediaType("application/vnd.openxmlformats-officedocument.spreadsheetml.sheet")) + .body(bytes) + } + + /** + * 圖2:車線 Report(單一 sheet;每間物流公司一個水平區塊) + */ + @PostMapping( + "/exportRouteReportExcel", + consumes = [MediaType.APPLICATION_JSON_VALUE], + produces = ["application/vnd.openxmlformats-officedocument.spreadsheetml.sheet"], + ) + fun exportRouteReportExcel( + request: HttpServletRequest, + @RequestBody req: ExportRouteReportRequest, + ): ResponseEntity { + val preparedBy = request.userPrincipal?.name?.trim().takeUnless { it.isNullOrBlank() } ?: "current user" + val bytes = truckService.exportRouteReportExcelBytes(req.laneIds, preparedBy) + val filename = truckService.buildRouteReportFilename() + val disposition = ContentDisposition.attachment() + .filename(filename, StandardCharsets.UTF_8) + .build() + return ResponseEntity.ok() + .header(HttpHeaders.CONTENT_DISPOSITION, disposition.toString()) + .contentType(MediaType.parseMediaType("application/vnd.openxmlformats-officedocument.spreadsheetml.sheet")) + .body(bytes) + } + + @PostMapping( + "/exportTruckLaneVersionReportExcel", + consumes = [MediaType.APPLICATION_JSON_VALUE], + produces = ["application/vnd.openxmlformats-officedocument.spreadsheetml.sheet"], + ) + open fun exportTruckLaneVersionReportExcel( + request: HttpServletRequest, + @RequestBody req: ExportTruckLaneVersionReportExcelRequest, + ): ResponseEntity { + val preparedBy = request.userPrincipal?.name?.trim().takeUnless { it.isNullOrBlank() } ?: "current user" + val bytes = truckService.exportTruckLaneVersionReportExcelBytes( + TruckService.ExportTruckLaneVersionReportInput( + fromVersionId = req.fromVersionId, + toVersionId = req.toVersionId, + preparedBy = preparedBy, + ), + ) + val filename = "車線版本報告_${System.currentTimeMillis()}.xlsx" + val disposition = ContentDisposition.attachment() + .filename(filename, StandardCharsets.UTF_8) + .build() + return ResponseEntity.ok() + .header(HttpHeaders.CONTENT_DISPOSITION, disposition.toString()) + .contentType(MediaType.parseMediaType("application/vnd.openxmlformats-officedocument.spreadsheetml.sheet")) + .body(bytes) + } + + /** 與 [importRouteLanesExcel] 同一格式;僅解析、不寫入 DB(看板 staged import 預覽)。 */ + @PostMapping("/parseRouteLanesExcel") + @Throws(ServletRequestBindingException::class) + fun parseRouteLanesExcel(request: HttpServletRequest): ResponseEntity { + var workbook: Workbook? = null + try { + val multipartFile = (request as MultipartHttpServletRequest).getFile("multipartFileList") + workbook = XSSFWorkbook(multipartFile?.inputStream) + return ResponseEntity.ok(truckService.parseRouteLanesExcel(workbook)) + } catch (e: Exception) { + println("Error reading Excel file: ${e.message}") + return ResponseEntity.badRequest().body( + ParseRouteLanesExcelResponse(0, 0, emptyList()), + ) + } finally { + try { + workbook?.close() + } catch (_: Exception) { + } + } + } + + /** 與 [exportRouteLanesExcel] 同一格式;一個檔案內多 sheet,每 sheet 一條車線。 */ + @PostMapping("/importRouteLanesExcel") + @Throws(ServletRequestBindingException::class) + fun importRouteLanesExcel(request: HttpServletRequest): ResponseEntity<*> { + var workbook: Workbook? = null + try { + val multipartFile = (request as MultipartHttpServletRequest).getFile("multipartFileList") + workbook = XSSFWorkbook(multipartFile?.inputStream) + } catch (e: Exception) { + println("Error reading Excel file: ${e.message}") + return ResponseEntity.badRequest().body( + MessageResponse( + id = null, + name = null, + code = null, + type = "truck", + message = "Error reading Excel file: ${e.message}", + errorPosition = null, + entity = null, + ), + ) + } + try { + val result = truckService.importRouteLanesExcel(workbook) + return ResponseEntity.ok( + MessageResponse( + id = null, + name = null, + code = null, + type = "truck", + message = result, + errorPosition = null, + entity = null, + ), + ) + } finally { + try { + workbook?.close() + } catch (_: Exception) { + } + } + } + @PostMapping("/importExcel") @Throws(ServletRequestBindingException::class) fun importExcel(request: HttpServletRequest): ResponseEntity<*> { @@ -103,18 +250,25 @@ class TruckController( ) } - val result = truckService.importExcel(workbook) - return ResponseEntity.ok( - MessageResponse( - id = null, - name = null, - code = null, - type = "truck", - message = result, - errorPosition = null, - entity = null + try { + val result = truckService.importExcel(workbook) + return ResponseEntity.ok( + MessageResponse( + id = null, + name = null, + code = null, + type = "truck", + message = result, + errorPosition = null, + entity = null + ) ) - ) + } finally { + try { + workbook?.close() + } catch (_: Exception) { + } + } } @GetMapping("/findTruckLane/{shopId}") @@ -136,7 +290,7 @@ class TruckController( type = "truck", message = if (truck != null) "Truck lane updated successfully" else "Truck lane not found", errorPosition = null, - entity = truck + entity = null ) } catch (e: Exception) { return MessageResponse( @@ -151,6 +305,32 @@ class TruckController( } } + @PostMapping("/updateLaneLogistic") + fun updateLaneLogistic(@Valid @RequestBody request: UpdateLaneLogisticRequest): MessageResponse { + try { + val n = truckService.updateLogisticForEntireLane(request) + return MessageResponse( + id = null, + name = null, + code = request.truckLanceCode, + type = "truck", + message = "Updated logistic for $n truck row(s)", + errorPosition = null, + entity = null, + ) + } catch (e: Exception) { + return MessageResponse( + id = null, + name = null, + code = null, + type = "truck", + message = "Error: ${e.message}", + errorPosition = null, + entity = null, + ) + } + } + @PostMapping("/deleteTruckLane") fun deleteTruckLane(@Valid @RequestBody request: deleteTruckLane): MessageResponse { try { @@ -178,8 +358,10 @@ class TruckController( } @GetMapping("/findAllUniqueTruckLanceCodeAndRemarkCombinations") - fun findAllUniqueTruckLanceCodeAndRemarkCombinations(): List { - return truckService.findAllUniqueTruckLanceCodeAndRemarkCombinations() + fun findAllUniqueTruckLanceCodeAndRemarkCombinations(): List { + return truckService + .findAllUniqueTruckLanceCodeAndRemarkCombinations() + .map { it.toLaneCombinationResponse() } } @@ -193,6 +375,27 @@ class TruckController( return truckService.findAllByTruckLanceCodeAndDeletedFalse(truckLanceCode) } + /** + * Filter trucks by the same (truckLanceCode, remark) group as the unique-combinations query. + * Omit `remark` or pass empty for rows with NULL/empty remark. + */ + @GetMapping("/findAllByTruckLanceCodeAndRemarkAndDeletedFalse") + fun findAllByTruckLanceCodeAndRemarkAndDeletedFalse( + @RequestParam truckLanceCode: String, + @RequestParam(required = false) remark: String?, + ): List { + return truckService.findAllByTruckLanceCodeAndRemarkAndDeletedFalse(truckLanceCode, remark) + } + + /** + * RouteBoard O(1) load: return all truck rows (deleted=false) once. + * Frontend groups by (truckLanceCode, normalizedRemark). + */ + @GetMapping("/findAllForRouteBoard") + fun findAllForRouteBoard(): List { + return truckService.findAllForRouteBoard() + } + @GetMapping("/findAllUniqueShopNamesAndCodesFromTrucks") fun findAllUniqueShopNamesAndCodesFromTrucks(): List> { return truckService.findAllUniqueShopNamesAndCodesFromTrucks() diff --git a/src/main/java/com/ffii/fpsms/modules/pickOrder/web/TruckLaneVersionController.kt b/src/main/java/com/ffii/fpsms/modules/pickOrder/web/TruckLaneVersionController.kt new file mode 100644 index 0000000..90eba64 --- /dev/null +++ b/src/main/java/com/ffii/fpsms/modules/pickOrder/web/TruckLaneVersionController.kt @@ -0,0 +1,73 @@ +package com.ffii.fpsms.modules.pickOrder.web + +import com.ffii.fpsms.modules.master.web.models.MessageResponse +import com.ffii.fpsms.modules.pickOrder.service.TruckLaneVersionService +import com.ffii.fpsms.modules.pickOrder.web.models.CreateTruckLaneSnapshotRequest +import com.ffii.fpsms.modules.pickOrder.web.models.TruckLaneVersionDiffResponse +import com.ffii.fpsms.modules.pickOrder.web.models.TruckLaneVersionLineResponse +import com.ffii.fpsms.modules.pickOrder.web.models.TruckLaneVersionResponse +import com.ffii.fpsms.modules.pickOrder.web.models.UpdateTruckLaneVersionNoteRequest +import jakarta.validation.Valid +import org.springframework.http.ResponseEntity +import org.springframework.web.bind.annotation.* + +@RestController +@RequestMapping("/truckLaneVersion") +class TruckLaneVersionController( + private val truckLaneVersionService: TruckLaneVersionService, +) { + @PostMapping("/snapshot") + fun createSnapshot(@Valid @RequestBody request: CreateTruckLaneSnapshotRequest): TruckLaneVersionResponse { + return truckLaneVersionService.createSnapshot(request) + } + + @GetMapping + fun listVersions( + @RequestParam(required = false) truckLanceCode: String?, + ): List { + val lane = truckLanceCode?.trim()?.takeIf { it.isNotEmpty() } + return if (lane != null) { + truckLaneVersionService.listVersionsByLane(lane) + } else { + truckLaneVersionService.listAllVersions() + } + } + + @GetMapping("/{versionId}/lines") + fun getLines(@PathVariable versionId: Long): List { + return truckLaneVersionService.getVersionLines(versionId) + } + + @PatchMapping("/{versionId}/note") + fun updateNote( + @PathVariable versionId: Long, + @Valid @RequestBody request: UpdateTruckLaneVersionNoteRequest, + ): TruckLaneVersionResponse { + return truckLaneVersionService.updateNote(versionId, request.note) + } + + @GetMapping("/diff") + fun diff( + @RequestParam fromVersionId: Long, + @RequestParam toVersionId: Long, + ): TruckLaneVersionDiffResponse { + return truckLaneVersionService.diff(fromVersionId, toVersionId) + } + + @PostMapping("/{versionId}/restore") + fun restore(@PathVariable versionId: Long): ResponseEntity { + val msg = truckLaneVersionService.restore(versionId) + return ResponseEntity.ok( + MessageResponse( + id = null, + name = null, + code = null, + type = "OK", + message = msg, + errorPosition = null, + entity = null, + ) + ) + } +} + diff --git a/src/main/java/com/ffii/fpsms/modules/pickOrder/web/models/ExportRouteLanesRequest.kt b/src/main/java/com/ffii/fpsms/modules/pickOrder/web/models/ExportRouteLanesRequest.kt new file mode 100644 index 0000000..9ce0111 --- /dev/null +++ b/src/main/java/com/ffii/fpsms/modules/pickOrder/web/models/ExportRouteLanesRequest.kt @@ -0,0 +1,5 @@ +package com.ffii.fpsms.modules.pickOrder.web.models + +data class ExportRouteLanesRequest( + val laneIds: List, +) diff --git a/src/main/java/com/ffii/fpsms/modules/pickOrder/web/models/ExportRouteReportRequest.kt b/src/main/java/com/ffii/fpsms/modules/pickOrder/web/models/ExportRouteReportRequest.kt new file mode 100644 index 0000000..cb70349 --- /dev/null +++ b/src/main/java/com/ffii/fpsms/modules/pickOrder/web/models/ExportRouteReportRequest.kt @@ -0,0 +1,11 @@ +package com.ffii.fpsms.modules.pickOrder.web.models + +/** + * 匯出「車線 Report」(圖2):單一 workbook/單 sheet。 + * laneIds 與前端 encodeLaneId 一致:encodeURIComponent(code)|encodeURIComponent(remark)。 + * 若 laneIds 為空,視為匯出 RouteBoard 全部車線。 + */ +data class ExportRouteReportRequest( + val laneIds: List = emptyList(), +) + diff --git a/src/main/java/com/ffii/fpsms/modules/pickOrder/web/models/ExportTruckLaneVersionReportExcelRequest.kt b/src/main/java/com/ffii/fpsms/modules/pickOrder/web/models/ExportTruckLaneVersionReportExcelRequest.kt new file mode 100644 index 0000000..40949d1 --- /dev/null +++ b/src/main/java/com/ffii/fpsms/modules/pickOrder/web/models/ExportTruckLaneVersionReportExcelRequest.kt @@ -0,0 +1,7 @@ +package com.ffii.fpsms.modules.pickOrder.web.models + +data class ExportTruckLaneVersionReportExcelRequest( + val fromVersionId: Long, + val toVersionId: Long, +) + diff --git a/src/main/java/com/ffii/fpsms/modules/pickOrder/web/models/ParseRouteLanesExcelModels.kt b/src/main/java/com/ffii/fpsms/modules/pickOrder/web/models/ParseRouteLanesExcelModels.kt new file mode 100644 index 0000000..e830810 --- /dev/null +++ b/src/main/java/com/ffii/fpsms/modules/pickOrder/web/models/ParseRouteLanesExcelModels.kt @@ -0,0 +1,22 @@ +package com.ffii.fpsms.modules.pickOrder.web.models + +/** Preview row for staged route Excel import (no DB write). */ +data class RouteLaneImportPreviewRow( + val truckRowId: Long?, + val truckLanceCode: String, + val remark: String?, + val storeId: String, + val departureTime: String, + val shopId: Long, + val shopName: String, + val shopCode: String, + val loadingSequence: Int, + val districtReference: String?, + val logisticId: Long?, +) + +data class ParseRouteLanesExcelResponse( + val sheetCount: Int, + val rowCount: Int, + val rows: List, +) diff --git a/src/main/java/com/ffii/fpsms/modules/pickOrder/web/models/SaveTruckRequest.kt b/src/main/java/com/ffii/fpsms/modules/pickOrder/web/models/SaveTruckRequest.kt index b96e33c..7e59339 100644 --- a/src/main/java/com/ffii/fpsms/modules/pickOrder/web/models/SaveTruckRequest.kt +++ b/src/main/java/com/ffii/fpsms/modules/pickOrder/web/models/SaveTruckRequest.kt @@ -1,4 +1,5 @@ package com.ffii.fpsms.modules.pickOrder.web.models +import jakarta.validation.constraints.NotBlank import java.time.LocalTime data class SaveTruckRequest( val id: Long? = null, @@ -11,6 +12,7 @@ data class SaveTruckRequest( val loadingSequence: Int, val remark: String? = null, val districtReference: String? = null, + val logisticId: Long? = null, ) data class SaveTruckLane( val id: Long, @@ -19,7 +21,10 @@ data class SaveTruckLane( val loadingSequence: Long, val districtReference: String?, val storeId: String, - val remark: String? = null + val remark: String? = null, + val logisticId: Long? = null, + /** When true, apply [logisticId] (including null to clear); when false, leave truck.logistic unchanged. */ + val updateLogistic: Boolean = false, ) data class deleteTruckLane( val id: Long @@ -39,4 +44,13 @@ data class CreateTruckWithoutShopRequest( val loadingSequence: Int = 0, val districtReference: String? = null, val remark: String? = null, + val logisticId: Long? = null, +) + +/** 單一 transaction 更新同 (truckLanceCode, remark) 桶內所有 truck 的 logistic。 */ +data class UpdateLaneLogisticRequest( + @field:NotBlank + val truckLanceCode: String, + val remark: String? = null, + val logisticId: Long? = null, ) diff --git a/src/main/java/com/ffii/fpsms/modules/pickOrder/web/models/TruckLaneCombinationResponse.kt b/src/main/java/com/ffii/fpsms/modules/pickOrder/web/models/TruckLaneCombinationResponse.kt new file mode 100644 index 0000000..35e4e09 --- /dev/null +++ b/src/main/java/com/ffii/fpsms/modules/pickOrder/web/models/TruckLaneCombinationResponse.kt @@ -0,0 +1,34 @@ +package com.ffii.fpsms.modules.pickOrder.web.models + +import com.ffii.fpsms.modules.pickOrder.entity.Truck +import java.time.LocalTime + +/** + * 僅供 `findAllUniqueTruckLanceCodeAndRemarkCombinations` 回傳,避免序列化 JPA + * 關聯(shop / logistic)產生超大或非法 JSON。 + */ +data class TruckLaneCombinationResponse( + val id: Long, + val truckLanceCode: String?, + val departureTime: LocalTime?, + val loadingSequence: Int?, + val districtReference: String?, + val storeId: String?, + val remark: String?, + val shopName: String?, + val shopCode: String?, +) + +fun Truck.toLaneCombinationResponse(): TruckLaneCombinationResponse { + return TruckLaneCombinationResponse( + id = this.id ?: 0L, + truckLanceCode = this.truckLanceCode, + departureTime = this.departureTime, + loadingSequence = this.loadingSequence, + districtReference = this.districtReference, + storeId = this.storeId, + remark = this.remark, + shopName = this.shopName, + shopCode = this.shopCode, + ) +} diff --git a/src/main/java/com/ffii/fpsms/modules/pickOrder/web/models/TruckLaneVersionModels.kt b/src/main/java/com/ffii/fpsms/modules/pickOrder/web/models/TruckLaneVersionModels.kt new file mode 100644 index 0000000..8f59864 --- /dev/null +++ b/src/main/java/com/ffii/fpsms/modules/pickOrder/web/models/TruckLaneVersionModels.kt @@ -0,0 +1,68 @@ +package com.ffii.fpsms.modules.pickOrder.web.models + +import jakarta.validation.constraints.Size + +data class CreateTruckLaneSnapshotRequest( + @field:Size(max = 100) + val truckLanceCode: String? = null, + @field:Size(max = 500) + val note: String? = null, +) + +data class RestoreTruckLaneSnapshotRequest( + val versionId: Long, +) + +data class TruckLaneVersionResponse( + val id: Long, + val truckLanceCode: String, + val note: String?, + val created: String?, + /** BaseEntity `modifiedBy`:最後寫入此快照的使用者(通常為 JWT name / staffNo) */ + val modifiedBy: String?, +) + +data class TruckLaneVersionLineResponse( + val truckRowId: Long, + val truckLanceCode: String?, + val shopCode: String?, + val branchName: String?, + val districtReference: String?, + val loadingSequence: Int?, + val departureTime: String?, + val storeId: String, + val remark: String?, + val logisticId: Long?, +) + +data class DiffFieldChange( + val field: String, + val from: String?, + val to: String?, +) + +data class TruckLaneVersionDiffLine( + val truckRowId: Long, + val shopCode: String?, + val changes: List, + /** 快照中的車線代碼(優先 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, + val logisticMasterChanges: List = emptyList(), +) + diff --git a/src/main/java/com/ffii/fpsms/modules/pickOrder/web/models/UpdateTruckLaneVersionNoteRequest.kt b/src/main/java/com/ffii/fpsms/modules/pickOrder/web/models/UpdateTruckLaneVersionNoteRequest.kt new file mode 100644 index 0000000..219c085 --- /dev/null +++ b/src/main/java/com/ffii/fpsms/modules/pickOrder/web/models/UpdateTruckLaneVersionNoteRequest.kt @@ -0,0 +1,8 @@ +package com.ffii.fpsms.modules.pickOrder.web.models + +import jakarta.validation.constraints.Size + +data class UpdateTruckLaneVersionNoteRequest( + @field:Size(max = 500) + val note: String? = null, +) diff --git a/src/main/java/com/ffii/fpsms/modules/productProcess/service/ProductProcessService.kt b/src/main/java/com/ffii/fpsms/modules/productProcess/service/ProductProcessService.kt index c0724f5..49b5e69 100644 --- a/src/main/java/com/ffii/fpsms/modules/productProcess/service/ProductProcessService.kt +++ b/src/main/java/com/ffii/fpsms/modules/productProcess/service/ProductProcessService.kt @@ -2385,11 +2385,11 @@ open class ProductProcessService( val allLines = productProcessLineRepository.findByProductProcess_Id(productProcessId) .sortedBy { it.seqNo ?: 0L } - println("=== findNewCreatedLineIds DEBUG START ===") - println("BOM bomProcessMap: $bomProcessMap") - println("All lines (sorted by seqNo):") + //println("=== findNewCreatedLineIds DEBUG START ===") + //println("BOM bomProcessMap: $bomProcessMap") + //println("All lines (sorted by seqNo):") allLines.forEach { line -> - println(" id=${line.id}, seqNo=${line.seqNo}, bomProcessId=${line.bomProcess?.id}") + //println(" id=${line.id}, seqNo=${line.seqNo}, bomProcessId=${line.bomProcess?.id}") } // 创建一个集合来跟踪哪些 line 是新创建的 @@ -2402,18 +2402,18 @@ open class ProductProcessService( iteration++ hasChanges = false - println("\n--- Iteration $iteration ---") + //println("\n--- Iteration $iteration ---") // 获取剩余的 line(排除已标记为新创建的),按 seqNo 排序 val remainingLines = allLines.filter { it.id !in newCreatedLineIds } .sortedBy { it.seqNo ?: 0L } - println("Remaining lines (excluding new created):") + //println("Remaining lines (excluding new created):") remainingLines.forEach { line -> - println(" id=${line.id}, seqNo=${line.seqNo}, bomProcessId=${line.bomProcess?.id}") + //println(" id=${line.id}, seqNo=${line.seqNo}, bomProcessId=${line.bomProcess?.id}") } - println("New created line IDs so far: $newCreatedLineIds") + //println("New created line IDs so far: $newCreatedLineIds") // 计算每个剩余 line 的期望 seqNo(应该是连续的 1, 2, 3...) val expectedSeqNoMap = remainingLines.mapIndexed { index, line -> @@ -2430,7 +2430,7 @@ open class ProductProcessService( val bomProcessId = line.bomProcess?.id val expectedSeqNo = expectedSeqNoMap[line.id] ?: continue - println("\nChecking line id=${line.id}, seqNo=${line.seqNo}, bomProcessId=$bomProcessId, expectedSeqNo=$expectedSeqNo") + //println("\nChecking line id=${line.id}, seqNo=${line.seqNo}, bomProcessId=$bomProcessId, expectedSeqNo=$expectedSeqNo") if (bomProcessId == null) { println(" -> No bomProcessId, marking as new created") @@ -2442,7 +2442,7 @@ open class ProductProcessService( // 查找这个 bomProcessId 在 BOM 中的实际 seqNo val bomProcessSeqNo = bomProcessMap[bomProcessId] - println(" -> BOM bomProcessId=$bomProcessId has seqNo=$bomProcessSeqNo in BOM") + //println(" -> BOM bomProcessId=$bomProcessId has seqNo=$bomProcessSeqNo in BOM") if (bomProcessSeqNo == null) { println(" -> bomProcessId not found in BOM, marking as new created") @@ -2461,8 +2461,8 @@ open class ProductProcessService( } } } - println("\n=== Final Result ===") - println("New created line IDs: $newCreatedLineIds") + //println("\n=== Final Result ===") + //println("New created line IDs: $newCreatedLineIds") println("=== findNewCreatedLineIds DEBUG END ===\n") return newCreatedLineIds diff --git a/src/main/java/com/ffii/fpsms/modules/purchaseOrder/entity/PurchaseOrderLineRepository.kt b/src/main/java/com/ffii/fpsms/modules/purchaseOrder/entity/PurchaseOrderLineRepository.kt index abde40e..a5a1896 100644 --- a/src/main/java/com/ffii/fpsms/modules/purchaseOrder/entity/PurchaseOrderLineRepository.kt +++ b/src/main/java/com/ffii/fpsms/modules/purchaseOrder/entity/PurchaseOrderLineRepository.kt @@ -3,6 +3,7 @@ package com.ffii.fpsms.modules.purchaseOrder.entity import com.ffii.core.support.AbstractRepository import com.ffii.fpsms.modules.purchaseOrder.entity.projections.PurchaseOrderLineInfo import com.ffii.fpsms.modules.purchaseOrder.enums.PurchaseOrderLineStatus +import org.springframework.data.domain.Pageable import org.springframework.data.jpa.repository.Query import org.springframework.data.repository.query.Param import org.springframework.stereotype.Repository @@ -10,6 +11,37 @@ import java.io.Serializable @Repository interface PurchaseOrderLineRepository : AbstractRepository { + @Query( + "SELECT pol FROM PurchaseOrderLine pol " + + "LEFT JOIN FETCH pol.purchaseOrder po " + + "LEFT JOIN FETCH po.supplier " + + "JOIN FETCH pol.uom " + + "LEFT JOIN FETCH pol.uomM18 " + + "WHERE pol.deleted = false AND pol.item.id = :itemId AND pol.m18DataLog IS NOT NULL " + + "ORDER BY pol.created DESC", + ) + fun findLatestLinesForBomM18ByItemId( + @Param("itemId") itemId: Long, + pageable: Pageable, + ): List + + /** + * Latest PO (by header `purchase_order.created`) for a material item code: supplier `shop.m18Id` from `purchase_order.supplierId`. + * Mirrors manual SQL: pol → items (code), po, shop on supplier, uom_conversion; order by po.created desc limit 1. + */ + @Query( + value = + "SELECT sh.m18Id FROM purchase_order_line pol " + + "LEFT JOIN items it ON pol.itemId = it.id " + + "LEFT JOIN purchase_order po ON pol.purchaseOrderId = po.id " + + "LEFT JOIN shop sh ON po.supplierId = sh.id " + + "LEFT JOIN uom_conversion um ON pol.uomIdM18 = um.id " + + "WHERE pol.deleted = false AND it.deleted = false AND it.code = :itemCode " + + "ORDER BY po.created DESC LIMIT 1", + nativeQuery = true, + ) + fun findLatestPoSupplierM18IdByItemCodeNative(@Param("itemCode") itemCode: String): List + fun findByM18DataLogIdAndDeletedIsFalse(m18datalogId: Serializable): PurchaseOrderLine? fun findAllPurchaseOrderLineInfoByPurchaseOrderIdAndDeletedIsFalse(purchaseOrderId: Long): List fun findAllByPurchaseOrderIdAndDeletedIsFalse(purchaseOrderId: Long): List diff --git a/src/main/java/com/ffii/fpsms/modules/report/service/FGStockOutTraceabilityReportService.kt b/src/main/java/com/ffii/fpsms/modules/report/service/FGStockOutTraceabilityReportService.kt index 7c0857e..10e625e 100644 --- a/src/main/java/com/ffii/fpsms/modules/report/service/FGStockOutTraceabilityReportService.kt +++ b/src/main/java/com/ffii/fpsms/modules/report/service/FGStockOutTraceabilityReportService.kt @@ -9,13 +9,23 @@ class FGStockOutTraceabilityReportService( ) { fun getDistinctHandlersForFGStockOutTraceability(): List { val sql = """ - SELECT DISTINCT COALESCE(picker_user.name, modified_user.name, '') AS handler - FROM stock_out_line sol - INNER JOIN stock_out so ON sol.stockOutId = so.id AND so.deleted = 0 AND so.type = 'do' - LEFT JOIN user picker_user ON sol.handled_by = picker_user.id AND picker_user.deleted = 0 - LEFT JOIN user modified_user ON sol.modifiedBy = modified_user.staffNo AND modified_user.deleted = 0 AND sol.handled_by IS NULL - WHERE sol.deleted = 0 - ORDER BY handler + SELECT DISTINCT h.handler + FROM ( + SELECT TRIM(COALESCE(picker_user.name, modified_user.name, '')) AS handler + FROM stock_out_line sol + INNER JOIN stock_out so ON sol.stockOutId = so.id AND so.deleted = 0 AND so.type = 'do' + LEFT JOIN user picker_user ON sol.handled_by = picker_user.id AND picker_user.deleted = 0 + LEFT JOIN user modified_user ON sol.modifiedBy = modified_user.staffNo AND modified_user.deleted = 0 AND sol.handled_by IS NULL + WHERE sol.deleted = 0 + UNION + SELECT TRIM(IFNULL(handlerName, '')) AS handler + FROM delivery_order_pick_order + WHERE deleted = 0 + AND ticketStatus = 'completed' + AND IFNULL(handlerName, '') <> '' + ) h + WHERE TRIM(IFNULL(h.handler, '')) <> '' + ORDER BY h.handler """.trimIndent() return jdbcDao @@ -54,7 +64,7 @@ class FGStockOutTraceabilityReportService( val yearSql = if (!year.isNullOrBlank()) { args["year"] = year - "AND YEAR(IFNULL(dpor.RequiredDeliveryDate, do.estimatedArrivalDate)) = :year" + "AND YEAR(IFNULL(dopo.requiredDeliveryDate, do.estimatedArrivalDate)) = :year" } else { "" } @@ -62,7 +72,7 @@ class FGStockOutTraceabilityReportService( val lastOutDateStartSql = if (!lastOutDateStart.isNullOrBlank()) { val formattedDate = lastOutDateStart.replace("/", "-") args["lastOutDateStart"] = formattedDate - "AND DATE(IFNULL(dpor.RequiredDeliveryDate, do.estimatedArrivalDate)) >= DATE(:lastOutDateStart)" + "AND DATE(IFNULL(dopo.requiredDeliveryDate, do.estimatedArrivalDate)) >= DATE(:lastOutDateStart)" } else { "" } @@ -70,14 +80,14 @@ class FGStockOutTraceabilityReportService( val lastOutDateEndSql = if (!lastOutDateEnd.isNullOrBlank()) { val formattedDate = lastOutDateEnd.replace("/", "-") args["lastOutDateEnd"] = formattedDate - "AND DATE(IFNULL(dpor.RequiredDeliveryDate, do.estimatedArrivalDate)) <= DATE(:lastOutDateEnd)" + "AND DATE(IFNULL(dopo.requiredDeliveryDate, do.estimatedArrivalDate)) <= DATE(:lastOutDateEnd)" } else { "" } val handlerSql = buildMultiValueExactClause( handler, - "COALESCE(picker_user.name, modified_user.name, '')", + "COALESCE(picker_user.name, modified_user.name, IFNULL(dopo.handlerName, ''))", "handler", args, ) @@ -85,13 +95,13 @@ class FGStockOutTraceabilityReportService( val sql = """ SELECT IFNULL(DATE_FORMAT( - IFNULL(dpor.RequiredDeliveryDate, do.estimatedArrivalDate), + IFNULL(dopo.requiredDeliveryDate, do.estimatedArrivalDate), '%Y-%m-%d' ), '') AS deliveryDate, IFNULL(it.code, '') AS itemNo, IFNULL(it.name, '') AS itemName, IFNULL(uc.udfudesc, '') AS unitOfMeasure, - IFNULL(dpor.deliveryNoteCode, '') AS dnNo, + IFNULL(dopo.deliveryNoteCode, '') AS dnNo, CAST(IFNULL(sp.id, 0) AS CHAR) AS customerId, IFNULL(sp.name, '') AS customerName, FORMAT( @@ -109,11 +119,13 @@ class FGStockOutTraceabilityReportService( COALESCE( picker_user.name, modified_user.name, + dopo.handlerName, '' ) AS handler, COALESCE( picker_user.name, modified_user.name, + dopo.handlerName, '' ) AS pickedBy, GROUP_CONCAT(DISTINCT wh.code ORDER BY wh.code SEPARATOR ', ') AS storeLocation, @@ -122,19 +134,22 @@ class FGStockOutTraceabilityReportService( ROUND(SUM(IFNULL(sol.qty, 0)) OVER (PARTITION BY it.code), 0), 0 ) AS totalStockOutQty, 0 AS stockSubCategory - FROM do_pick_order_line_record dpolr - LEFT JOIN do_pick_order_record dpor - ON dpolr.record_id = dpor.id - AND dpor.deleted = 0 - AND dpor.ticket_status = 'completed' + FROM delivery_order_pick_order dopo + INNER JOIN pick_order po + ON po.deliveryOrderPickOrderId = dopo.id + AND po.deleted = 0 INNER JOIN delivery_order do - ON dpolr.do_order_id = do.id + ON po.doId = do.id AND do.deleted = 0 LEFT JOIN shop sp ON do.shopId = sp.id AND sp.deleted = 0 + LEFT JOIN pick_order_line pol + ON pol.poId = po.id + AND pol.deleted = 0 LEFT JOIN delivery_order_line dol - ON do.id = dol.deliveryOrderId + ON dol.deliveryOrderId = do.id + AND dol.itemId = pol.itemId AND dol.deleted = 0 LEFT JOIN items it ON dol.itemId = it.id @@ -144,13 +159,6 @@ class FGStockOutTraceabilityReportService( AND iu.stockUnit = 1 LEFT JOIN uom_conversion uc ON iu.uomId = uc.id - LEFT JOIN pick_order_line pol - ON dpolr.pick_order_id = pol.poId - AND pol.itemId = it.id - AND pol.deleted = 0 - LEFT JOIN pick_order po - ON pol.poId = po.id - AND po.deleted = 0 LEFT JOIN stock_out_line sol ON pol.id = sol.pickOrderLineId AND sol.itemId = it.id @@ -176,7 +184,8 @@ class FGStockOutTraceabilityReportService( AND modified_user.deleted = 0 AND sol.handled_by IS NULL WHERE - dpolr.deleted = 0 + dopo.deleted = 0 + AND dopo.ticketStatus = 'completed' $stockCategorySql $stockSubCategorySql $itemCodeSql @@ -186,12 +195,13 @@ class FGStockOutTraceabilityReportService( $handlerSql GROUP BY sol.id, - dpor.RequiredDeliveryDate, + dopo.requiredDeliveryDate, + dopo.handlerName, do.estimatedArrivalDate, it.code, it.name, uc.udfudesc, - dpor.deliveryNoteCode, + dopo.deliveryNoteCode, sp.id, sp.name, sol.qty, diff --git a/src/main/java/com/ffii/fpsms/modules/report/service/ReportService.kt b/src/main/java/com/ffii/fpsms/modules/report/service/ReportService.kt index ffac33c..f6c5bf6 100644 --- a/src/main/java/com/ffii/fpsms/modules/report/service/ReportService.kt +++ b/src/main/java/com/ffii/fpsms/modules/report/service/ReportService.kt @@ -10,6 +10,7 @@ import com.ffii.fpsms.m18.M18GrnRules import com.ffii.fpsms.modules.master.entity.ShopRepository import com.ffii.fpsms.modules.master.enums.ShopType import com.ffii.fpsms.modules.master.service.ItemUomService +import com.ffii.fpsms.modules.deliveryOrder.service.DoFloorSupplierSettingsService import java.math.BigDecimal import net.sf.jasperreports.engine.export.ooxml.JRXlsxExporter import net.sf.jasperreports.export.SimpleExporterInput @@ -20,6 +21,7 @@ open class ReportService( private val jdbcDao: JdbcDao, private val itemUomService: ItemUomService, private val shopRepository: ShopRepository, + private val doFloorSupplierSettingsService: DoFloorSupplierSettingsService, ) { /** * Queries the database for inventory data based on dates and optional item type. @@ -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 { val sql = """ - SELECT DISTINCT COALESCE(picker_user.name, modified_user.name, '') AS handler - FROM stock_out_line sol - INNER JOIN stock_out so ON sol.stockOutId = so.id AND so.deleted = 0 AND so.type = 'do' - LEFT JOIN user picker_user ON sol.handled_by = picker_user.id AND picker_user.deleted = 0 - LEFT JOIN user modified_user ON sol.modifiedBy = modified_user.staffNo AND modified_user.deleted = 0 AND sol.handled_by IS NULL - WHERE sol.deleted = 0 - ORDER BY handler + SELECT DISTINCT h.handler + FROM ( + SELECT TRIM(COALESCE(picker_user.name, modified_user.name, '')) AS handler + FROM stock_out_line sol + INNER JOIN stock_out so ON sol.stockOutId = so.id AND so.deleted = 0 AND so.type = 'do' + LEFT JOIN user picker_user ON sol.handled_by = picker_user.id AND picker_user.deleted = 0 + LEFT JOIN user modified_user ON sol.modifiedBy = modified_user.staffNo AND modified_user.deleted = 0 AND sol.handled_by IS NULL + WHERE sol.deleted = 0 + UNION + SELECT TRIM(IFNULL(handlerName, '')) AS handler + FROM delivery_order_pick_order + WHERE deleted = 0 + AND ticketStatus = 'completed' + AND IFNULL(handlerName, '') <> '' + ) h + WHERE TRIM(IFNULL(h.handler, '')) <> '' + ORDER BY h.handler """.trimIndent() return jdbcDao.queryForList(sql, emptyMap()).map { row -> (row["handler"]?.toString() ?: "").trim() }.filter { it.isNotBlank() } } diff --git a/src/main/java/com/ffii/fpsms/modules/settings/web/SettingsController.java b/src/main/java/com/ffii/fpsms/modules/settings/web/SettingsController.java index 046037f..acf0d79 100644 --- a/src/main/java/com/ffii/fpsms/modules/settings/web/SettingsController.java +++ b/src/main/java/com/ffii/fpsms/modules/settings/web/SettingsController.java @@ -5,6 +5,7 @@ import java.util.List; import org.springframework.http.HttpStatus; import org.springframework.web.bind.annotation.GetMapping; import org.springframework.web.bind.annotation.PatchMapping; +import org.springframework.web.bind.annotation.PostMapping; import org.springframework.web.bind.annotation.PathVariable; import org.springframework.web.bind.annotation.RequestBody; import org.springframework.web.bind.annotation.RequestMapping; @@ -41,13 +42,24 @@ public class SettingsController{ // @PreAuthorize("hasAuthority('ADMIN')") @ResponseStatus(HttpStatus.NO_CONTENT) public void update(@PathVariable String name, @RequestBody @Valid UpdateReq body) { + applyUpdate(name, body); + } + + /** Same as PATCH; use from browsers where CORS preflight for PATCH is blocked. */ + @PostMapping("/{name}") + @ResponseStatus(HttpStatus.NO_CONTENT) + public void updatePost(@PathVariable String name, @RequestBody @Valid UpdateReq body) { + applyUpdate(name, body); + } + + private void applyUpdate(String name, UpdateReq body) { Settings entity = this.settingsService.findByName(name) .orElseThrow(NotFoundException::new); - if (!this.settingsService.validateType(entity.getType(), body.value)) { + if (!this.settingsService.validateType(entity.getType(), body.getValue())) { throw new BadRequestException(); } - entity.setValue(body.value); + entity.setValue(body.getValue()); this.settingsService.save(entity); } diff --git a/src/main/java/com/ffii/fpsms/modules/stock/entity/InventoryRepository.kt b/src/main/java/com/ffii/fpsms/modules/stock/entity/InventoryRepository.kt index e75cb39..a439b31 100644 --- a/src/main/java/com/ffii/fpsms/modules/stock/entity/InventoryRepository.kt +++ b/src/main/java/com/ffii/fpsms/modules/stock/entity/InventoryRepository.kt @@ -14,11 +14,22 @@ import java.util.Optional interface InventoryRepository: AbstractRepository { fun findInventoryInfoByDeletedIsFalse(): List - @Query("SELECT i FROM Inventory i " + - "WHERE (:code IS NULL OR i.item.code LIKE CONCAT('%', :code, '%')) " + - "AND (:name IS NULL OR i.item.name LIKE CONCAT('%', :name, '%')) " + - "AND (:type IS NULL OR :type = '' OR i.item.type = :type) " + - "AND i.deleted = false") + @Query( + """ + SELECT i FROM Inventory i + WHERE (:code IS NULL OR i.item.code LIKE CONCAT('%', :code, '%')) + AND (:name IS NULL OR i.item.name LIKE CONCAT('%', :name, '%')) + AND (:type IS NULL OR :type = '' OR i.item.type = :type) + AND i.deleted = false + AND EXISTS ( + SELECT 1 FROM ItemUom iu + WHERE iu.item.id = i.item.id + AND iu.deleted = false + AND iu.baseUnit = true + AND iu.uom.id = i.uom.id + ) + """ +) fun findInventoryInfoByItemCodeContainsAndItemNameContainsAndItemTypeAndDeletedIsFalse(code: String, name: String, type: String, pageable: Pageable): Page @Query("SELECT i FROM Inventory i " + diff --git a/src/main/java/com/ffii/fpsms/modules/stock/entity/StockLedgerRepository.kt b/src/main/java/com/ffii/fpsms/modules/stock/entity/StockLedgerRepository.kt index 9ac5eac..1a9858a 100644 --- a/src/main/java/com/ffii/fpsms/modules/stock/entity/StockLedgerRepository.kt +++ b/src/main/java/com/ffii/fpsms/modules/stock/entity/StockLedgerRepository.kt @@ -1,11 +1,12 @@ package com.ffii.fpsms.modules.stock.entity import com.ffii.core.support.AbstractRepository +import org.springframework.data.domain.Page +import org.springframework.data.domain.Pageable import org.springframework.data.jpa.repository.Query import org.springframework.data.repository.query.Param import org.springframework.stereotype.Repository -import java.time.LocalDate -import java.util.Optional +import java.time.LocalDateTime @Repository interface StockLedgerRepository: AbstractRepository { @@ -19,17 +20,17 @@ interface StockLedgerRepository: AbstractRepository { AND (:itemCode IS NULL OR sl.itemCode LIKE CONCAT('%', :itemCode, '%')) AND (:itemName IS NULL OR i.name LIKE CONCAT('%', :itemName, '%')) AND (:type IS NULL OR sl.type = :type) - AND (:startDate IS NULL OR DATE(sl.created) >= :startDate) - AND (:endDate IS NULL OR DATE(sl.created) <= :endDate) - ORDER BY sl.created ASC, sl.itemId + AND (:startDateTime IS NULL OR sl.created >= :startDateTime) + AND (:endDateExclusive IS NULL OR sl.created < :endDateExclusive) """) fun findStockTransactions( @Param("itemCode") itemCode: String?, @Param("itemName") itemName: String?, @Param("type") type: String?, - @Param("startDate") startDate: LocalDate?, - @Param("endDate") endDate: LocalDate? - ): List + @Param("startDateTime") startDateTime: LocalDateTime?, + @Param("endDateExclusive") endDateExclusive: LocalDateTime?, + pageable: Pageable + ): Page @Query(""" SELECT COUNT(sl) FROM StockLedger sl @@ -39,15 +40,15 @@ interface StockLedgerRepository: AbstractRepository { AND (:itemCode IS NULL OR sl.itemCode LIKE CONCAT('%', :itemCode, '%')) AND (:itemName IS NULL OR i.name LIKE CONCAT('%', :itemName, '%')) AND (:type IS NULL OR sl.type = :type) - AND (:startDate IS NULL OR DATE(sl.created) >= :startDate) - AND (:endDate IS NULL OR DATE(sl.created) <= :endDate) + AND (:startDateTime IS NULL OR sl.created >= :startDateTime) + AND (:endDateExclusive IS NULL OR sl.created < :endDateExclusive) """) fun countStockTransactions( @Param("itemCode") itemCode: String?, @Param("itemName") itemName: String?, @Param("type") type: String?, - @Param("startDate") startDate: LocalDate?, - @Param("endDate") endDate: LocalDate? + @Param("startDateTime") startDateTime: LocalDateTime?, + @Param("endDateExclusive") endDateExclusive: LocalDateTime? ): Long diff --git a/src/main/java/com/ffii/fpsms/modules/stock/service/StockTakeRecordService.kt b/src/main/java/com/ffii/fpsms/modules/stock/service/StockTakeRecordService.kt index b15337b..812c325 100644 --- a/src/main/java/com/ffii/fpsms/modules/stock/service/StockTakeRecordService.kt +++ b/src/main/java/com/ffii/fpsms/modules/stock/service/StockTakeRecordService.kt @@ -16,6 +16,7 @@ import java.time.LocalDateTime import java.math.BigDecimal import com.ffii.fpsms.modules.user.entity.UserRepository import org.springframework.data.domain.PageRequest +import org.springframework.data.domain.Sort import com.ffii.core.response.RecordsRes import com.ffii.fpsms.modules.stock.service.InventoryLotLineService import com.ffii.fpsms.modules.stock.entity.StockTakeLine @@ -2741,40 +2742,32 @@ open fun searchStockTransactions(request: SearchStockTransactionRequest): Record return RecordsRes(emptyList(), 0) } - val startDate = request.startDate - val endDate = request.endDate + val startDateTime = request.startDate?.atStartOfDay() + val endDateExclusive = request.endDate?.plusDays(1)?.atStartOfDay() - println("Processed params: itemCode=$itemCode, itemName=$itemName, startDate=$startDate, endDate=$endDate") - - val total = stockLedgerRepository.countStockTransactions( - itemCode = itemCode, - itemName = itemName, - type = request.type, - startDate = startDate, - endDate = endDate + println( + "Processed params: itemCode=$itemCode, itemName=$itemName, " + + "startDateTime=$startDateTime, endDateExclusive=$endDateExclusive" ) - println("Total count: $total") - - val actualPageSize = if (request.pageSize == 100) { - total.toInt().coerceAtLeast(1) - } else { - request.pageSize - } - - val offset = request.pageNum * actualPageSize + val pageable = PageRequest.of( + request.pageNum.coerceAtLeast(0), + request.pageSize.coerceAtLeast(1), + Sort.by(Sort.Order.asc("created"), Sort.Order.asc("itemId")) + ) - val ledgers = stockLedgerRepository.findStockTransactions( + val ledgerPage = stockLedgerRepository.findStockTransactions( itemCode = itemCode, itemName = itemName, type = request.type, - startDate = startDate, - endDate = endDate + startDateTime = startDateTime, + endDateExclusive = endDateExclusive, + pageable = pageable ) - println("Found ${ledgers.size} ledgers") + println("Found ${ledgerPage.numberOfElements} ledgers in current page, total=${ledgerPage.totalElements}") - val transactions = ledgers.map { ledger -> + val transactions = ledgerPage.content.map { ledger -> val stockInLine = ledger.stockInLine val stockOutLine = ledger.stockOutLine @@ -2805,17 +2798,9 @@ open fun searchStockTransactions(request: SearchStockTransactionRequest): Record ) } - val sortedTransactions = transactions.sortedWith( - compareBy( - { it.date ?: it.transactionDate?.toLocalDate() }, - { it.transactionDate } - ) - ) - - val paginatedTransactions = sortedTransactions.drop(offset).take(actualPageSize) val totalTime = System.currentTimeMillis() - startTime - println("Total time (Repository query): ${totalTime}ms, count: ${paginatedTransactions.size}, total: $total") + println("Total time (Repository query): ${totalTime}ms, count: ${transactions.size}, total: ${ledgerPage.totalElements}") - return RecordsRes(paginatedTransactions, total.toInt()) + return RecordsRes(transactions, ledgerPage.totalElements.toInt()) } } diff --git a/src/main/java/com/ffii/fpsms/modules/stock/service/SuggestedPickLotService.kt b/src/main/java/com/ffii/fpsms/modules/stock/service/SuggestedPickLotService.kt index 6060b86..48692d9 100644 --- a/src/main/java/com/ffii/fpsms/modules/stock/service/SuggestedPickLotService.kt +++ b/src/main/java/com/ffii/fpsms/modules/stock/service/SuggestedPickLotService.kt @@ -42,6 +42,7 @@ import com.ffii.fpsms.modules.stock.entity.projection.StockOutLineInfo import com.ffii.fpsms.modules.stock.entity.StockOutLIneRepository import com.ffii.fpsms.modules.stock.web.model.StockOutStatus import com.ffii.fpsms.modules.common.SecurityUtils +import com.ffii.fpsms.modules.deliveryOrder.service.DoFloorSupplierSettingsService @Service open class SuggestedPickLotService( val suggestedPickLotRepository: SuggestPickLotRepository, @@ -57,7 +58,8 @@ open class SuggestedPickLotService( val failInventoryLotLineRepository: FailInventoryLotLineRepository, val stockOutRepository: StockOutRepository, val itemRepository: ItemsRepository, - val stockOutLineRepository: StockOutLIneRepository + val stockOutLineRepository: StockOutLIneRepository, + private val doFloorSupplierSettingsService: DoFloorSupplierSettingsService, ) { // Calculation Available Qty / Remaining Qty @@ -114,6 +116,8 @@ open class SuggestedPickLotService( .filter { it.expiryDate.isAfter(today) || it.expiryDate.isEqual(today)} .sortedBy { it.expiryDate } .groupBy { it.item?.id } + + val (floorSuppliers2F, floorSuppliers4F) = doFloorSupplierSettingsService.loadDoFloorSupplierLists() // loop for suggest pick lot line pols.forEach { line -> @@ -126,11 +130,11 @@ open class SuggestedPickLotService( val doPreferredFloor: String? = if (isDoPickOrder) { val supplierCode = pickOrder?.deliveryOrder?.supplier?.code - when (supplierCode) { - "P06B" -> "4F" - "P07", "P06D" -> "2F" - else -> null // 其他供应商不限定 2F/4F - } + doFloorSupplierSettingsService.preferredFloorForPickLotOrNull( + supplierCode, + floorSuppliers2F, + floorSuppliers4F, + ) } else { null } diff --git a/src/main/java/com/ffii/fpsms/modules/stock/web/StockTakeRecordController.kt b/src/main/java/com/ffii/fpsms/modules/stock/web/StockTakeRecordController.kt index 806d3e4..6931ea9 100644 --- a/src/main/java/com/ffii/fpsms/modules/stock/web/StockTakeRecordController.kt +++ b/src/main/java/com/ffii/fpsms/modules/stock/web/StockTakeRecordController.kt @@ -32,7 +32,8 @@ class StockTakeRecordController( @RequestParam(required = false) stockTakeSections: String?, @RequestParam(required = false) status: String?, @RequestParam(required = false) area: String?, - @RequestParam(required = false) storeId: String? + @RequestParam(required = false) storeId: String?, + @RequestParam(required = false, defaultValue = "false") onlyLatestRound: Boolean ): RecordsRes { var all = stockOutRecordService.AllPickedStockTakeList() if (sectionDescription != null && sectionDescription != "All") { @@ -71,6 +72,18 @@ class StockTakeRecordController( it.storeId?.contains(storeIdKeyword, ignoreCase = true) == true } } + if (onlyLatestRound) { + val latestRoundKey = all + .mapNotNull { item -> + item.stockTakeRoundId ?: item.stockTakeId.takeIf { it > 0 } + } + .maxOrNull() + all = if (latestRoundKey == null) { + emptyList() + } else { + all.filter { (it.stockTakeRoundId ?: it.stockTakeId) == latestRoundKey } + } + } val total = all.size val fromIndex = pageNum * pageSize val toIndex = minOf(fromIndex + pageSize, total) diff --git a/src/main/java/com/ffii/fpsms/modules/stock/web/model/CreateStockTakeForSectionsRequest.kt b/src/main/java/com/ffii/fpsms/modules/stock/web/model/CreateStockTakeForSectionsRequest.kt new file mode 100644 index 0000000..3ba0c02 --- /dev/null +++ b/src/main/java/com/ffii/fpsms/modules/stock/web/model/CreateStockTakeForSectionsRequest.kt @@ -0,0 +1,8 @@ +package com.ffii.fpsms.modules.stock.web.model + +import com.fasterxml.jackson.annotation.JsonIgnoreProperties + +@JsonIgnoreProperties(ignoreUnknown = true) +data class CreateStockTakeForSectionsRequest( + val sections: List? = null, +) diff --git a/src/main/java/com/ffii/fpsms/modules/user/service/GroupService.java b/src/main/java/com/ffii/fpsms/modules/user/service/GroupService.java index 2d41d9e..79a4f5c 100644 --- a/src/main/java/com/ffii/fpsms/modules/user/service/GroupService.java +++ b/src/main/java/com/ffii/fpsms/modules/user/service/GroupService.java @@ -1,6 +1,7 @@ package com.ffii.fpsms.modules.user.service; import java.util.Date; +import java.util.LinkedHashMap; import java.util.List; import java.util.Map; import java.util.stream.Collectors; @@ -209,4 +210,13 @@ public class GroupService extends AbstractBaseEntityService>> listAuthForUsers(List userIds) { + Map>> result = new LinkedHashMap<>(); + for (Integer userId : userIds) { + result.put(userId, listAuth(Map.of("userId", userId))); + } + return result; + } + } diff --git a/src/main/java/com/ffii/fpsms/modules/user/service/UserService.java b/src/main/java/com/ffii/fpsms/modules/user/service/UserService.java index fa9e7df..9c57e79 100644 --- a/src/main/java/com/ffii/fpsms/modules/user/service/UserService.java +++ b/src/main/java/com/ffii/fpsms/modules/user/service/UserService.java @@ -185,8 +185,8 @@ public class UserService extends AbstractBaseEntityService> authComboJson(HttpServletRequest request, @PathVariable("id") int id, @PathVariable("target") String target) throws ServletRequestBindingException { - System.out.println(request); Map args = new HashMap<>(); if (id != 0){ if (target.equals("group")){ @@ -94,4 +95,11 @@ public class GroupController{ return new RecordsRes<>(groupService.listAuth(args)); } + @GetMapping("/auth/user-batch") + public Map>> authBatchByUserIds( + @RequestParam("userIds") List userIds + ) { + return groupService.listAuthForUsers(userIds); + } + } diff --git a/src/main/java/com/ffii/fpsms/modules/user/web/UserController.java b/src/main/java/com/ffii/fpsms/modules/user/web/UserController.java index af4abda..c464cda 100644 --- a/src/main/java/com/ffii/fpsms/modules/user/web/UserController.java +++ b/src/main/java/com/ffii/fpsms/modules/user/web/UserController.java @@ -78,7 +78,6 @@ public class UserController{ @GetMapping // @PreAuthorize("hasAuthority('VIEW_USER')") public ResponseEntity> list(@ModelAttribute @Valid SearchUserReq req) { - logger.info("Test List user"); return ResponseEntity.ok(userService.search(req)); } @@ -120,13 +119,10 @@ public class UserController{ @GetMapping("/{id}") @PreAuthorize("hasAuthority('VIEW_USER')") public LoadUserRes load(@PathVariable long id) { - LoadUserRes test = new LoadUserRes( + return new LoadUserRes( userService.find(id).orElseThrow(NotFoundException::new), userService.listUserAuthId(id), userService.listUserGroupId(id)); - logger.info("Test List user2"); - logger.info(test); - return test; } @GetMapping("/user-info/{id}") // @PreAuthorize("hasAuthority('VIEW_USER')") @@ -147,7 +143,6 @@ public class UserController{ // @ResponseStatus(HttpStatus.CREATED) // @PreAuthorize("hasAuthority('MAINTAIN_USER')") public IdRes newRecord(@RequestBody @Valid NewUserReq req) throws UnsupportedEncodingException { - System.out.println(req.getUsername()); return new IdRes(userService.newRecord(req).getId()); } diff --git a/src/main/resources/application.yml b/src/main/resources/application.yml index 9d4cb97..e8cd673 100644 --- a/src/main/resources/application.yml +++ b/src/main/resources/application.yml @@ -27,6 +27,10 @@ scheduler: syncOffsetDays: 0 inventoryLotExpiry: enabled: true + # Job order: at 00:00:15 daily, process JOs whose planStart was yesterday (hide or reschedule). + jo: + planStart: + enabled: true # Nav: PO stock_in_line pending/receiving within last N days (see ProductProcessService for 工單 QC/上架:今日+昨日). fpsms: diff --git a/src/main/resources/db/changelog/changes/20260430_02_2fi/01_truck_lane_version_snapshot.sql b/src/main/resources/db/changelog/changes/20260430_02_2fi/01_truck_lane_version_snapshot.sql new file mode 100644 index 0000000..4571ba9 --- /dev/null +++ b/src/main/resources/db/changelog/changes/20260430_02_2fi/01_truck_lane_version_snapshot.sql @@ -0,0 +1,130 @@ +-- liquibase formatted sql +-- changeset 2fi:20260430_03_truck_lane_version_snapshot +-- preconditions onFail:MARK_RAN +-- precondition-sql-check expectedResult:0 SELECT COUNT(*) FROM information_schema.tables WHERE table_schema = DATABASE() AND table_name = 'truck_lane_version' + +CREATE TABLE IF NOT EXISTS `truck_lane_version` +( + `id` BIGINT NOT NULL AUTO_INCREMENT, + `created` DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP, + `createdBy` VARCHAR(30) NULL DEFAULT NULL, + `version` INT NOT NULL DEFAULT '0', + `modified` DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP, + `modifiedBy` VARCHAR(30) NULL DEFAULT NULL, + `deleted` TINYINT(1) NOT NULL DEFAULT '0', + `storeId` VARCHAR(10) NOT NULL, + `truckLanceCode` VARCHAR(100) NOT NULL, + `note` VARCHAR(500) NULL DEFAULT NULL, + CONSTRAINT pk_truck_lane_version PRIMARY KEY (`id`) +); + +-- When upgrading an existing database, CREATE TABLE IF NOT EXISTS will not add missing columns. +-- Old DB snapshots might already have `truck_lane_version` without `storeId`, which would break the index creation below. +SET @col_tlv_storeId := ( + SELECT COUNT(*) + FROM information_schema.columns + WHERE table_schema = DATABASE() + AND table_name = 'truck_lane_version' + AND column_name = 'storeId' +); +SET @sql_add_tlv_storeId := IF( + @col_tlv_storeId = 0, + 'ALTER TABLE `truck_lane_version` ADD COLUMN `storeId` VARCHAR(10) NULL DEFAULT NULL AFTER `deleted`', + 'SELECT 1' +); +PREPARE stmt_add_tlv_storeId FROM @sql_add_tlv_storeId; +EXECUTE stmt_add_tlv_storeId; +DEALLOCATE PREPARE stmt_add_tlv_storeId; + +SET @idx_tlv := ( + SELECT COUNT(*) + FROM information_schema.statistics + WHERE table_schema = DATABASE() + AND table_name = 'truck_lane_version' + AND index_name = 'idx_tlv_lane' +); +SET @col_tlv_truckLanceCode := ( + SELECT COUNT(*) + FROM information_schema.columns + WHERE table_schema = DATABASE() + AND table_name = 'truck_lane_version' + AND column_name = 'truckLanceCode' +); +SET @sql_tlv := IF( + @idx_tlv = 0 AND @col_tlv_storeId > 0 AND @col_tlv_truckLanceCode > 0, + 'CREATE INDEX idx_tlv_lane ON `truck_lane_version` (`storeId`, `truckLanceCode`, `created`)', + 'SELECT 1' +); +PREPARE stmt_tlv FROM @sql_tlv; +EXECUTE stmt_tlv; +DEALLOCATE PREPARE stmt_tlv; + +CREATE TABLE IF NOT EXISTS `truck_lane_version_line` +( + `id` BIGINT NOT NULL AUTO_INCREMENT, + `created` DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP, + `createdBy` VARCHAR(30) NULL DEFAULT NULL, + `version` INT NOT NULL DEFAULT '0', + `modified` DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP, + `modifiedBy` VARCHAR(30) NULL DEFAULT NULL, + `deleted` TINYINT(1) NOT NULL DEFAULT '0', + `truckLaneVersionId` BIGINT NOT NULL, + `truckRowId` BIGINT NOT NULL, + `shopCode` VARCHAR(50) NULL DEFAULT NULL, + `branchName` VARCHAR(255) NULL DEFAULT NULL, + `districtReference` VARCHAR(255) NULL DEFAULT NULL, + `loadingSequence` INT NULL DEFAULT NULL, + `departureTime` VARCHAR(30) NULL DEFAULT NULL, + `storeId` VARCHAR(10) NOT NULL, + `remark` VARCHAR(255) NULL DEFAULT NULL, + CONSTRAINT pk_truck_lane_version_line PRIMARY KEY (`id`), + CONSTRAINT fk_tlvl_version FOREIGN KEY (`truckLaneVersionId`) REFERENCES `truck_lane_version` (`id`) +); + +SET @col_tlvl_storeId := ( + SELECT COUNT(*) + FROM information_schema.columns + WHERE table_schema = DATABASE() + AND table_name = 'truck_lane_version_line' + AND column_name = 'storeId' +); +SET @sql_add_tlvl_storeId := IF( + @col_tlvl_storeId = 0, + 'ALTER TABLE `truck_lane_version_line` ADD COLUMN `storeId` VARCHAR(10) NULL DEFAULT NULL AFTER `departureTime`', + 'SELECT 1' +); +PREPARE stmt_add_tlvl_storeId FROM @sql_add_tlvl_storeId; +EXECUTE stmt_add_tlvl_storeId; +DEALLOCATE PREPARE stmt_add_tlvl_storeId; + +SET @idx_tlvl_v := ( + SELECT COUNT(*) + FROM information_schema.statistics + WHERE table_schema = DATABASE() + AND table_name = 'truck_lane_version_line' + AND index_name = 'idx_tlvl_version' +); +SET @sql_tlvl_v := IF( + @idx_tlvl_v = 0, + 'CREATE INDEX idx_tlvl_version ON `truck_lane_version_line` (`truckLaneVersionId`)', + 'SELECT 1' +); +PREPARE stmt_tlvl_v FROM @sql_tlvl_v; +EXECUTE stmt_tlvl_v; +DEALLOCATE PREPARE stmt_tlvl_v; + +SET @idx_tlvl_tr := ( + SELECT COUNT(*) + FROM information_schema.statistics + WHERE table_schema = DATABASE() + AND table_name = 'truck_lane_version_line' + AND index_name = 'idx_tlvl_truck_row' +); +SET @sql_tlvl_tr := IF( + @idx_tlvl_tr = 0, + 'CREATE INDEX idx_tlvl_truck_row ON `truck_lane_version_line` (`truckRowId`)', + 'SELECT 1' +); +PREPARE stmt_tlvl_tr FROM @sql_tlvl_tr; +EXECUTE stmt_tlvl_tr; +DEALLOCATE PREPARE stmt_tlvl_tr; diff --git a/src/main/resources/db/changelog/changes/20260430_02_2fi/02_truck_lane_version_snapshot_patch.sql b/src/main/resources/db/changelog/changes/20260430_02_2fi/02_truck_lane_version_snapshot_patch.sql new file mode 100644 index 0000000..b79fb6c --- /dev/null +++ b/src/main/resources/db/changelog/changes/20260430_02_2fi/02_truck_lane_version_snapshot_patch.sql @@ -0,0 +1,46 @@ +-- liquibase formatted sql +-- changeset 2fi:20260430_04_truck_lane_version_snapshot_patch +-- preconditions onFail:MARK_RAN +-- precondition-sql-check expectedResult:0 SELECT COUNT(*) FROM information_schema.columns WHERE table_schema = DATABASE() AND table_name = 'truck_lane_version_line' AND column_name = 'truckLanceCode' + +ALTER TABLE `truck_lane_version` + MODIFY COLUMN `truckLanceCode` VARCHAR(100) NULL; + +SET @col_tlvl_storeId := ( + SELECT COUNT(*) + FROM information_schema.columns + WHERE table_schema = DATABASE() + AND table_name = 'truck_lane_version_line' + AND column_name = 'storeId' +); +SET @col_tlvl_tlc := ( + SELECT COUNT(*) + FROM information_schema.columns + WHERE table_schema = DATABASE() + AND table_name = 'truck_lane_version_line' + AND column_name = 'truckLanceCode' +); +SET @sql_add_tlc := IF( + @col_tlvl_tlc = 0, + 'ALTER TABLE `truck_lane_version_line` ADD COLUMN `truckLanceCode` VARCHAR(100) NULL DEFAULT NULL AFTER `truckRowId`', + 'SELECT 1' +); +PREPARE stmt_add_tlc FROM @sql_add_tlc; +EXECUTE stmt_add_tlc; +DEALLOCATE PREPARE stmt_add_tlc; + +SET @idx_tlvl_lane := ( + SELECT COUNT(*) + FROM information_schema.statistics + WHERE table_schema = DATABASE() + AND table_name = 'truck_lane_version_line' + AND index_name = 'idx_tlvl_lane' +); +SET @sql_tlvl_lane := IF( + @idx_tlvl_lane = 0 AND @col_tlvl_storeId > 0 AND @col_tlvl_tlc > 0, + 'CREATE INDEX idx_tlvl_lane ON `truck_lane_version_line` (`storeId`, `truckLanceCode`)', + 'SELECT 1' +); +PREPARE stmt_tlvl_lane FROM @sql_tlvl_lane; +EXECUTE stmt_tlvl_lane; +DEALLOCATE PREPARE stmt_tlvl_lane; diff --git a/src/main/resources/db/changelog/changes/20260504_01_2fi/01_truck_add_logistic_id.sql b/src/main/resources/db/changelog/changes/20260504_01_2fi/01_truck_add_logistic_id.sql new file mode 100644 index 0000000..5b5e68f --- /dev/null +++ b/src/main/resources/db/changelog/changes/20260504_01_2fi/01_truck_add_logistic_id.sql @@ -0,0 +1,10 @@ +-- liquibase formatted sql +-- changeset 2fi:20260504_01_truck_add_logistic_id +-- preconditions onFail:MARK_RAN +-- precondition-sql-check expectedResult:0 SELECT COUNT(*) FROM information_schema.columns WHERE table_schema = DATABASE() AND table_name = 'truck' AND column_name = 'logisticId' + +ALTER TABLE `truck` + ADD COLUMN `logisticId` INT NULL; + +ALTER TABLE `truck` + ADD CONSTRAINT `fk_truck_logistic` FOREIGN KEY (`logisticId`) REFERENCES `logistic` (`id`); diff --git a/src/main/resources/db/changelog/changes/20260505_01_2fi/01_truck_lane_version_drop_store_id.sql b/src/main/resources/db/changelog/changes/20260505_01_2fi/01_truck_lane_version_drop_store_id.sql new file mode 100644 index 0000000..aa20c43 --- /dev/null +++ b/src/main/resources/db/changelog/changes/20260505_01_2fi/01_truck_lane_version_drop_store_id.sql @@ -0,0 +1,52 @@ +-- liquibase formatted sql +-- changeset 2fi:20260505_01_truck_lane_version_drop_store_id +-- preconditions onFail:MARK_RAN +-- precondition-sql-check expectedResult:1 SELECT COUNT(*) FROM information_schema.columns WHERE table_schema = DATABASE() AND table_name = 'truck_lane_version' AND column_name = 'storeId' + +SET @idx_tlv := ( + SELECT COUNT(*) + FROM information_schema.statistics + WHERE table_schema = DATABASE() + AND table_name = 'truck_lane_version' + AND index_name = 'idx_tlv_lane' +); +SET @sql_drop_idx := IF( + @idx_tlv > 0, + 'DROP INDEX idx_tlv_lane ON `truck_lane_version`', + 'SELECT 1' +); +PREPARE stmt_drop_idx FROM @sql_drop_idx; +EXECUTE stmt_drop_idx; +DEALLOCATE PREPARE stmt_drop_idx; + +SET @col_tlv_sid := ( + SELECT COUNT(*) + FROM information_schema.columns + WHERE table_schema = DATABASE() + AND table_name = 'truck_lane_version' + AND column_name = 'storeId' +); +SET @sql_drop_col := IF( + @col_tlv_sid > 0, + 'ALTER TABLE `truck_lane_version` DROP COLUMN `storeId`', + 'SELECT 1' +); +PREPARE stmt_drop_col FROM @sql_drop_col; +EXECUTE stmt_drop_col; +DEALLOCATE PREPARE stmt_drop_col; + +SET @idx_new := ( + SELECT COUNT(*) + FROM information_schema.statistics + WHERE table_schema = DATABASE() + AND table_name = 'truck_lane_version' + AND index_name = 'idx_tlv_lane_created' +); +SET @sql_new_idx := IF( + @idx_new = 0, + 'CREATE INDEX idx_tlv_lane_created ON `truck_lane_version` (`truckLanceCode`, `created`)', + 'SELECT 1' +); +PREPARE stmt_new_idx FROM @sql_new_idx; +EXECUTE stmt_new_idx; +DEALLOCATE PREPARE stmt_new_idx; diff --git a/src/main/resources/db/changelog/changes/20260507_01_2fi/01_truck_lane_version_line_add_logistic_id.sql b/src/main/resources/db/changelog/changes/20260507_01_2fi/01_truck_lane_version_line_add_logistic_id.sql new file mode 100644 index 0000000..072423e --- /dev/null +++ b/src/main/resources/db/changelog/changes/20260507_01_2fi/01_truck_lane_version_line_add_logistic_id.sql @@ -0,0 +1,37 @@ +-- liquibase formatted sql +-- changeset 2fi:20260507_01_truck_lane_version_line_add_logisticId +-- preconditions onFail:MARK_RAN +-- precondition-sql-check expectedResult:0 SELECT COUNT(*) FROM information_schema.columns WHERE table_schema = DATABASE() AND table_name = 'truck_lane_version_line' AND column_name = 'logisticId' + +SET @col_tlvl_lid := ( + SELECT COUNT(*) + FROM information_schema.columns + WHERE table_schema = DATABASE() + AND table_name = 'truck_lane_version_line' + AND column_name = 'logisticId' +); +SET @sql_add_tlvl_lid := IF( + @col_tlvl_lid = 0, + 'ALTER TABLE `truck_lane_version_line` ADD COLUMN `logisticId` BIGINT NULL DEFAULT NULL AFTER `remark`', + 'SELECT 1' +); +PREPARE stmt_add_tlvl_lid FROM @sql_add_tlvl_lid; +EXECUTE stmt_add_tlvl_lid; +DEALLOCATE PREPARE stmt_add_tlvl_lid; + +SET @idx_tlvl_lid := ( + SELECT COUNT(*) + FROM information_schema.statistics + WHERE table_schema = DATABASE() + AND table_name = 'truck_lane_version_line' + AND index_name = 'idx_tlvl_logisticId' +); +SET @sql_tlvl_lid := IF( + @idx_tlvl_lid = 0, + 'CREATE INDEX idx_tlvl_logisticId ON `truck_lane_version_line` (`logisticId`)', + 'SELECT 1' +); +PREPARE stmt_tlvl_lid FROM @sql_tlvl_lid; +EXECUTE stmt_tlvl_lid; +DEALLOCATE PREPARE stmt_tlvl_lid; + diff --git a/src/main/resources/db/changelog/changes/20260512_fai/01_m18_bom_shop_sync_settings.sql b/src/main/resources/db/changelog/changes/20260512_fai/01_m18_bom_shop_sync_settings.sql new file mode 100644 index 0000000..cf1102c --- /dev/null +++ b/src/main/resources/db/changelog/changes/20260512_fai/01_m18_bom_shop_sync_settings.sql @@ -0,0 +1,20 @@ +--liquibase formatted sql +--changeset fai:20260512_m18_bom_shop_sync_settings + +INSERT INTO `settings` (`name`, `value`, `category`, `type`) +SELECT v.name, v.value, v.category, v.type +FROM ( + SELECT 'M18.bom.shop.sync.enabled' AS name, 'false' AS value, 'M18' AS category, 'boolean' AS type +) v +WHERE NOT EXISTS ( + SELECT 1 FROM `settings` s WHERE s.name = 'M18.bom.shop.sync.enabled' +); + +INSERT INTO `settings` (`name`, `value`, `category`, `type`) +SELECT v.name, v.value, v.category, v.type +FROM ( + SELECT 'M18.bom.shop.sync.allowedBomIds' AS name, '78,274' AS value, 'M18' AS category, 'string' AS type +) v +WHERE NOT EXISTS ( + SELECT 1 FROM `settings` s WHERE s.name = 'M18.bom.shop.sync.allowedBomIds' +); diff --git a/src/main/resources/db/changelog/changes/20260512_fai/02_m18_bom_shop_sync_log.sql b/src/main/resources/db/changelog/changes/20260512_fai/02_m18_bom_shop_sync_log.sql new file mode 100644 index 0000000..e0e1805 --- /dev/null +++ b/src/main/resources/db/changelog/changes/20260512_fai/02_m18_bom_shop_sync_log.sql @@ -0,0 +1,24 @@ +--liquibase formatted sql +--changeset fai:20260512_m18_bom_shop_sync_log + +CREATE TABLE IF NOT EXISTS `m18_bom_shop_sync_log` ( + `id` BIGINT NOT NULL AUTO_INCREMENT, + `created` DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP, + `createdBy` VARCHAR(30) NULL DEFAULT NULL, + `version` INT NOT NULL DEFAULT '0', + `modified` DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP, + `modifiedBy` VARCHAR(30) NULL DEFAULT NULL, + `deleted` TINYINT(1) NOT NULL DEFAULT '0', + + `bom_id` BIGINT NOT NULL COMMENT 'FPSMS bom.id', + `m18_record_id` BIGINT NULL DEFAULT NULL COMMENT 'M18 udfBomForShop record id when returned', + `m18_api_status` TINYINT(1) NOT NULL COMMENT 'M18 response status field', + `synced` TINYINT(1) NOT NULL COMMENT 'FPSMS treat as success (e.g. updated bom.m18Id)', + `message` VARCHAR(4000) NULL DEFAULT NULL COMMENT 'Summary / errors', + `request_json` LONGTEXT NULL COMMENT 'PUT body sent to M18', + `response_json` LONGTEXT NULL COMMENT 'Parsed M18 response or error JSON', + + CONSTRAINT pk_m18_bom_shop_sync_log PRIMARY KEY (`id`), + KEY `idx_m18_bom_shop_sync_log_bom_id` (`bom_id`), + KEY `idx_m18_bom_shop_sync_log_created` (`created`) +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci; diff --git a/src/main/resources/db/changelog/changes/20260513_do2_1pm/01_update_do2_schedule_to_13.sql b/src/main/resources/db/changelog/changes/20260513_do2_1pm/01_update_do2_schedule_to_13.sql new file mode 100644 index 0000000..0c849bb --- /dev/null +++ b/src/main/resources/db/changelog/changes/20260513_do2_1pm/01_update_do2_schedule_to_13.sql @@ -0,0 +1,4 @@ +--liquibase formatted sql +--changeset fpsms:20260513_do2_schedule_1pm + +UPDATE `settings` SET `value` = '0 0 13 * * *' WHERE `name` = 'SCHEDULE.m18.do2'; diff --git a/src/main/resources/db/changelog/changes/20260514_Enson/01_setting.sql b/src/main/resources/db/changelog/changes/20260514_Enson/01_setting.sql new file mode 100644 index 0000000..465fea8 --- /dev/null +++ b/src/main/resources/db/changelog/changes/20260514_Enson/01_setting.sql @@ -0,0 +1,18 @@ +--liquibase formatted sql + +-- DO 樓層供應商代碼(逗號分隔),name 須與前端 constants 一致。預設值對齊既有硬編碼邏輯,後端改讀 settings 後才會生效。 +--changeset Enson:20260514-01 +INSERT INTO `fpsmsdb`.`settings` (`name`, `value`, `category`, `type`) +SELECT 'DO.floor.suppliers.2F', 'P07,P06D,P06Y', 'DO_FLOOR', 'string' +FROM DUAL +WHERE NOT EXISTS ( + SELECT 1 FROM `fpsmsdb`.`settings` WHERE `name` = 'DO.floor.suppliers.2F' +); + +--changeset Enson:20260514-02 +INSERT INTO `fpsmsdb`.`settings` (`name`, `value`, `category`, `type`) +SELECT 'DO.floor.suppliers.4F', 'P06B', 'DO_FLOOR', 'string' +FROM DUAL +WHERE NOT EXISTS ( + SELECT 1 FROM `fpsmsdb`.`settings` WHERE `name` = 'DO.floor.suppliers.4F' +); diff --git a/src/main/resources/db/changelog/changes/20260514_Enson/02_setting.sql b/src/main/resources/db/changelog/changes/20260514_Enson/02_setting.sql new file mode 100644 index 0000000..e39fece --- /dev/null +++ b/src/main/resources/db/changelog/changes/20260514_Enson/02_setting.sql @@ -0,0 +1,6 @@ +--liquibase formatted sql + +-- 修改 delivery_order 表的 isExtra 欄位為 isExtra +--changeset Enson:20260514-03 +ALTER TABLE `delivery_order` CHANGE COLUMN `isEtra` `isExtra` TINYINT(1) NOT NULL DEFAULT 0; + diff --git a/src/main/resources/db/changelog/changes/20260515_fpsms/01_m18_bom_shop_sync_log_columns.sql b/src/main/resources/db/changelog/changes/20260515_fpsms/01_m18_bom_shop_sync_log_columns.sql new file mode 100644 index 0000000..0d1d86f --- /dev/null +++ b/src/main/resources/db/changelog/changes/20260515_fpsms/01_m18_bom_shop_sync_log_columns.sql @@ -0,0 +1,7 @@ +--liquibase formatted sql +--changeset fpsms:20260515_m18_bom_shop_sync_log_columns + +ALTER TABLE `m18_bom_shop_sync_log` + ADD COLUMN `finished_item_code` VARCHAR(100) NULL COMMENT 'BOM finished-good item code' AFTER `bom_id`, + ADD COLUMN `m18_header_code` VARCHAR(200) NULL COMMENT 'M18 header code BOM+item+Vnnn' AFTER `finished_item_code`, + ADD COLUMN `request_fingerprint` VARCHAR(64) NULL COMMENT 'SHA-256 of normalized payload for change detection' AFTER `m18_header_code`; diff --git a/src/main/resources/log4j2-prod-linux.yml b/src/main/resources/log4j2-prod-linux.yml index c90a291..f19e1f1 100644 --- a/src/main/resources/log4j2-prod-linux.yml +++ b/src/main/resources/log4j2-prod-linux.yml @@ -11,6 +11,7 @@ Configutation: filePattern: ${log_location}fpsms-all.log.%i.gz PatternLayout: Pattern: "%d %p [%l] - %m%n" + charset: UTF-8 Policies: SizeBasedTriggeringPolicy: size: 4096KB diff --git a/src/main/resources/log4j2-prod-win.yml b/src/main/resources/log4j2-prod-win.yml index d4f8a75..3152168 100644 --- a/src/main/resources/log4j2-prod-win.yml +++ b/src/main/resources/log4j2-prod-win.yml @@ -11,6 +11,7 @@ Configutation: filePattern: ${log_location}fpsms-all.log.%i.gz PatternLayout: Pattern: "%d %p [%l] - %m%n" + charset: UTF-8 Policies: SizeBasedTriggeringPolicy: size: 4096KB diff --git a/src/main/resources/log4j2.yml b/src/main/resources/log4j2.yml index e3b20ff..8977b61 100644 --- a/src/main/resources/log4j2.yml +++ b/src/main/resources/log4j2.yml @@ -10,6 +10,7 @@ Configutation: target: SYSTEM_OUT PatternLayout: pattern: ${log_pattern} + charset: UTF-8 Loggers: Root: level: info