| @@ -16,6 +16,7 @@ Bag2 is kept as a separate legacy v2.x line; do not assume Bag2 matches Bag3. | |||
| Run: python Bag3.py | |||
| """ | |||
| import errno | |||
| import json | |||
| import os | |||
| import select | |||
| @@ -344,6 +345,25 @@ DATAFLEX_UI_PROGRESS_EVERY = max( | |||
| DATAFLEX_SINGLE_TCP_JOB = _dataflex_bool_env( | |||
| "FPSMS_DATAFLEX_SINGLE_TCP_JOB", False | |||
| ) | |||
| # Link-OS SGD: raw-ZPL job shows this host id instead of a generic name (e.g. "ZPL. EMULATION"). | |||
| # Same id when the same job order is printed again. Disable: FPSMS_DATAFLEX_HOST_IDENTIFICATION_SGD=0 | |||
| DATAFLEX_HOST_IDENTIFICATION_SGD = _dataflex_bool_env( | |||
| "FPSMS_DATAFLEX_HOST_IDENTIFICATION_SGD", True | |||
| ) | |||
| # Bag ZPL size (dots). ^PW700 matched little content (mostly vertical ^A@R), so previews showed a wide strip with empty right margin. | |||
| DATAFLEX_LABEL_PW = max( | |||
| 280, | |||
| _dataflex_int_env("FPSMS_DATAFLEX_LABEL_PW", 400), | |||
| ) | |||
| DATAFLEX_LABEL_LL = max( | |||
| 200, | |||
| _dataflex_int_env("FPSMS_DATAFLEX_LABEL_LL", 500), | |||
| ) | |||
| # Some Zebra/DataFlex units RST the socket on host half-close; Windows surfaces WinError 10054. | |||
| # Set FPSMS_DATAFLEX_SKIP_SHUTDOWN_WR=1 to omit shutdown(SHUT_WR) and only close() (often avoids RST). | |||
| DATAFLEX_SKIP_SHUTDOWN_WR = _dataflex_bool_env( | |||
| "FPSMS_DATAFLEX_SKIP_SHUTDOWN_WR", False | |||
| ) | |||
| # Full recovery (~JR soft reset) — used by「打袋重設」only; longer delay for firmware | |||
| DATAFLEX_POST_FULL_RECOVERY_DELAY_SEC = 1.2 | |||
| # Zebra ~RO only (used when FPSMS_DATAFLEX_NO_JR is set for full recovery) | |||
| @@ -364,12 +384,56 @@ def _zpl_escape(s: str) -> str: | |||
| return s.replace("\\", "\\\\").replace("^", "\\^") | |||
| def _dataflex_host_identification_sgd_prefix(job_order_id: Optional[int]) -> str: | |||
| """ | |||
| Optional ASCII prefix before ^XA: set zpl.host_identification so the printer lists the job | |||
| under the job order id instead of a generic raw-ZPL label. | |||
| """ | |||
| if not DATAFLEX_HOST_IDENTIFICATION_SGD or job_order_id is None: | |||
| return "" | |||
| try: | |||
| jid = str(int(job_order_id)) | |||
| except (TypeError, ValueError): | |||
| return "" | |||
| if not jid.isdigit(): | |||
| return "" | |||
| return f'! U1 setvar "zpl.host_identification" "{jid}"\r\n' | |||
| def _dataflex_zpl_bytes(zpl: str) -> bytes: | |||
| """UTF-8 ZPL with one trailing CRLF so the printer sees a clear job boundary.""" | |||
| s = (zpl or "").rstrip("\r\n") | |||
| return (s + "\r\n").encode("utf-8") | |||
| def _dataflex_is_benign_tcp_reset(err: BaseException) -> bool: | |||
| """True when peer closed with RST/FIN in a way that is normal for raw printer TCP (Windows 10054).""" | |||
| if isinstance(err, (BrokenPipeError, ConnectionResetError, ConnectionAbortedError)): | |||
| return True | |||
| if isinstance(err, OSError): | |||
| if getattr(err, "winerror", None) == 10054: # WSAECONNRESET | |||
| return True | |||
| if err.errno in ( | |||
| errno.ECONNRESET, | |||
| errno.EPIPE, | |||
| errno.ECONNABORTED, | |||
| ): | |||
| return True | |||
| return False | |||
| def _dataflex_shutdown_write_maybe(sock: socket.socket) -> None: | |||
| """Half-close write side; ignore printer RST (common after ZPL on port 9100-style links).""" | |||
| if DATAFLEX_SKIP_SHUTDOWN_WR: | |||
| return | |||
| try: | |||
| sock.shutdown(socket.SHUT_WR) | |||
| except OSError as e: | |||
| if _dataflex_is_benign_tcp_reset(e): | |||
| return | |||
| raise | |||
| def generate_zpl_dataflex( | |||
| batch_no: str, | |||
| item_code: str, | |||
| @@ -377,6 +441,7 @@ def generate_zpl_dataflex( | |||
| item_id: Optional[int] = None, | |||
| stock_in_line_id: Optional[int] = None, | |||
| lot_no: Optional[str] = None, | |||
| job_order_id: Optional[int] = None, | |||
| font_regular: str = "E:STXihei.ttf", | |||
| font_bold: str = "E:STXihei.ttf", | |||
| ) -> str: | |||
| @@ -398,11 +463,12 @@ def generate_zpl_dataflex( | |||
| qr_value = _zpl_escape(qr_payload) | |||
| # Explicit ^PQ1: each ^XA…^XZ is exactly one bag. Avoids E1005 "over quantity" on some Zebra/DataFlex | |||
| # firmware when many labels are sent on one TCP session without a per-job quantity. | |||
| return f"""^XA | |||
| host_id = _dataflex_host_identification_sgd_prefix(job_order_id) | |||
| return host_id + f"""^XA | |||
| ^PQ1,0,1,N | |||
| ^CI28 | |||
| ^PW700 | |||
| ^LL500 | |||
| ^PW{DATAFLEX_LABEL_PW} | |||
| ^LL{DATAFLEX_LABEL_LL} | |||
| ^PO N | |||
| ^FO10,20 | |||
| ^BQN,2,4^FDQA,{qr_value}^FS | |||
| @@ -447,10 +513,7 @@ def send_dataflex_preprint_reset(ip: str, port: int, *, force: bool = False) -> | |||
| sock.connect((ip, port)) | |||
| sock.sendall(DATAFLEX_PREPRINT_BYTES) | |||
| time.sleep(DATAFLEX_POST_PREPRINT_DELAY_SEC) | |||
| try: | |||
| sock.shutdown(socket.SHUT_WR) | |||
| except OSError: | |||
| pass | |||
| _dataflex_shutdown_write_maybe(sock) | |||
| finally: | |||
| sock.close() | |||
| @@ -472,10 +535,7 @@ def send_dataflex_job_counter_reset(ip: str, port: int, *, force: bool = False) | |||
| sock.connect((ip, port)) | |||
| sock.sendall(_dataflex_full_recovery_payload()) | |||
| time.sleep(DATAFLEX_POST_FULL_RECOVERY_DELAY_SEC) | |||
| try: | |||
| sock.shutdown(socket.SHUT_WR) | |||
| except OSError: | |||
| pass | |||
| _dataflex_shutdown_write_maybe(sock) | |||
| finally: | |||
| sock.close() | |||
| @@ -527,10 +587,7 @@ def send_dataflex_reset_and_labels( | |||
| time.sleep(DATAFLEX_POST_LABEL_SETTLE_SEC) | |||
| if i < copies - 1: | |||
| time.sleep(delay_sec) | |||
| try: | |||
| sock.shutdown(socket.SHUT_WR) | |||
| except OSError: | |||
| pass | |||
| _dataflex_shutdown_write_maybe(sock) | |||
| finally: | |||
| sock.close() | |||
| @@ -879,10 +936,7 @@ def send_zpl_to_dataflex(ip: str, port: int, zpl: str) -> None: | |||
| sock.connect((ip, port)) | |||
| sock.sendall(_dataflex_zpl_bytes(zpl)) | |||
| time.sleep(DATAFLEX_POST_LABEL_SETTLE_SEC) | |||
| try: | |||
| sock.shutdown(socket.SHUT_WR) | |||
| except OSError: | |||
| pass | |||
| _dataflex_shutdown_write_maybe(sock) | |||
| finally: | |||
| sock.close() | |||
| @@ -907,6 +961,10 @@ def query_dataflex_host_status(ip: str, port: int) -> str: | |||
| data = sock.recv(4096) | |||
| except socket.timeout: | |||
| break | |||
| except OSError as ex: | |||
| if _dataflex_is_benign_tcp_reset(ex): | |||
| break | |||
| raise | |||
| if not data: | |||
| break | |||
| chunks.append(data) | |||
| @@ -2451,6 +2509,7 @@ def main() -> None: | |||
| item_id=item_id, | |||
| stock_in_line_id=stock_in_line_id, | |||
| lot_no=lot_no, | |||
| job_order_id=j.get("id"), | |||
| ) | |||
| label_text = (lot_no or b).strip() | |||
| if continuous: | |||
| @@ -0,0 +1,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.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) | |||
| @@ -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"; | |||
| @@ -57,6 +57,12 @@ open class SchedulerService( | |||
| val m18GrnCodeSyncService: M18GrnCodeSyncService, | |||
| val inventoryLotLineService: InventoryLotLineService, | |||
| ) { | |||
| companion object { | |||
| /** DO2: Spring 6-field cron default and M18 `lastModifyDate` upper bound hour (1pm local). */ | |||
| const val DO2_MODIFIED_TO_HOUR: Int = 13 | |||
| const val DO2_DEFAULT_CRON: String = "0 0 13 * * *" | |||
| } | |||
| var logger: Logger = LoggerFactory.getLogger(JwtTokenUtil::class.java) | |||
| val dataStringFormat = DateTimeFormatter.ofPattern("yyyy-MM-dd") | |||
| val dateTimeStringFormat = DateTimeFormatter.ofPattern("yyyy-MM-dd HH:mm:ss") | |||
| @@ -206,7 +212,7 @@ open class SchedulerService( | |||
| logger.info("M18 DO2 scheduler disabled (scheduler.m18Sync.enabled=false)") | |||
| return | |||
| } | |||
| scheduledM18Do2 = commonSchedule(scheduledM18Do2, SettingNames.SCHEDULE_M18_DO2, ::getM18Dos2) | |||
| scheduledM18Do2 = commonSchedule(scheduledM18Do2, SettingNames.SCHEDULE_M18_DO2, DO2_DEFAULT_CRON, ::getM18Dos2) | |||
| } | |||
| fun scheduleM18MasterData() { | |||
| @@ -455,7 +461,7 @@ open class SchedulerService( | |||
| val ysd = today.minusDays(1L) | |||
| val tmr = today.plusDays(1L) | |||
| // Default: lastModified from yesterday 19:00 (aligns with nightly DO2 expectation). | |||
| // Default: lastModified from yesterday 19:00 through today's DO2 run hour (1pm; aligns with SCHEDULE.m18.do2). | |||
| // On Sunday, yesterday is Saturday: use 03:00 instead so we include DO changed after Sat 03:10 DO1 | |||
| // (otherwise Sat 03:00–18:59 would be skipped until a much later sync). | |||
| val isSundayDo2 = runDate.dayOfWeek == DayOfWeek.SUNDAY | |||
| @@ -465,21 +471,21 @@ open class SchedulerService( | |||
| ysd.withHour(19).withMinute(0).withSecond(0) | |||
| } | |||
| // Set to 11:00:00 of today | |||
| val todayEleven = today.withHour(11).withMinute(0).withSecond(0) | |||
| val modifiedDateToEnd = | |||
| today.withHour(DO2_MODIFIED_TO_HOUR).withMinute(0).withSecond(0) | |||
| logger.info( | |||
| "DO2 modifiedDateFrom={} ({}), modifiedDateTo={}", | |||
| modifiedFromStart.format(dateTimeStringFormat), | |||
| if (isSundayDo2) "Sunday window from Sat 03:00" else "from yesterday 19:00", | |||
| todayEleven.format(dateTimeStringFormat), | |||
| modifiedDateToEnd.format(dateTimeStringFormat), | |||
| ) | |||
| val requestDO = M18CommonRequest( | |||
| // These will now produce "yyyy-MM-dd HH:mm:ss" | |||
| dDateTo = tmr.format(dateTimeStringFormat), // e.g. 2026-01-19 00:00:00 | |||
| dDateFrom = tmr.format(dateTimeStringFormat), // e.g. 2026-01-19 00:00:00 | |||
| modifiedDateTo = todayEleven.format(dateTimeStringFormat), // 2026-01-18 11:00:00 | |||
| modifiedDateTo = modifiedDateToEnd.format(dateTimeStringFormat), | |||
| modifiedDateFrom = modifiedFromStart.format(dateTimeStringFormat), | |||
| ) | |||
| @@ -491,30 +497,6 @@ open class SchedulerService( | |||
| result = result, | |||
| start = currentTime | |||
| ) | |||
| // Extra DO sync window: after DO2, also sync ETA = today or tomorrow (normal sync; does NOT set isEtra). | |||
| try { | |||
| val extraStart = LocalDateTime.now() | |||
| val requestExtra = M18CommonRequest( | |||
| dDateFrom = today.format(dateTimeStringFormat), | |||
| dDateTo = tmr.format(dateTimeStringFormat), | |||
| ) | |||
| val extraResult = m18DeliveryOrderService.saveDeliveryOrders(requestExtra) | |||
| saveSyncLog( | |||
| type = "DO2_EXTRA", | |||
| status = "SUCCESS", | |||
| result = extraResult, | |||
| start = extraStart, | |||
| ) | |||
| } catch (e: Exception) { | |||
| logger.error("DO2_EXTRA sync failed: ${e.message}", e) | |||
| saveSyncLog( | |||
| type = "DO2_EXTRA", | |||
| status = "FAIL", | |||
| error = e.message, | |||
| start = LocalDateTime.now(), | |||
| ) | |||
| } | |||
| } | |||
| open fun getPostCompletedDnAndProcessGrn( | |||
| @@ -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 | |||
| @@ -34,6 +34,15 @@ import java.util.Comparator | |||
| import com.ffii.fpsms.modules.jobOrder.entity.JobOrderRepository | |||
| import com.ffii.fpsms.modules.productProcess.entity.ProductProcessRepository | |||
| import org.springframework.transaction.annotation.Transactional | |||
| import com.fasterxml.jackson.databind.ObjectMapper | |||
| import com.ffii.fpsms.m18.service.M18BomForShopService | |||
| import com.ffii.fpsms.m18.model.M18BomShopSyncTriggerResult | |||
| import com.ffii.fpsms.m18.model.GoodsReceiptNoteResponse | |||
| import com.ffii.fpsms.m18.entity.M18BomShopSyncLog | |||
| import com.ffii.fpsms.m18.entity.M18BomShopSyncLogRepository | |||
| import com.ffii.fpsms.modules.common.SettingNames | |||
| import com.ffii.fpsms.modules.settings.entity.Settings | |||
| import com.ffii.fpsms.modules.settings.service.SettingsService | |||
| @Service | |||
| open class BomService( | |||
| @@ -52,6 +61,10 @@ open class BomService( | |||
| private val itemUomService: ItemUomService, | |||
| private val jobOrderRepository: JobOrderRepository, | |||
| private val productProcessRepository: ProductProcessRepository, | |||
| private val m18BomForShopService: M18BomForShopService, | |||
| private val m18BomShopSyncLogRepository: M18BomShopSyncLogRepository, | |||
| private val objectMapper: ObjectMapper, | |||
| private val settingsService: SettingsService, | |||
| @Value("\${bom.import.temp-dir:\${java.io.tmpdir}/fpsms-bom-import}") private val bomImportTempDir: String, | |||
| ) { | |||
| open fun uploadBomFiles(files: List<MultipartFile>): BomUploadResponse { | |||
| @@ -119,6 +132,29 @@ open class BomService( | |||
| ?: bomRepository.findAllByItemIdAndDeletedIsFalse(itemId).firstOrNull() | |||
| } | |||
| /** Resolve BOM header for a finished-good item code ([Items.code] on [Bom.item]). */ | |||
| open fun findBomSummaryByItemCode(itemCodeTrimmed: String): BomIdByItemCodeResponse { | |||
| val code = itemCodeTrimmed.trim() | |||
| val item = itemsRepository.findByCodeAndDeletedFalse(code) | |||
| ?: return BomIdByItemCodeResponse( | |||
| itemCode = code, | |||
| message = "Item not found for code", | |||
| ) | |||
| val bom = findByItemId(item.id!!) | |||
| ?: return BomIdByItemCodeResponse( | |||
| itemCode = code, | |||
| itemId = item.id, | |||
| message = "No BOM linked to this item", | |||
| ) | |||
| return BomIdByItemCodeResponse( | |||
| itemCode = code, | |||
| itemId = item.id, | |||
| bomId = bom.id, | |||
| bomCode = bom.code, | |||
| bomM18Id = bom.m18Id, | |||
| ) | |||
| } | |||
| open fun saveBom(request: SaveBomRequest): SaveBomResponse { | |||
| val item = request.code.let { itemsService.findByM18BomCode(it) } ?: request.itemId?.let { itemsService.findById(it) } | |||
| @@ -371,6 +407,111 @@ open class BomService( | |||
| return getBomDetail(bom.id!!) | |||
| } | |||
| /** | |||
| * When {@link SettingNames#M18_BOM_SHOP_SYNC_ENABLED} is true, push BOM to M18 udfBomForShop. | |||
| * Use {@code POST /m18/test/bom-shop-sync/{bomId}} (or future UI) to trigger explicitly. | |||
| * Optional [m18HeaderId]: M18 udfBomForShop **header** record id (e.g. [BomIdByItemCodeResponse.bomM18Id]) | |||
| * to force **update** when [Bom.m18Id] is missing or stale. When null, uses [Bom.m18Id] if set. | |||
| */ | |||
| open fun pushBomToM18ShopIfAllowed(bomId: Long, m18HeaderId: Long? = null): M18BomShopSyncTriggerResult { | |||
| if (!isM18BomShopSyncEnabled()) { | |||
| return M18BomShopSyncTriggerResult( | |||
| bomId = bomId, | |||
| synced = false, | |||
| skippedReason = "M18 BOM shop sync disabled (${SettingNames.M18_BOM_SHOP_SYNC_ENABLED} is not true)", | |||
| ) | |||
| } | |||
| val bom = bomRepository.findByIdAndDeletedIsFalse(bomId) | |||
| ?: return M18BomShopSyncTriggerResult(bomId, false, skippedReason = "BOM not found") | |||
| val req = m18BomForShopService.buildSaveRequest(bom, m18HeaderId) | |||
| ?: return M18BomShopSyncTriggerResult(bomId, false, skippedReason = "Cannot build M18 payload (missing m18 UOM/item ids or no materials)") | |||
| val requestJsonPayload = m18BomForShopService.toJson(req) | |||
| var resp: GoodsReceiptNoteResponse? = null | |||
| var callError: Throwable? = null | |||
| try { | |||
| resp = m18BomForShopService.saveBomForShop(req) | |||
| } catch (e: Exception) { | |||
| callError = e | |||
| } | |||
| val responseJsonPayload = when { | |||
| resp != null -> m18BomForShopService.toJson(resp) | |||
| callError != null -> | |||
| runCatching { | |||
| objectMapper.writeValueAsString( | |||
| mapOf( | |||
| "exceptionType" to callError.javaClass.name, | |||
| "message" to (callError.message ?: ""), | |||
| ), | |||
| ) | |||
| }.getOrElse { """{"error":"failed to serialize exception"}""" } | |||
| else -> """{"error":"M18 API returned null"}""" | |||
| } | |||
| val msgSummary = resp?.messages?.joinToString("; ") { it.msgDetail ?: it.msgCode ?: "" }.orEmpty() | |||
| val apiStatus = resp?.status == true | |||
| val recordId = resp?.recordId ?: 0L | |||
| val result = when { | |||
| callError != null -> | |||
| M18BomShopSyncTriggerResult( | |||
| bomId = bomId, | |||
| synced = false, | |||
| skippedReason = callError.message ?: "M18 API call failed", | |||
| status = false, | |||
| messageSummary = callError.message, | |||
| ) | |||
| resp == null -> | |||
| M18BomShopSyncTriggerResult(bomId, false, skippedReason = "M18 API returned null") | |||
| resp.status == true && resp.recordId > 0L -> { | |||
| bom.m18Id = resp.recordId | |||
| bomRepository.saveAndFlush(bom) | |||
| M18BomShopSyncTriggerResult( | |||
| bomId = bomId, | |||
| synced = true, | |||
| recordId = resp.recordId, | |||
| status = true, | |||
| messageSummary = msgSummary.ifBlank { null }, | |||
| ) | |||
| } | |||
| else -> | |||
| M18BomShopSyncTriggerResult( | |||
| bomId = bomId, | |||
| synced = false, | |||
| skippedReason = "M18 save failed or status=false", | |||
| recordId = resp.recordId.takeIf { it > 0 }, | |||
| status = resp.status, | |||
| messageSummary = msgSummary.ifBlank { null }, | |||
| ) | |||
| } | |||
| val logMessage = listOfNotNull( | |||
| msgSummary.ifBlank { null }, | |||
| callError?.message, | |||
| result.skippedReason?.takeIf { !result.synced }, | |||
| ).joinToString("; ").take(4000) | |||
| m18BomShopSyncLogRepository.save( | |||
| M18BomShopSyncLog().apply { | |||
| this.bomId = bomId | |||
| m18RecordId = recordId.takeIf { it > 0 } | |||
| m18ApiStatus = apiStatus | |||
| synced = result.synced | |||
| message = logMessage.ifBlank { null } | |||
| requestJson = requestJsonPayload | |||
| responseJson = responseJsonPayload | |||
| }, | |||
| ) | |||
| return result | |||
| } | |||
| private fun isM18BomShopSyncEnabled(): Boolean = | |||
| settingsService.findByName(SettingNames.M18_BOM_SHOP_SYNC_ENABLED) | |||
| .map { Settings.VALUE_BOOLEAN_TRUE == it.value } | |||
| .orElse(false) | |||
| private fun resolveEquipmentForBomProcess(pReq: EditBomProcessRequest): Equipment { | |||
| val equipmentId = pReq.equipmentId | |||
| val equipmentCode = pReq.equipmentCode?.trim().orEmpty() | |||
| @@ -282,6 +282,20 @@ open class ItemUomService( | |||
| return finalizePreciseStockQty(stockUnit, stockQty) | |||
| } | |||
| /** | |||
| * Convert quantity from [uomId] (must exist on `item_uom` for [itemId]) to the item's **base unit** quantity. | |||
| * Returns null when no `item_uom` row links the item to that UOM. | |||
| */ | |||
| open fun convertQtyToBaseQtyPrecise(itemId: Long, uomId: Long, sourceQty: BigDecimal): BigDecimal? { | |||
| val itemUom = findFirstByItemIdAndUomId(itemId, uomId) ?: return null | |||
| val one = BigDecimal.ONE | |||
| val calcScale = 10 | |||
| return sourceQty | |||
| .multiply(itemUom.ratioN ?: one) | |||
| .divide(itemUom.ratioD ?: one, calcScale, RoundingMode.HALF_UP) | |||
| .stripTrailingZeros() | |||
| } | |||
| // See if need to update the response | |||
| open fun saveItemUom(request: ItemUomRequest): ItemUom { | |||
| val itemUom = request.m18Id?.let { itemUomRespository.findFirstByM18IdOrderByIdDesc(it) } | |||
| @@ -29,6 +29,8 @@ import com.ffii.fpsms.modules.master.web.models.ImportBomRequestPayload | |||
| import com.ffii.fpsms.modules.master.web.models.BomDetailResponse | |||
| import com.ffii.fpsms.modules.master.web.models.EditBomRequest | |||
| import com.ffii.fpsms.modules.master.web.models.BomExcelCheckProgress | |||
| import com.ffii.fpsms.modules.master.web.models.BomIdByItemCodeResponse | |||
| import com.ffii.core.exception.BadRequestException | |||
| import java.util.logging.Logger | |||
| import java.nio.file.Files | |||
| import org.springframework.core.io.FileSystemResource | |||
| @@ -120,6 +122,16 @@ fun downloadBomFormatIssueLog( | |||
| // fun exportProblematicBom() { | |||
| // return bomService.importBOM() | |||
| // } | |||
| /** Testing: FPSMS BOM id by finished-good item code (same item as BOM header). */ | |||
| @GetMapping("/by-item-code") | |||
| fun getBomByItemCode(@RequestParam code: String): BomIdByItemCodeResponse { | |||
| if (code.isBlank()) { | |||
| throw BadRequestException("query parameter code is required") | |||
| } | |||
| return bomService.findBomSummaryByItemCode(code.trim()) | |||
| } | |||
| @GetMapping("/{id}/detail") | |||
| fun getBomDetail(@PathVariable id: Long): BomDetailResponse { | |||
| return bomService.getBomDetail(id) | |||
| @@ -0,0 +1,14 @@ | |||
| package com.ffii.fpsms.modules.master.web.models | |||
| /** | |||
| * Testing / lookup: resolve FPSMS BOM from finished-good [item] code (bom.item → [Items.code]). | |||
| */ | |||
| data class BomIdByItemCodeResponse( | |||
| val itemCode: String, | |||
| val itemId: Long? = null, | |||
| val bomId: Long? = null, | |||
| val bomCode: String? = null, | |||
| val bomM18Id: Long? = null, | |||
| /** e.g. item not found, or no BOM for item */ | |||
| val message: String? = null, | |||
| ) | |||
| @@ -9,7 +9,7 @@ WHERE NOT EXISTS ( | |||
| ); | |||
| INSERT INTO `fpsmsdb`.`settings` (`name`, `value`, `category`, `type`) | |||
| SELECT 'SCHEDULE.m18.do2', '0 0 11 * * *', 'SCHEDULE', 'string' | |||
| SELECT 'SCHEDULE.m18.do2', '0 0 13 * * *', 'SCHEDULE', 'string' | |||
| FROM DUAL | |||
| WHERE NOT EXISTS ( | |||
| SELECT 1 FROM `fpsmsdb`.`settings` WHERE `name` = 'SCHEDULE.m18.do2' | |||
| @@ -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 | |||
| PatternLayout: | |||
| Pattern: "%d %p [%l] - %m%n" | |||
| charset: UTF-8 | |||
| Policies: | |||
| SizeBasedTriggeringPolicy: | |||
| size: 4096KB | |||
| @@ -11,6 +11,7 @@ Configutation: | |||
| filePattern: ${log_location}fpsms-all.log.%i.gz | |||
| PatternLayout: | |||
| Pattern: "%d %p [%l] - %m%n" | |||
| charset: UTF-8 | |||
| Policies: | |||
| SizeBasedTriggeringPolicy: | |||
| size: 4096KB | |||
| @@ -10,6 +10,7 @@ Configutation: | |||
| target: SYSTEM_OUT | |||
| PatternLayout: | |||
| pattern: ${log_pattern} | |||
| charset: UTF-8 | |||
| Loggers: | |||
| Root: | |||
| level: info | |||