| @@ -16,6 +16,7 @@ Bag2 is kept as a separate legacy v2.x line; do not assume Bag2 matches Bag3. | |||||
| Run: python Bag3.py | Run: python Bag3.py | ||||
| """ | """ | ||||
| import errno | |||||
| import json | import json | ||||
| import os | import os | ||||
| import select | import select | ||||
| @@ -344,6 +345,25 @@ DATAFLEX_UI_PROGRESS_EVERY = max( | |||||
| DATAFLEX_SINGLE_TCP_JOB = _dataflex_bool_env( | DATAFLEX_SINGLE_TCP_JOB = _dataflex_bool_env( | ||||
| "FPSMS_DATAFLEX_SINGLE_TCP_JOB", False | "FPSMS_DATAFLEX_SINGLE_TCP_JOB", False | ||||
| ) | ) | ||||
| # Link-OS SGD: raw-ZPL job shows this host id instead of a generic name (e.g. "ZPL. EMULATION"). | |||||
| # Same id when the same job order is printed again. Disable: FPSMS_DATAFLEX_HOST_IDENTIFICATION_SGD=0 | |||||
| DATAFLEX_HOST_IDENTIFICATION_SGD = _dataflex_bool_env( | |||||
| "FPSMS_DATAFLEX_HOST_IDENTIFICATION_SGD", True | |||||
| ) | |||||
| # Bag ZPL size (dots). ^PW700 matched little content (mostly vertical ^A@R), so previews showed a wide strip with empty right margin. | |||||
| DATAFLEX_LABEL_PW = max( | |||||
| 280, | |||||
| _dataflex_int_env("FPSMS_DATAFLEX_LABEL_PW", 400), | |||||
| ) | |||||
| DATAFLEX_LABEL_LL = max( | |||||
| 200, | |||||
| _dataflex_int_env("FPSMS_DATAFLEX_LABEL_LL", 500), | |||||
| ) | |||||
| # Some Zebra/DataFlex units RST the socket on host half-close; Windows surfaces WinError 10054. | |||||
| # Set FPSMS_DATAFLEX_SKIP_SHUTDOWN_WR=1 to omit shutdown(SHUT_WR) and only close() (often avoids RST). | |||||
| DATAFLEX_SKIP_SHUTDOWN_WR = _dataflex_bool_env( | |||||
| "FPSMS_DATAFLEX_SKIP_SHUTDOWN_WR", False | |||||
| ) | |||||
| # Full recovery (~JR soft reset) — used by「打袋重設」only; longer delay for firmware | # Full recovery (~JR soft reset) — used by「打袋重設」only; longer delay for firmware | ||||
| DATAFLEX_POST_FULL_RECOVERY_DELAY_SEC = 1.2 | DATAFLEX_POST_FULL_RECOVERY_DELAY_SEC = 1.2 | ||||
| # Zebra ~RO only (used when FPSMS_DATAFLEX_NO_JR is set for full recovery) | # Zebra ~RO only (used when FPSMS_DATAFLEX_NO_JR is set for full recovery) | ||||
| @@ -364,12 +384,56 @@ def _zpl_escape(s: str) -> str: | |||||
| return s.replace("\\", "\\\\").replace("^", "\\^") | return s.replace("\\", "\\\\").replace("^", "\\^") | ||||
| def _dataflex_host_identification_sgd_prefix(job_order_id: Optional[int]) -> str: | |||||
| """ | |||||
| Optional ASCII prefix before ^XA: set zpl.host_identification so the printer lists the job | |||||
| under the job order id instead of a generic raw-ZPL label. | |||||
| """ | |||||
| if not DATAFLEX_HOST_IDENTIFICATION_SGD or job_order_id is None: | |||||
| return "" | |||||
| try: | |||||
| jid = str(int(job_order_id)) | |||||
| except (TypeError, ValueError): | |||||
| return "" | |||||
| if not jid.isdigit(): | |||||
| return "" | |||||
| return f'! U1 setvar "zpl.host_identification" "{jid}"\r\n' | |||||
| def _dataflex_zpl_bytes(zpl: str) -> bytes: | def _dataflex_zpl_bytes(zpl: str) -> bytes: | ||||
| """UTF-8 ZPL with one trailing CRLF so the printer sees a clear job boundary.""" | """UTF-8 ZPL with one trailing CRLF so the printer sees a clear job boundary.""" | ||||
| s = (zpl or "").rstrip("\r\n") | s = (zpl or "").rstrip("\r\n") | ||||
| return (s + "\r\n").encode("utf-8") | return (s + "\r\n").encode("utf-8") | ||||
| def _dataflex_is_benign_tcp_reset(err: BaseException) -> bool: | |||||
| """True when peer closed with RST/FIN in a way that is normal for raw printer TCP (Windows 10054).""" | |||||
| if isinstance(err, (BrokenPipeError, ConnectionResetError, ConnectionAbortedError)): | |||||
| return True | |||||
| if isinstance(err, OSError): | |||||
| if getattr(err, "winerror", None) == 10054: # WSAECONNRESET | |||||
| return True | |||||
| if err.errno in ( | |||||
| errno.ECONNRESET, | |||||
| errno.EPIPE, | |||||
| errno.ECONNABORTED, | |||||
| ): | |||||
| return True | |||||
| return False | |||||
| def _dataflex_shutdown_write_maybe(sock: socket.socket) -> None: | |||||
| """Half-close write side; ignore printer RST (common after ZPL on port 9100-style links).""" | |||||
| if DATAFLEX_SKIP_SHUTDOWN_WR: | |||||
| return | |||||
| try: | |||||
| sock.shutdown(socket.SHUT_WR) | |||||
| except OSError as e: | |||||
| if _dataflex_is_benign_tcp_reset(e): | |||||
| return | |||||
| raise | |||||
| def generate_zpl_dataflex( | def generate_zpl_dataflex( | ||||
| batch_no: str, | batch_no: str, | ||||
| item_code: str, | item_code: str, | ||||
| @@ -377,6 +441,7 @@ def generate_zpl_dataflex( | |||||
| item_id: Optional[int] = None, | item_id: Optional[int] = None, | ||||
| stock_in_line_id: Optional[int] = None, | stock_in_line_id: Optional[int] = None, | ||||
| lot_no: Optional[str] = None, | lot_no: Optional[str] = None, | ||||
| job_order_id: Optional[int] = None, | |||||
| font_regular: str = "E:STXihei.ttf", | font_regular: str = "E:STXihei.ttf", | ||||
| font_bold: str = "E:STXihei.ttf", | font_bold: str = "E:STXihei.ttf", | ||||
| ) -> str: | ) -> str: | ||||
| @@ -398,11 +463,12 @@ def generate_zpl_dataflex( | |||||
| qr_value = _zpl_escape(qr_payload) | qr_value = _zpl_escape(qr_payload) | ||||
| # Explicit ^PQ1: each ^XA…^XZ is exactly one bag. Avoids E1005 "over quantity" on some Zebra/DataFlex | # Explicit ^PQ1: each ^XA…^XZ is exactly one bag. Avoids E1005 "over quantity" on some Zebra/DataFlex | ||||
| # firmware when many labels are sent on one TCP session without a per-job quantity. | # firmware when many labels are sent on one TCP session without a per-job quantity. | ||||
| return f"""^XA | |||||
| host_id = _dataflex_host_identification_sgd_prefix(job_order_id) | |||||
| return host_id + f"""^XA | |||||
| ^PQ1,0,1,N | ^PQ1,0,1,N | ||||
| ^CI28 | ^CI28 | ||||
| ^PW700 | |||||
| ^LL500 | |||||
| ^PW{DATAFLEX_LABEL_PW} | |||||
| ^LL{DATAFLEX_LABEL_LL} | |||||
| ^PO N | ^PO N | ||||
| ^FO10,20 | ^FO10,20 | ||||
| ^BQN,2,4^FDQA,{qr_value}^FS | ^BQN,2,4^FDQA,{qr_value}^FS | ||||
| @@ -447,10 +513,7 @@ def send_dataflex_preprint_reset(ip: str, port: int, *, force: bool = False) -> | |||||
| sock.connect((ip, port)) | sock.connect((ip, port)) | ||||
| sock.sendall(DATAFLEX_PREPRINT_BYTES) | sock.sendall(DATAFLEX_PREPRINT_BYTES) | ||||
| time.sleep(DATAFLEX_POST_PREPRINT_DELAY_SEC) | time.sleep(DATAFLEX_POST_PREPRINT_DELAY_SEC) | ||||
| try: | |||||
| sock.shutdown(socket.SHUT_WR) | |||||
| except OSError: | |||||
| pass | |||||
| _dataflex_shutdown_write_maybe(sock) | |||||
| finally: | finally: | ||||
| sock.close() | sock.close() | ||||
| @@ -472,10 +535,7 @@ def send_dataflex_job_counter_reset(ip: str, port: int, *, force: bool = False) | |||||
| sock.connect((ip, port)) | sock.connect((ip, port)) | ||||
| sock.sendall(_dataflex_full_recovery_payload()) | sock.sendall(_dataflex_full_recovery_payload()) | ||||
| time.sleep(DATAFLEX_POST_FULL_RECOVERY_DELAY_SEC) | time.sleep(DATAFLEX_POST_FULL_RECOVERY_DELAY_SEC) | ||||
| try: | |||||
| sock.shutdown(socket.SHUT_WR) | |||||
| except OSError: | |||||
| pass | |||||
| _dataflex_shutdown_write_maybe(sock) | |||||
| finally: | finally: | ||||
| sock.close() | sock.close() | ||||
| @@ -527,10 +587,7 @@ def send_dataflex_reset_and_labels( | |||||
| time.sleep(DATAFLEX_POST_LABEL_SETTLE_SEC) | time.sleep(DATAFLEX_POST_LABEL_SETTLE_SEC) | ||||
| if i < copies - 1: | if i < copies - 1: | ||||
| time.sleep(delay_sec) | time.sleep(delay_sec) | ||||
| try: | |||||
| sock.shutdown(socket.SHUT_WR) | |||||
| except OSError: | |||||
| pass | |||||
| _dataflex_shutdown_write_maybe(sock) | |||||
| finally: | finally: | ||||
| sock.close() | sock.close() | ||||
| @@ -879,10 +936,7 @@ def send_zpl_to_dataflex(ip: str, port: int, zpl: str) -> None: | |||||
| sock.connect((ip, port)) | sock.connect((ip, port)) | ||||
| sock.sendall(_dataflex_zpl_bytes(zpl)) | sock.sendall(_dataflex_zpl_bytes(zpl)) | ||||
| time.sleep(DATAFLEX_POST_LABEL_SETTLE_SEC) | time.sleep(DATAFLEX_POST_LABEL_SETTLE_SEC) | ||||
| try: | |||||
| sock.shutdown(socket.SHUT_WR) | |||||
| except OSError: | |||||
| pass | |||||
| _dataflex_shutdown_write_maybe(sock) | |||||
| finally: | finally: | ||||
| sock.close() | sock.close() | ||||
| @@ -907,6 +961,10 @@ def query_dataflex_host_status(ip: str, port: int) -> str: | |||||
| data = sock.recv(4096) | data = sock.recv(4096) | ||||
| except socket.timeout: | except socket.timeout: | ||||
| break | break | ||||
| except OSError as ex: | |||||
| if _dataflex_is_benign_tcp_reset(ex): | |||||
| break | |||||
| raise | |||||
| if not data: | if not data: | ||||
| break | break | ||||
| chunks.append(data) | chunks.append(data) | ||||
| @@ -2451,6 +2509,7 @@ def main() -> None: | |||||
| item_id=item_id, | item_id=item_id, | ||||
| stock_in_line_id=stock_in_line_id, | stock_in_line_id=stock_in_line_id, | ||||
| lot_no=lot_no, | lot_no=lot_no, | ||||
| job_order_id=j.get("id"), | |||||
| ) | ) | ||||
| label_text = (lot_no or b).strip() | label_text = (lot_no or b).strip() | ||||
| if continuous: | if continuous: | ||||
| @@ -0,0 +1,39 @@ | |||||
| package com.ffii.fpsms.m18.entity | |||||
| import com.ffii.core.entity.BaseEntity | |||||
| import jakarta.persistence.Column | |||||
| import jakarta.persistence.Entity | |||||
| import jakarta.persistence.Table | |||||
| import jakarta.validation.constraints.NotNull | |||||
| /** | |||||
| * Audit log for FPSMS → M18 udfBomForShop sync (request / response bodies). | |||||
| */ | |||||
| @Entity | |||||
| @Table(name = "m18_bom_shop_sync_log") | |||||
| open class M18BomShopSyncLog : BaseEntity<Long>() { | |||||
| @NotNull | |||||
| @Column(name = "bom_id", nullable = false) | |||||
| open var bomId: Long? = null | |||||
| @Column(name = "m18_record_id") | |||||
| open var m18RecordId: Long? = null | |||||
| @NotNull | |||||
| @Column(name = "m18_api_status", nullable = false) | |||||
| open var m18ApiStatus: Boolean = false | |||||
| @NotNull | |||||
| @Column(name = "synced", nullable = false) | |||||
| open var synced: Boolean = false | |||||
| @Column(name = "message", length = 4000) | |||||
| open var message: String? = null | |||||
| @Column(name = "request_json", columnDefinition = "LONGTEXT") | |||||
| open var requestJson: String? = null | |||||
| @Column(name = "response_json", columnDefinition = "LONGTEXT") | |||||
| open var responseJson: String? = null | |||||
| } | |||||
| @@ -0,0 +1,5 @@ | |||||
| package com.ffii.fpsms.m18.entity | |||||
| import com.ffii.core.support.AbstractRepository | |||||
| interface M18BomShopSyncLogRepository : AbstractRepository<M18BomShopSyncLog, Long> | |||||
| @@ -0,0 +1,86 @@ | |||||
| package com.ffii.fpsms.m18.model | |||||
| import com.fasterxml.jackson.annotation.JsonInclude | |||||
| import com.fasterxml.jackson.annotation.JsonProperty | |||||
| /** | |||||
| * M18 save payload for Shop BOM (udfBomForShop). | |||||
| * PUT /root/api/save/udfbomforshop?menuCode=udfbomforshop | |||||
| * | |||||
| * Same idea as GRN (`mainan` + `ant`): header and lines each wrapped as `{ "values": [ ... ] }`. | |||||
| * Root keys: **`udfbomforshop`** and **`udfproduct`** (same as M18 read [M18BomData]). | |||||
| * (Spelling is **udf**, not "uni".) | |||||
| */ | |||||
| @JsonInclude(JsonInclude.Include.NON_NULL) | |||||
| data class M18BomForShopSaveRequest( | |||||
| @JsonProperty("udfbomforshop") | |||||
| val udfbomforshop: M18MainUdfBomForShopWrapper, | |||||
| @JsonProperty("udfproduct") | |||||
| val udfproduct: M18UdfProductWrapper, | |||||
| ) | |||||
| @JsonInclude(JsonInclude.Include.NON_NULL) | |||||
| data class M18MainUdfBomForShopWrapper( | |||||
| val values: List<M18MainUdfBomForShopValue>, | |||||
| ) | |||||
| /** | |||||
| * Header row for udfBomForShop. Field names match M18 read/sample JSON. | |||||
| */ | |||||
| @JsonInclude(JsonInclude.Include.NON_NULL) | |||||
| data class M18MainUdfBomForShopValue( | |||||
| /** | |||||
| * Existing M18 udfBomForShop header id for **update** (same as FPSMS [Bom.m18Id] after first sync). | |||||
| * Omit or null for **create**. Sent as JSON string for M18 compatibility (like GRN mainan `id`). | |||||
| */ | |||||
| val id: String? = null, | |||||
| val code: String? = null, | |||||
| val beId: Int? = null, | |||||
| val desc: String? = null, | |||||
| @JsonProperty("desc_en") | |||||
| val descEn: String? = null, | |||||
| @JsonProperty("udfBOMCode") | |||||
| val udfBomCode: String? = null, | |||||
| val rev: String? = null, | |||||
| val udfUnit: Long? = null, | |||||
| /** Harvest qty: [Bom.outputQty] × pack multiple from header item stock UOM code (e.g. PACK2LB → ×2), else plain output qty. */ | |||||
| val udfHarvest: String? = null, | |||||
| /** Trailing unit letters from that code (e.g. LB); null if code not parsed. */ | |||||
| val udfHarvestUnit: String? = null, | |||||
| /** Epoch milliseconds (M18-style; same as read `lastModifyDate`). From FPSMS [com.ffii.core.entity.BaseEntity.created] in Asia/Hong_Kong. */ | |||||
| @JsonProperty("udfeffectivedate") | |||||
| val udfEffectiveDate: Long? = null, | |||||
| @JsonProperty("udfYieldratePP") | |||||
| val udfYieldratePP: Number? = null, | |||||
| val udftypeoffood: String? = null, | |||||
| val staffId: Int? = null, | |||||
| val flowTypeId: Int? = null, | |||||
| val virDeptId: Int? = null, | |||||
| val status: String? = null, | |||||
| ) | |||||
| @JsonInclude(JsonInclude.Include.NON_NULL) | |||||
| data class M18UdfProductWrapper( | |||||
| val values: List<M18UdfProductSaveValue>, | |||||
| ) | |||||
| /** | |||||
| * Line payload for `udfproduct.values[]`. **`udfBaseUnit`** is the FPSMS UOM **code** for the line. | |||||
| * **`udfpurchaseUnit`** / **`udfPackingUnit`** / **`udfPackingQty`** / **`udfproremark`** are not sent. | |||||
| */ | |||||
| @JsonInclude(JsonInclude.Include.NON_NULL) | |||||
| data class M18UdfProductSaveValue( | |||||
| /** Line id in M18 when updating */ | |||||
| val id: Long? = null, | |||||
| val udfqty: Number? = null, | |||||
| val udfProduct: Long? = null, | |||||
| val udfIngredients: String? = null, | |||||
| /** Line UOM: [com.ffii.fpsms.modules.master.entity.UomConversion.code] (same unit as [udfqty]). */ | |||||
| val udfBaseUnit: String? = null, | |||||
| val udfSupplier: Long? = null, | |||||
| /** Line sequence, e.g. " 1" */ | |||||
| val itemNo: String? = null, | |||||
| val udfoptions: String? = null, | |||||
| val udfoption: Number? = null, | |||||
| val udfYieldRate: Number? = null, | |||||
| ) | |||||
| @@ -0,0 +1,14 @@ | |||||
| package com.ffii.fpsms.m18.model | |||||
| /** | |||||
| * Result of [com.ffii.fpsms.modules.master.service.BomService.pushBomToM18ShopIfAllowed] | |||||
| * (e.g. POST /m18/test/bom-shop-sync/{bomId}). | |||||
| */ | |||||
| data class M18BomShopSyncTriggerResult( | |||||
| val bomId: Long, | |||||
| val synced: Boolean, | |||||
| val skippedReason: String? = null, | |||||
| val recordId: Long? = null, | |||||
| val status: Boolean? = null, | |||||
| val messageSummary: String? = null, | |||||
| ) | |||||
| @@ -0,0 +1,223 @@ | |||||
| package com.ffii.fpsms.m18.service | |||||
| import com.fasterxml.jackson.core.JsonGenerator | |||||
| import com.fasterxml.jackson.databind.ObjectMapper | |||||
| import com.fasterxml.jackson.module.kotlin.jacksonObjectMapper | |||||
| import com.ffii.fpsms.api.service.ApiCallerService | |||||
| import com.ffii.fpsms.m18.M18Config | |||||
| import com.ffii.fpsms.m18.model.GoodsReceiptNoteResponse | |||||
| import com.ffii.fpsms.m18.model.M18BomForShopSaveRequest | |||||
| import com.ffii.fpsms.m18.model.M18MainUdfBomForShopValue | |||||
| import com.ffii.fpsms.m18.model.M18MainUdfBomForShopWrapper | |||||
| import com.ffii.fpsms.m18.model.M18UdfProductSaveValue | |||||
| import com.ffii.fpsms.m18.model.M18UdfProductWrapper | |||||
| import com.ffii.fpsms.modules.master.entity.Bom | |||||
| import com.ffii.fpsms.modules.master.entity.BomMaterial | |||||
| import com.ffii.fpsms.modules.master.service.ItemUomService | |||||
| import org.slf4j.Logger | |||||
| import org.slf4j.LoggerFactory | |||||
| import org.springframework.stereotype.Service | |||||
| import org.springframework.util.LinkedMultiValueMap | |||||
| import reactor.core.publisher.Mono | |||||
| import java.math.BigDecimal | |||||
| import java.math.RoundingMode | |||||
| import java.nio.charset.StandardCharsets | |||||
| import java.time.ZoneId | |||||
| /** | |||||
| * Push FPSMS BOM + materials to M18 udfBomForShop (similar to GRN save/an). | |||||
| * PUT /root/api/save/udfbomforshop?menuCode=udfbomforshop | |||||
| */ | |||||
| @Service | |||||
| open class M18BomForShopService( | |||||
| private val m18Config: M18Config, | |||||
| private val apiCallerService: ApiCallerService, | |||||
| private val itemUomService: ItemUomService, | |||||
| ) { | |||||
| private val logger: Logger = LoggerFactory.getLogger(M18BomForShopService::class.java) | |||||
| private val savePath = "/root/api/save/udfbomforshop" | |||||
| private val menuCode = "udfbomforshop" | |||||
| /** M18 business entity id for udfBomForShop header (`udfbomforshop.values[0].beId`). */ | |||||
| private val bomShopMainBeId: Int = 29 | |||||
| /** | |||||
| * Stock UOM `code` on the **BOM header item** (e.g. PACK2LB = prefix + pack multiple + unit suffix). | |||||
| * [udfHarvest] = [Bom.outputQty] × middle number; [udfHarvestUnit] = trailing unit (e.g. LB). | |||||
| */ | |||||
| private val bomItemStockUomPackCodeRegex = Regex("^([A-Za-z]+)(\\d+)([A-Za-z]+)$") | |||||
| companion object { | |||||
| private const val HARVEST_CALC_SCALE = 10 | |||||
| private val m18Tz: ZoneId = ZoneId.of("Asia/Hong_Kong") | |||||
| } | |||||
| @Suppress("DEPRECATION") | |||||
| private val objectMapper: ObjectMapper = jacksonObjectMapper().apply { | |||||
| disable(JsonGenerator.Feature.ESCAPE_NON_ASCII) | |||||
| } | |||||
| /** | |||||
| * Builds M18 save body from a persisted BOM (materials loaded). | |||||
| * [headerM18IdOverride] optional M18 header record id (e.g. from `/bom/by-item-code` `bomM18Id`) when DB column is stale. | |||||
| * Otherwise uses [Bom.m18Id] when set for **update**; omitted for **create**. | |||||
| * Returns null if required M18 ids are missing (caller should log and skip). | |||||
| */ | |||||
| open fun buildSaveRequest(bom: Bom, headerM18IdOverride: Long? = null): M18BomForShopSaveRequest? { | |||||
| val code = bom.code ?: return null | |||||
| val flowTypeId = resolveFlowTypeId(code) | |||||
| val udfUnit = bom.uom?.m18Id?.takeIf { it > 0 } ?: return null | |||||
| val outputQty = bom.outputQty ?: BigDecimal.ZERO | |||||
| val (udfHarvest, udfHarvestUnit) = resolveUdfHarvestFields(bom, outputQty) | |||||
| val udfEffectiveDate = bom.created?.atZone(m18Tz)?.toInstant()?.toEpochMilli() | |||||
| val effectiveHeaderM18Id = | |||||
| headerM18IdOverride?.takeIf { it > 0 } ?: bom.m18Id?.takeIf { it > 0 } | |||||
| val header = M18MainUdfBomForShopValue( | |||||
| id = effectiveHeaderM18Id?.toString(), | |||||
| code = code, | |||||
| beId = bomShopMainBeId, | |||||
| desc = bom.name ?: bom.description, | |||||
| descEn = bom.name ?: bom.description, | |||||
| udfBomCode = deriveUdfBomCode(code), | |||||
| rev = deriveRev(code), | |||||
| udfUnit = udfUnit, | |||||
| udfHarvest = udfHarvest, | |||||
| udfHarvestUnit = udfHarvestUnit, | |||||
| udfEffectiveDate = udfEffectiveDate, | |||||
| udfYieldratePP = bom.yield, | |||||
| udftypeoffood = "半成品", | |||||
| staffId = 232, | |||||
| flowTypeId = flowTypeId, | |||||
| virDeptId = 117, | |||||
| status = "Y", | |||||
| ) | |||||
| val lines = bom.bomMaterials | |||||
| .filter { it.deleted != true } | |||||
| .sortedBy { it.id ?: 0L } | |||||
| .mapIndexedNotNull { idx, mat -> toProductLine(mat, idx + 1) } | |||||
| if (lines.isEmpty()) { | |||||
| logger.warn("[M18 BOM] BOM id=${bom.id} code=$code has no materials; skipping M18 save") | |||||
| return null | |||||
| } | |||||
| logger.info( | |||||
| "[M18 BOM] buildSaveRequest fpsmsBomId=${bom.id} code=$code mainM18Id=$effectiveHeaderM18Id " + | |||||
| "(m18HeaderIdOverride=$headerM18IdOverride, bom.m18Id=${bom.m18Id})", | |||||
| ) | |||||
| return M18BomForShopSaveRequest( | |||||
| udfbomforshop = M18MainUdfBomForShopWrapper(values = listOf(header)), | |||||
| udfproduct = M18UdfProductWrapper(values = lines), | |||||
| ) | |||||
| } | |||||
| /** | |||||
| * From the **finished-good** [Bom.item] stock unit [com.ffii.fpsms.modules.master.entity.UomConversion.code] | |||||
| * (pattern `LETTER_PREFIX` + `DIGITS` + `UNIT_SUFFIX`, e.g. PACK2LB): harvest qty = outputQty × digits, unit = suffix. | |||||
| * Falls back to plain [outputQty] and null unit when item/stock UOM/code is missing or does not match. | |||||
| */ | |||||
| private fun resolveUdfHarvestFields(bom: Bom, outputQty: BigDecimal): Pair<String, String?> { | |||||
| val itemId = bom.item?.id | |||||
| if (itemId == null) { | |||||
| logger.warn("[M18 BOM] bom.item id missing; udfHarvest=outputQty only. bomId=${bom.id}") | |||||
| return outputQty.stripTrailingZeros().toPlainString() to null | |||||
| } | |||||
| val stockCode = itemUomService.findStockUnitByItemId(itemId)?.uom?.code?.trim().orEmpty() | |||||
| if (stockCode.isEmpty()) { | |||||
| logger.warn("[M18 BOM] stock UOM code missing for bom itemId=$itemId; udfHarvest=outputQty only. bomId=${bom.id}") | |||||
| return outputQty.stripTrailingZeros().toPlainString() to null | |||||
| } | |||||
| val match = bomItemStockUomPackCodeRegex.matchEntire(stockCode) | |||||
| if (match == null) { | |||||
| logger.warn( | |||||
| "[M18 BOM] stock UOM code '$stockCode' does not match PREFIX+NUMBER+SUFFIX; " + | |||||
| "udfHarvest=outputQty only. bomId=${bom.id} itemId=$itemId", | |||||
| ) | |||||
| return outputQty.stripTrailingZeros().toPlainString() to null | |||||
| } | |||||
| val mult = match.groupValues[2].toBigDecimalOrNull() | |||||
| if (mult == null || mult.compareTo(BigDecimal.ZERO) <= 0) { | |||||
| logger.warn( | |||||
| "[M18 BOM] invalid pack multiple in stock UOM code '$stockCode'; udfHarvest=outputQty only. bomId=${bom.id}", | |||||
| ) | |||||
| return outputQty.stripTrailingZeros().toPlainString() to null | |||||
| } | |||||
| val unitSuffix = match.groupValues[3] | |||||
| val harvestQty = outputQty.multiply(mult).setScale(HARVEST_CALC_SCALE, RoundingMode.HALF_UP).stripTrailingZeros() | |||||
| return harvestQty.toPlainString() to unitSuffix | |||||
| } | |||||
| private fun toProductLine(mat: BomMaterial, lineNo: Int): M18UdfProductSaveValue? { | |||||
| val proId = mat.item?.m18Id?.takeIf { it > 0 } ?: run { | |||||
| logger.warn("[M18 BOM] material item m18Id missing bomMaterialId=${mat.id} itemId=${mat.item?.id}") | |||||
| return null | |||||
| } | |||||
| val udfBaseUnit = mat.uom?.code?.trim()?.takeIf { it.isNotEmpty() } ?: run { | |||||
| logger.warn("[M18 BOM] material UOM code missing bomMaterialId=${mat.id}") | |||||
| return null | |||||
| } | |||||
| val udfqty = (mat.qty ?: BigDecimal.ZERO).setScale(8, RoundingMode.HALF_UP).toDouble() | |||||
| return M18UdfProductSaveValue( | |||||
| id = mat.m18Id?.takeIf { it > 0 }, | |||||
| udfqty = udfqty, | |||||
| udfProduct = proId, | |||||
| udfIngredients = mat.itemName ?: mat.item?.name, | |||||
| udfBaseUnit = udfBaseUnit, | |||||
| udfSupplier = 0L, | |||||
| itemNo = String.format("%6d", lineNo), | |||||
| udfoptions = "", | |||||
| udfoption = 0.0, | |||||
| udfYieldRate = 0.0, | |||||
| ) | |||||
| } | |||||
| private fun deriveUdfBomCode(fullCode: String): String { | |||||
| val v = Regex("^(.*)V(\\d+)$").find(fullCode) | |||||
| return if (v != null) v.groupValues[1] else fullCode | |||||
| } | |||||
| private fun deriveRev(fullCode: String): String? { | |||||
| val v = Regex("^.*V(\\d+)$").find(fullCode) ?: return null | |||||
| return v.groupValues[1] | |||||
| } | |||||
| private fun resolveFlowTypeId(code: String): Int = when { | |||||
| code.startsWith("TOA") -> 1 | |||||
| code.startsWith("BOMPP") || code.startsWith("PP") -> 3 | |||||
| code.startsWith("BOMPF") || code.startsWith("PF") || code.startsWith("PFP") -> 2 | |||||
| else -> 1 | |||||
| } | |||||
| open fun toJson(request: M18BomForShopSaveRequest): String = | |||||
| objectMapper.writeValueAsString(request) | |||||
| open fun toJson(response: GoodsReceiptNoteResponse): String = | |||||
| objectMapper.writeValueAsString(response) | |||||
| open fun saveBomForShop(request: M18BomForShopSaveRequest): GoodsReceiptNoteResponse? = | |||||
| saveBomForShopMono(request).block() | |||||
| open fun saveBomForShopMono(request: M18BomForShopSaveRequest): Mono<GoodsReceiptNoteResponse> { | |||||
| val queryParams = LinkedMultiValueMap<String, String>().apply { | |||||
| add("menuCode", menuCode) | |||||
| } | |||||
| val qs = queryParams.entries.flatMap { (k, v) -> v.map { "$k=$it" } }.joinToString("&") | |||||
| val fullUrl = "${m18Config.BASE_URL}$savePath?$qs" | |||||
| val bodyJson = objectMapper.writeValueAsString(request) | |||||
| logger.info("[M18 BOM udfBomForShop] PUT url=$fullUrl bodyUtf8Bytes=${bodyJson.toByteArray(StandardCharsets.UTF_8).size}") | |||||
| logger.debug("[M18 BOM udfBomForShop] PUT body=$bodyJson") | |||||
| return apiCallerService.putWithJsonString<GoodsReceiptNoteResponse>( | |||||
| urlPath = savePath, | |||||
| queryParams = queryParams, | |||||
| bodyJson = bodyJson, | |||||
| ).doOnSuccess { r -> | |||||
| logger.info("[M18 BOM udfBomForShop] response status=${r.status} recordId=${r.recordId} messages=${r.messages}") | |||||
| }.doOnError { e -> | |||||
| logger.error("[M18 BOM udfBomForShop] failed: ${e.message}", e) | |||||
| } | |||||
| } | |||||
| } | |||||
| @@ -4,8 +4,9 @@ import com.ffii.core.utils.JwtTokenUtil | |||||
| import com.ffii.fpsms.m18.M18Config | import com.ffii.fpsms.m18.M18Config | ||||
| import com.ffii.fpsms.m18.model.SyncResult | import com.ffii.fpsms.m18.model.SyncResult | ||||
| import com.ffii.fpsms.m18.service.* | import com.ffii.fpsms.m18.service.* | ||||
| import com.ffii.fpsms.m18.model.M18BomShopSyncTriggerResult | |||||
| import com.ffii.fpsms.m18.web.models.M18CommonRequest | import com.ffii.fpsms.m18.web.models.M18CommonRequest | ||||
| import com.ffii.fpsms.modules.common.SettingNames | |||||
| import com.ffii.fpsms.modules.master.service.BomService | |||||
| import com.ffii.fpsms.modules.common.scheduler.service.SchedulerService | import com.ffii.fpsms.modules.common.scheduler.service.SchedulerService | ||||
| import com.ffii.fpsms.modules.master.entity.ItemUom | import com.ffii.fpsms.modules.master.entity.ItemUom | ||||
| import com.ffii.fpsms.modules.master.entity.Items | import com.ffii.fpsms.modules.master.entity.Items | ||||
| @@ -35,6 +36,7 @@ class M18TestController ( | |||||
| private val m18DeliveryOrderService: M18DeliveryOrderService, | private val m18DeliveryOrderService: M18DeliveryOrderService, | ||||
| val schedulerService: SchedulerService, | val schedulerService: SchedulerService, | ||||
| private val settingsService: SettingsService, | private val settingsService: SettingsService, | ||||
| private val bomService: BomService, | |||||
| ) { | ) { | ||||
| var logger: Logger = LoggerFactory.getLogger(JwtTokenUtil::class.java) | var logger: Logger = LoggerFactory.getLogger(JwtTokenUtil::class.java) | ||||
| @@ -65,6 +67,14 @@ class M18TestController ( | |||||
| return schedulerService.getM18Pos(); | return schedulerService.getM18Pos(); | ||||
| } | } | ||||
| @PostMapping("/test/bom-shop-sync/{bomId}") | |||||
| fun testBomShopSync( | |||||
| @PathVariable bomId: Long, | |||||
| @RequestParam(required = false) m18HeaderId: Long?, | |||||
| ): M18BomShopSyncTriggerResult { | |||||
| return bomService.pushBomToM18ShopIfAllowed(bomId, m18HeaderId) | |||||
| } | |||||
| @GetMapping("/test/po-by-code") | @GetMapping("/test/po-by-code") | ||||
| fun testSyncPoByCode(@RequestParam code: String): SyncResult { | fun testSyncPoByCode(@RequestParam code: String): SyncResult { | ||||
| return m18PurchaseOrderService.savePurchaseOrderByCode(code) | return m18PurchaseOrderService.savePurchaseOrderByCode(code) | ||||
| @@ -41,6 +41,11 @@ public abstract class SettingNames { | |||||
| */ | */ | ||||
| public static final String M18_UNITS_SYNC_INITIAL_FULL_SYNC_DONE = "M18.units.sync.initialFullSyncDone"; | public static final String M18_UNITS_SYNC_INITIAL_FULL_SYNC_DONE = "M18.units.sync.initialFullSyncDone"; | ||||
| /** | |||||
| * When "true", FPSMS may push BOM header + materials to M18 udfBomForShop. | |||||
| */ | |||||
| public static final String M18_BOM_SHOP_SYNC_ENABLED = "M18.bom.shop.sync.enabled"; | |||||
| /** Post completed DN and process M18 GRN (cron, e.g. "0 40 23 * * *" for 23:40 daily) */ | /** Post completed DN and process M18 GRN (cron, e.g. "0 40 23 * * *" for 23:40 daily) */ | ||||
| public static final String SCHEDULE_POST_COMPLETED_DN_GRN = "SCHEDULE.postCompletedDn.grn"; | public static final String SCHEDULE_POST_COMPLETED_DN_GRN = "SCHEDULE.postCompletedDn.grn"; | ||||
| @@ -57,6 +57,12 @@ open class SchedulerService( | |||||
| val m18GrnCodeSyncService: M18GrnCodeSyncService, | val m18GrnCodeSyncService: M18GrnCodeSyncService, | ||||
| val inventoryLotLineService: InventoryLotLineService, | val inventoryLotLineService: InventoryLotLineService, | ||||
| ) { | ) { | ||||
| companion object { | |||||
| /** DO2: Spring 6-field cron default and M18 `lastModifyDate` upper bound hour (1pm local). */ | |||||
| const val DO2_MODIFIED_TO_HOUR: Int = 13 | |||||
| const val DO2_DEFAULT_CRON: String = "0 0 13 * * *" | |||||
| } | |||||
| var logger: Logger = LoggerFactory.getLogger(JwtTokenUtil::class.java) | var logger: Logger = LoggerFactory.getLogger(JwtTokenUtil::class.java) | ||||
| val dataStringFormat = DateTimeFormatter.ofPattern("yyyy-MM-dd") | val dataStringFormat = DateTimeFormatter.ofPattern("yyyy-MM-dd") | ||||
| val dateTimeStringFormat = DateTimeFormatter.ofPattern("yyyy-MM-dd HH:mm:ss") | val dateTimeStringFormat = DateTimeFormatter.ofPattern("yyyy-MM-dd HH:mm:ss") | ||||
| @@ -206,7 +212,7 @@ open class SchedulerService( | |||||
| logger.info("M18 DO2 scheduler disabled (scheduler.m18Sync.enabled=false)") | logger.info("M18 DO2 scheduler disabled (scheduler.m18Sync.enabled=false)") | ||||
| return | return | ||||
| } | } | ||||
| scheduledM18Do2 = commonSchedule(scheduledM18Do2, SettingNames.SCHEDULE_M18_DO2, ::getM18Dos2) | |||||
| scheduledM18Do2 = commonSchedule(scheduledM18Do2, SettingNames.SCHEDULE_M18_DO2, DO2_DEFAULT_CRON, ::getM18Dos2) | |||||
| } | } | ||||
| fun scheduleM18MasterData() { | fun scheduleM18MasterData() { | ||||
| @@ -455,7 +461,7 @@ open class SchedulerService( | |||||
| val ysd = today.minusDays(1L) | val ysd = today.minusDays(1L) | ||||
| val tmr = today.plusDays(1L) | val tmr = today.plusDays(1L) | ||||
| // Default: lastModified from yesterday 19:00 (aligns with nightly DO2 expectation). | |||||
| // Default: lastModified from yesterday 19:00 through today's DO2 run hour (1pm; aligns with SCHEDULE.m18.do2). | |||||
| // On Sunday, yesterday is Saturday: use 03:00 instead so we include DO changed after Sat 03:10 DO1 | // On Sunday, yesterday is Saturday: use 03:00 instead so we include DO changed after Sat 03:10 DO1 | ||||
| // (otherwise Sat 03:00–18:59 would be skipped until a much later sync). | // (otherwise Sat 03:00–18:59 would be skipped until a much later sync). | ||||
| val isSundayDo2 = runDate.dayOfWeek == DayOfWeek.SUNDAY | val isSundayDo2 = runDate.dayOfWeek == DayOfWeek.SUNDAY | ||||
| @@ -465,21 +471,21 @@ open class SchedulerService( | |||||
| ysd.withHour(19).withMinute(0).withSecond(0) | ysd.withHour(19).withMinute(0).withSecond(0) | ||||
| } | } | ||||
| // Set to 11:00:00 of today | |||||
| val todayEleven = today.withHour(11).withMinute(0).withSecond(0) | |||||
| val modifiedDateToEnd = | |||||
| today.withHour(DO2_MODIFIED_TO_HOUR).withMinute(0).withSecond(0) | |||||
| logger.info( | logger.info( | ||||
| "DO2 modifiedDateFrom={} ({}), modifiedDateTo={}", | "DO2 modifiedDateFrom={} ({}), modifiedDateTo={}", | ||||
| modifiedFromStart.format(dateTimeStringFormat), | modifiedFromStart.format(dateTimeStringFormat), | ||||
| if (isSundayDo2) "Sunday window from Sat 03:00" else "from yesterday 19:00", | if (isSundayDo2) "Sunday window from Sat 03:00" else "from yesterday 19:00", | ||||
| todayEleven.format(dateTimeStringFormat), | |||||
| modifiedDateToEnd.format(dateTimeStringFormat), | |||||
| ) | ) | ||||
| val requestDO = M18CommonRequest( | val requestDO = M18CommonRequest( | ||||
| // These will now produce "yyyy-MM-dd HH:mm:ss" | // These will now produce "yyyy-MM-dd HH:mm:ss" | ||||
| dDateTo = tmr.format(dateTimeStringFormat), // e.g. 2026-01-19 00:00:00 | dDateTo = tmr.format(dateTimeStringFormat), // e.g. 2026-01-19 00:00:00 | ||||
| dDateFrom = tmr.format(dateTimeStringFormat), // e.g. 2026-01-19 00:00:00 | dDateFrom = tmr.format(dateTimeStringFormat), // e.g. 2026-01-19 00:00:00 | ||||
| modifiedDateTo = todayEleven.format(dateTimeStringFormat), // 2026-01-18 11:00:00 | |||||
| modifiedDateTo = modifiedDateToEnd.format(dateTimeStringFormat), | |||||
| modifiedDateFrom = modifiedFromStart.format(dateTimeStringFormat), | modifiedDateFrom = modifiedFromStart.format(dateTimeStringFormat), | ||||
| ) | ) | ||||
| @@ -491,30 +497,6 @@ open class SchedulerService( | |||||
| result = result, | result = result, | ||||
| start = currentTime | start = currentTime | ||||
| ) | ) | ||||
| // Extra DO sync window: after DO2, also sync ETA = today or tomorrow (normal sync; does NOT set isEtra). | |||||
| try { | |||||
| val extraStart = LocalDateTime.now() | |||||
| val requestExtra = M18CommonRequest( | |||||
| dDateFrom = today.format(dateTimeStringFormat), | |||||
| dDateTo = tmr.format(dateTimeStringFormat), | |||||
| ) | |||||
| val extraResult = m18DeliveryOrderService.saveDeliveryOrders(requestExtra) | |||||
| saveSyncLog( | |||||
| type = "DO2_EXTRA", | |||||
| status = "SUCCESS", | |||||
| result = extraResult, | |||||
| start = extraStart, | |||||
| ) | |||||
| } catch (e: Exception) { | |||||
| logger.error("DO2_EXTRA sync failed: ${e.message}", e) | |||||
| saveSyncLog( | |||||
| type = "DO2_EXTRA", | |||||
| status = "FAIL", | |||||
| error = e.message, | |||||
| start = LocalDateTime.now(), | |||||
| ) | |||||
| } | |||||
| } | } | ||||
| open fun getPostCompletedDnAndProcessGrn( | open fun getPostCompletedDnAndProcessGrn( | ||||
| @@ -1367,15 +1367,18 @@ class PlasticBagPrinterService( | |||||
| } | } | ||||
| val qrValue = zplEscape(qrPayload) | val qrValue = zplEscape(qrPayload) | ||||
| // Must match python Bag2.py generate_zpl_dataflex() | |||||
| // Must match python Bag3.py generate_zpl_dataflex() field layout / fonts. | |||||
| val fontRegular = "E:STXihei.ttf" | val fontRegular = "E:STXihei.ttf" | ||||
| val fontBold = "E:STXihei.ttf" | val fontBold = "E:STXihei.ttf" | ||||
| // Match python Bag3.py DataFlex defaults: narrower ^PW so job preview is not mostly empty on the right (^A@R fields are tall, not wide). | |||||
| val labelPw = 400 | |||||
| val labelLl = 500 | |||||
| return """ | return """ | ||||
| ^XA | ^XA | ||||
| ^CI28 | ^CI28 | ||||
| ^PW700 | |||||
| ^LL500 | |||||
| ^PW$labelPw | |||||
| ^LL$labelLl | |||||
| ^PO N | ^PO N | ||||
| ^FO10,20 | ^FO10,20 | ||||
| ^BQN,2,4^FDQA,$qrValue^FS | ^BQN,2,4^FDQA,$qrValue^FS | ||||
| @@ -34,6 +34,15 @@ import java.util.Comparator | |||||
| import com.ffii.fpsms.modules.jobOrder.entity.JobOrderRepository | import com.ffii.fpsms.modules.jobOrder.entity.JobOrderRepository | ||||
| import com.ffii.fpsms.modules.productProcess.entity.ProductProcessRepository | import com.ffii.fpsms.modules.productProcess.entity.ProductProcessRepository | ||||
| import org.springframework.transaction.annotation.Transactional | import org.springframework.transaction.annotation.Transactional | ||||
| import com.fasterxml.jackson.databind.ObjectMapper | |||||
| import com.ffii.fpsms.m18.service.M18BomForShopService | |||||
| import com.ffii.fpsms.m18.model.M18BomShopSyncTriggerResult | |||||
| import com.ffii.fpsms.m18.model.GoodsReceiptNoteResponse | |||||
| import com.ffii.fpsms.m18.entity.M18BomShopSyncLog | |||||
| import com.ffii.fpsms.m18.entity.M18BomShopSyncLogRepository | |||||
| import com.ffii.fpsms.modules.common.SettingNames | |||||
| import com.ffii.fpsms.modules.settings.entity.Settings | |||||
| import com.ffii.fpsms.modules.settings.service.SettingsService | |||||
| @Service | @Service | ||||
| open class BomService( | open class BomService( | ||||
| @@ -52,6 +61,10 @@ open class BomService( | |||||
| private val itemUomService: ItemUomService, | private val itemUomService: ItemUomService, | ||||
| private val jobOrderRepository: JobOrderRepository, | private val jobOrderRepository: JobOrderRepository, | ||||
| private val productProcessRepository: ProductProcessRepository, | private val productProcessRepository: ProductProcessRepository, | ||||
| private val m18BomForShopService: M18BomForShopService, | |||||
| private val m18BomShopSyncLogRepository: M18BomShopSyncLogRepository, | |||||
| private val objectMapper: ObjectMapper, | |||||
| private val settingsService: SettingsService, | |||||
| @Value("\${bom.import.temp-dir:\${java.io.tmpdir}/fpsms-bom-import}") private val bomImportTempDir: String, | @Value("\${bom.import.temp-dir:\${java.io.tmpdir}/fpsms-bom-import}") private val bomImportTempDir: String, | ||||
| ) { | ) { | ||||
| open fun uploadBomFiles(files: List<MultipartFile>): BomUploadResponse { | open fun uploadBomFiles(files: List<MultipartFile>): BomUploadResponse { | ||||
| @@ -119,6 +132,29 @@ open class BomService( | |||||
| ?: bomRepository.findAllByItemIdAndDeletedIsFalse(itemId).firstOrNull() | ?: bomRepository.findAllByItemIdAndDeletedIsFalse(itemId).firstOrNull() | ||||
| } | } | ||||
| /** Resolve BOM header for a finished-good item code ([Items.code] on [Bom.item]). */ | |||||
| open fun findBomSummaryByItemCode(itemCodeTrimmed: String): BomIdByItemCodeResponse { | |||||
| val code = itemCodeTrimmed.trim() | |||||
| val item = itemsRepository.findByCodeAndDeletedFalse(code) | |||||
| ?: return BomIdByItemCodeResponse( | |||||
| itemCode = code, | |||||
| message = "Item not found for code", | |||||
| ) | |||||
| val bom = findByItemId(item.id!!) | |||||
| ?: return BomIdByItemCodeResponse( | |||||
| itemCode = code, | |||||
| itemId = item.id, | |||||
| message = "No BOM linked to this item", | |||||
| ) | |||||
| return BomIdByItemCodeResponse( | |||||
| itemCode = code, | |||||
| itemId = item.id, | |||||
| bomId = bom.id, | |||||
| bomCode = bom.code, | |||||
| bomM18Id = bom.m18Id, | |||||
| ) | |||||
| } | |||||
| open fun saveBom(request: SaveBomRequest): SaveBomResponse { | open fun saveBom(request: SaveBomRequest): SaveBomResponse { | ||||
| val item = request.code.let { itemsService.findByM18BomCode(it) } ?: request.itemId?.let { itemsService.findById(it) } | val item = request.code.let { itemsService.findByM18BomCode(it) } ?: request.itemId?.let { itemsService.findById(it) } | ||||
| @@ -371,6 +407,111 @@ open class BomService( | |||||
| return getBomDetail(bom.id!!) | return getBomDetail(bom.id!!) | ||||
| } | } | ||||
| /** | |||||
| * When {@link SettingNames#M18_BOM_SHOP_SYNC_ENABLED} is true, push BOM to M18 udfBomForShop. | |||||
| * Use {@code POST /m18/test/bom-shop-sync/{bomId}} (or future UI) to trigger explicitly. | |||||
| * Optional [m18HeaderId]: M18 udfBomForShop **header** record id (e.g. [BomIdByItemCodeResponse.bomM18Id]) | |||||
| * to force **update** when [Bom.m18Id] is missing or stale. When null, uses [Bom.m18Id] if set. | |||||
| */ | |||||
| open fun pushBomToM18ShopIfAllowed(bomId: Long, m18HeaderId: Long? = null): M18BomShopSyncTriggerResult { | |||||
| if (!isM18BomShopSyncEnabled()) { | |||||
| return M18BomShopSyncTriggerResult( | |||||
| bomId = bomId, | |||||
| synced = false, | |||||
| skippedReason = "M18 BOM shop sync disabled (${SettingNames.M18_BOM_SHOP_SYNC_ENABLED} is not true)", | |||||
| ) | |||||
| } | |||||
| val bom = bomRepository.findByIdAndDeletedIsFalse(bomId) | |||||
| ?: return M18BomShopSyncTriggerResult(bomId, false, skippedReason = "BOM not found") | |||||
| val req = m18BomForShopService.buildSaveRequest(bom, m18HeaderId) | |||||
| ?: return M18BomShopSyncTriggerResult(bomId, false, skippedReason = "Cannot build M18 payload (missing m18 UOM/item ids or no materials)") | |||||
| val requestJsonPayload = m18BomForShopService.toJson(req) | |||||
| var resp: GoodsReceiptNoteResponse? = null | |||||
| var callError: Throwable? = null | |||||
| try { | |||||
| resp = m18BomForShopService.saveBomForShop(req) | |||||
| } catch (e: Exception) { | |||||
| callError = e | |||||
| } | |||||
| val responseJsonPayload = when { | |||||
| resp != null -> m18BomForShopService.toJson(resp) | |||||
| callError != null -> | |||||
| runCatching { | |||||
| objectMapper.writeValueAsString( | |||||
| mapOf( | |||||
| "exceptionType" to callError.javaClass.name, | |||||
| "message" to (callError.message ?: ""), | |||||
| ), | |||||
| ) | |||||
| }.getOrElse { """{"error":"failed to serialize exception"}""" } | |||||
| else -> """{"error":"M18 API returned null"}""" | |||||
| } | |||||
| val msgSummary = resp?.messages?.joinToString("; ") { it.msgDetail ?: it.msgCode ?: "" }.orEmpty() | |||||
| val apiStatus = resp?.status == true | |||||
| val recordId = resp?.recordId ?: 0L | |||||
| val result = when { | |||||
| callError != null -> | |||||
| M18BomShopSyncTriggerResult( | |||||
| bomId = bomId, | |||||
| synced = false, | |||||
| skippedReason = callError.message ?: "M18 API call failed", | |||||
| status = false, | |||||
| messageSummary = callError.message, | |||||
| ) | |||||
| resp == null -> | |||||
| M18BomShopSyncTriggerResult(bomId, false, skippedReason = "M18 API returned null") | |||||
| resp.status == true && resp.recordId > 0L -> { | |||||
| bom.m18Id = resp.recordId | |||||
| bomRepository.saveAndFlush(bom) | |||||
| M18BomShopSyncTriggerResult( | |||||
| bomId = bomId, | |||||
| synced = true, | |||||
| recordId = resp.recordId, | |||||
| status = true, | |||||
| messageSummary = msgSummary.ifBlank { null }, | |||||
| ) | |||||
| } | |||||
| else -> | |||||
| M18BomShopSyncTriggerResult( | |||||
| bomId = bomId, | |||||
| synced = false, | |||||
| skippedReason = "M18 save failed or status=false", | |||||
| recordId = resp.recordId.takeIf { it > 0 }, | |||||
| status = resp.status, | |||||
| messageSummary = msgSummary.ifBlank { null }, | |||||
| ) | |||||
| } | |||||
| val logMessage = listOfNotNull( | |||||
| msgSummary.ifBlank { null }, | |||||
| callError?.message, | |||||
| result.skippedReason?.takeIf { !result.synced }, | |||||
| ).joinToString("; ").take(4000) | |||||
| m18BomShopSyncLogRepository.save( | |||||
| M18BomShopSyncLog().apply { | |||||
| this.bomId = bomId | |||||
| m18RecordId = recordId.takeIf { it > 0 } | |||||
| m18ApiStatus = apiStatus | |||||
| synced = result.synced | |||||
| message = logMessage.ifBlank { null } | |||||
| requestJson = requestJsonPayload | |||||
| responseJson = responseJsonPayload | |||||
| }, | |||||
| ) | |||||
| return result | |||||
| } | |||||
| private fun isM18BomShopSyncEnabled(): Boolean = | |||||
| settingsService.findByName(SettingNames.M18_BOM_SHOP_SYNC_ENABLED) | |||||
| .map { Settings.VALUE_BOOLEAN_TRUE == it.value } | |||||
| .orElse(false) | |||||
| private fun resolveEquipmentForBomProcess(pReq: EditBomProcessRequest): Equipment { | private fun resolveEquipmentForBomProcess(pReq: EditBomProcessRequest): Equipment { | ||||
| val equipmentId = pReq.equipmentId | val equipmentId = pReq.equipmentId | ||||
| val equipmentCode = pReq.equipmentCode?.trim().orEmpty() | val equipmentCode = pReq.equipmentCode?.trim().orEmpty() | ||||
| @@ -282,6 +282,20 @@ open class ItemUomService( | |||||
| return finalizePreciseStockQty(stockUnit, stockQty) | return finalizePreciseStockQty(stockUnit, stockQty) | ||||
| } | } | ||||
| /** | |||||
| * Convert quantity from [uomId] (must exist on `item_uom` for [itemId]) to the item's **base unit** quantity. | |||||
| * Returns null when no `item_uom` row links the item to that UOM. | |||||
| */ | |||||
| open fun convertQtyToBaseQtyPrecise(itemId: Long, uomId: Long, sourceQty: BigDecimal): BigDecimal? { | |||||
| val itemUom = findFirstByItemIdAndUomId(itemId, uomId) ?: return null | |||||
| val one = BigDecimal.ONE | |||||
| val calcScale = 10 | |||||
| return sourceQty | |||||
| .multiply(itemUom.ratioN ?: one) | |||||
| .divide(itemUom.ratioD ?: one, calcScale, RoundingMode.HALF_UP) | |||||
| .stripTrailingZeros() | |||||
| } | |||||
| // See if need to update the response | // See if need to update the response | ||||
| open fun saveItemUom(request: ItemUomRequest): ItemUom { | open fun saveItemUom(request: ItemUomRequest): ItemUom { | ||||
| val itemUom = request.m18Id?.let { itemUomRespository.findFirstByM18IdOrderByIdDesc(it) } | val itemUom = request.m18Id?.let { itemUomRespository.findFirstByM18IdOrderByIdDesc(it) } | ||||
| @@ -29,6 +29,8 @@ import com.ffii.fpsms.modules.master.web.models.ImportBomRequestPayload | |||||
| import com.ffii.fpsms.modules.master.web.models.BomDetailResponse | import com.ffii.fpsms.modules.master.web.models.BomDetailResponse | ||||
| import com.ffii.fpsms.modules.master.web.models.EditBomRequest | import com.ffii.fpsms.modules.master.web.models.EditBomRequest | ||||
| import com.ffii.fpsms.modules.master.web.models.BomExcelCheckProgress | import com.ffii.fpsms.modules.master.web.models.BomExcelCheckProgress | ||||
| import com.ffii.fpsms.modules.master.web.models.BomIdByItemCodeResponse | |||||
| import com.ffii.core.exception.BadRequestException | |||||
| import java.util.logging.Logger | import java.util.logging.Logger | ||||
| import java.nio.file.Files | import java.nio.file.Files | ||||
| import org.springframework.core.io.FileSystemResource | import org.springframework.core.io.FileSystemResource | ||||
| @@ -120,6 +122,16 @@ fun downloadBomFormatIssueLog( | |||||
| // fun exportProblematicBom() { | // fun exportProblematicBom() { | ||||
| // return bomService.importBOM() | // return bomService.importBOM() | ||||
| // } | // } | ||||
| /** Testing: FPSMS BOM id by finished-good item code (same item as BOM header). */ | |||||
| @GetMapping("/by-item-code") | |||||
| fun getBomByItemCode(@RequestParam code: String): BomIdByItemCodeResponse { | |||||
| if (code.isBlank()) { | |||||
| throw BadRequestException("query parameter code is required") | |||||
| } | |||||
| return bomService.findBomSummaryByItemCode(code.trim()) | |||||
| } | |||||
| @GetMapping("/{id}/detail") | @GetMapping("/{id}/detail") | ||||
| fun getBomDetail(@PathVariable id: Long): BomDetailResponse { | fun getBomDetail(@PathVariable id: Long): BomDetailResponse { | ||||
| return bomService.getBomDetail(id) | return bomService.getBomDetail(id) | ||||
| @@ -0,0 +1,14 @@ | |||||
| package com.ffii.fpsms.modules.master.web.models | |||||
| /** | |||||
| * Testing / lookup: resolve FPSMS BOM from finished-good [item] code (bom.item → [Items.code]). | |||||
| */ | |||||
| data class BomIdByItemCodeResponse( | |||||
| val itemCode: String, | |||||
| val itemId: Long? = null, | |||||
| val bomId: Long? = null, | |||||
| val bomCode: String? = null, | |||||
| val bomM18Id: Long? = null, | |||||
| /** e.g. item not found, or no BOM for item */ | |||||
| val message: String? = null, | |||||
| ) | |||||
| @@ -9,7 +9,7 @@ WHERE NOT EXISTS ( | |||||
| ); | ); | ||||
| INSERT INTO `fpsmsdb`.`settings` (`name`, `value`, `category`, `type`) | INSERT INTO `fpsmsdb`.`settings` (`name`, `value`, `category`, `type`) | ||||
| SELECT 'SCHEDULE.m18.do2', '0 0 11 * * *', 'SCHEDULE', 'string' | |||||
| SELECT 'SCHEDULE.m18.do2', '0 0 13 * * *', 'SCHEDULE', 'string' | |||||
| FROM DUAL | FROM DUAL | ||||
| WHERE NOT EXISTS ( | WHERE NOT EXISTS ( | ||||
| SELECT 1 FROM `fpsmsdb`.`settings` WHERE `name` = 'SCHEDULE.m18.do2' | SELECT 1 FROM `fpsmsdb`.`settings` WHERE `name` = 'SCHEDULE.m18.do2' | ||||
| @@ -0,0 +1,20 @@ | |||||
| --liquibase formatted sql | |||||
| --changeset fai:20260512_m18_bom_shop_sync_settings | |||||
| INSERT INTO `settings` (`name`, `value`, `category`, `type`) | |||||
| SELECT v.name, v.value, v.category, v.type | |||||
| FROM ( | |||||
| SELECT 'M18.bom.shop.sync.enabled' AS name, 'false' AS value, 'M18' AS category, 'boolean' AS type | |||||
| ) v | |||||
| WHERE NOT EXISTS ( | |||||
| SELECT 1 FROM `settings` s WHERE s.name = 'M18.bom.shop.sync.enabled' | |||||
| ); | |||||
| INSERT INTO `settings` (`name`, `value`, `category`, `type`) | |||||
| SELECT v.name, v.value, v.category, v.type | |||||
| FROM ( | |||||
| SELECT 'M18.bom.shop.sync.allowedBomIds' AS name, '78,274' AS value, 'M18' AS category, 'string' AS type | |||||
| ) v | |||||
| WHERE NOT EXISTS ( | |||||
| SELECT 1 FROM `settings` s WHERE s.name = 'M18.bom.shop.sync.allowedBomIds' | |||||
| ); | |||||
| @@ -0,0 +1,24 @@ | |||||
| --liquibase formatted sql | |||||
| --changeset fai:20260512_m18_bom_shop_sync_log | |||||
| CREATE TABLE IF NOT EXISTS `m18_bom_shop_sync_log` ( | |||||
| `id` BIGINT NOT NULL AUTO_INCREMENT, | |||||
| `created` DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP, | |||||
| `createdBy` VARCHAR(30) NULL DEFAULT NULL, | |||||
| `version` INT NOT NULL DEFAULT '0', | |||||
| `modified` DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP, | |||||
| `modifiedBy` VARCHAR(30) NULL DEFAULT NULL, | |||||
| `deleted` TINYINT(1) NOT NULL DEFAULT '0', | |||||
| `bom_id` BIGINT NOT NULL COMMENT 'FPSMS bom.id', | |||||
| `m18_record_id` BIGINT NULL DEFAULT NULL COMMENT 'M18 udfBomForShop record id when returned', | |||||
| `m18_api_status` TINYINT(1) NOT NULL COMMENT 'M18 response status field', | |||||
| `synced` TINYINT(1) NOT NULL COMMENT 'FPSMS treat as success (e.g. updated bom.m18Id)', | |||||
| `message` VARCHAR(4000) NULL DEFAULT NULL COMMENT 'Summary / errors', | |||||
| `request_json` LONGTEXT NULL COMMENT 'PUT body sent to M18', | |||||
| `response_json` LONGTEXT NULL COMMENT 'Parsed M18 response or error JSON', | |||||
| CONSTRAINT pk_m18_bom_shop_sync_log PRIMARY KEY (`id`), | |||||
| KEY `idx_m18_bom_shop_sync_log_bom_id` (`bom_id`), | |||||
| KEY `idx_m18_bom_shop_sync_log_created` (`created`) | |||||
| ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci; | |||||
| @@ -0,0 +1,4 @@ | |||||
| --liquibase formatted sql | |||||
| --changeset fpsms:20260513_do2_schedule_1pm | |||||
| UPDATE `settings` SET `value` = '0 0 13 * * *' WHERE `name` = 'SCHEDULE.m18.do2'; | |||||
| @@ -11,6 +11,7 @@ Configutation: | |||||
| filePattern: ${log_location}fpsms-all.log.%i.gz | filePattern: ${log_location}fpsms-all.log.%i.gz | ||||
| PatternLayout: | PatternLayout: | ||||
| Pattern: "%d %p [%l] - %m%n" | Pattern: "%d %p [%l] - %m%n" | ||||
| charset: UTF-8 | |||||
| Policies: | Policies: | ||||
| SizeBasedTriggeringPolicy: | SizeBasedTriggeringPolicy: | ||||
| size: 4096KB | size: 4096KB | ||||
| @@ -11,6 +11,7 @@ Configutation: | |||||
| filePattern: ${log_location}fpsms-all.log.%i.gz | filePattern: ${log_location}fpsms-all.log.%i.gz | ||||
| PatternLayout: | PatternLayout: | ||||
| Pattern: "%d %p [%l] - %m%n" | Pattern: "%d %p [%l] - %m%n" | ||||
| charset: UTF-8 | |||||
| Policies: | Policies: | ||||
| SizeBasedTriggeringPolicy: | SizeBasedTriggeringPolicy: | ||||
| size: 4096KB | size: 4096KB | ||||
| @@ -10,6 +10,7 @@ Configutation: | |||||
| target: SYSTEM_OUT | target: SYSTEM_OUT | ||||
| PatternLayout: | PatternLayout: | ||||
| pattern: ${log_pattern} | pattern: ${log_pattern} | ||||
| charset: UTF-8 | |||||
| Loggers: | Loggers: | ||||
| Root: | Root: | ||||
| level: info | level: info | ||||