diff --git a/python/Bag3.py b/python/Bag3.py index 63b5d83..887c401 100644 --- a/python/Bag3.py +++ b/python/Bag3.py @@ -16,6 +16,7 @@ Bag2 is kept as a separate legacy v2.x line; do not assume Bag2 matches Bag3. Run: python Bag3.py """ +import errno import json import os import select @@ -344,6 +345,25 @@ DATAFLEX_UI_PROGRESS_EVERY = max( DATAFLEX_SINGLE_TCP_JOB = _dataflex_bool_env( "FPSMS_DATAFLEX_SINGLE_TCP_JOB", False ) +# Link-OS SGD: raw-ZPL job shows this host id instead of a generic name (e.g. "ZPL. EMULATION"). +# Same id when the same job order is printed again. Disable: FPSMS_DATAFLEX_HOST_IDENTIFICATION_SGD=0 +DATAFLEX_HOST_IDENTIFICATION_SGD = _dataflex_bool_env( + "FPSMS_DATAFLEX_HOST_IDENTIFICATION_SGD", True +) +# Bag ZPL size (dots). ^PW700 matched little content (mostly vertical ^A@R), so previews showed a wide strip with empty right margin. +DATAFLEX_LABEL_PW = max( + 280, + _dataflex_int_env("FPSMS_DATAFLEX_LABEL_PW", 400), +) +DATAFLEX_LABEL_LL = max( + 200, + _dataflex_int_env("FPSMS_DATAFLEX_LABEL_LL", 500), +) +# Some Zebra/DataFlex units RST the socket on host half-close; Windows surfaces WinError 10054. +# Set FPSMS_DATAFLEX_SKIP_SHUTDOWN_WR=1 to omit shutdown(SHUT_WR) and only close() (often avoids RST). +DATAFLEX_SKIP_SHUTDOWN_WR = _dataflex_bool_env( + "FPSMS_DATAFLEX_SKIP_SHUTDOWN_WR", False +) # Full recovery (~JR soft reset) — used by「打袋重設」only; longer delay for firmware DATAFLEX_POST_FULL_RECOVERY_DELAY_SEC = 1.2 # Zebra ~RO only (used when FPSMS_DATAFLEX_NO_JR is set for full recovery) @@ -364,12 +384,56 @@ def _zpl_escape(s: str) -> str: return s.replace("\\", "\\\\").replace("^", "\\^") +def _dataflex_host_identification_sgd_prefix(job_order_id: Optional[int]) -> str: + """ + Optional ASCII prefix before ^XA: set zpl.host_identification so the printer lists the job + under the job order id instead of a generic raw-ZPL label. + """ + if not DATAFLEX_HOST_IDENTIFICATION_SGD or job_order_id is None: + return "" + try: + jid = str(int(job_order_id)) + except (TypeError, ValueError): + return "" + if not jid.isdigit(): + return "" + return f'! U1 setvar "zpl.host_identification" "{jid}"\r\n' + + def _dataflex_zpl_bytes(zpl: str) -> bytes: """UTF-8 ZPL with one trailing CRLF so the printer sees a clear job boundary.""" s = (zpl or "").rstrip("\r\n") return (s + "\r\n").encode("utf-8") +def _dataflex_is_benign_tcp_reset(err: BaseException) -> bool: + """True when peer closed with RST/FIN in a way that is normal for raw printer TCP (Windows 10054).""" + if isinstance(err, (BrokenPipeError, ConnectionResetError, ConnectionAbortedError)): + return True + if isinstance(err, OSError): + if getattr(err, "winerror", None) == 10054: # WSAECONNRESET + return True + if err.errno in ( + errno.ECONNRESET, + errno.EPIPE, + errno.ECONNABORTED, + ): + return True + return False + + +def _dataflex_shutdown_write_maybe(sock: socket.socket) -> None: + """Half-close write side; ignore printer RST (common after ZPL on port 9100-style links).""" + if DATAFLEX_SKIP_SHUTDOWN_WR: + return + try: + sock.shutdown(socket.SHUT_WR) + except OSError as e: + if _dataflex_is_benign_tcp_reset(e): + return + raise + + def generate_zpl_dataflex( batch_no: str, item_code: str, @@ -377,6 +441,7 @@ def generate_zpl_dataflex( item_id: Optional[int] = None, stock_in_line_id: Optional[int] = None, lot_no: Optional[str] = None, + job_order_id: Optional[int] = None, font_regular: str = "E:STXihei.ttf", font_bold: str = "E:STXihei.ttf", ) -> str: @@ -398,11 +463,12 @@ def generate_zpl_dataflex( qr_value = _zpl_escape(qr_payload) # Explicit ^PQ1: each ^XA…^XZ is exactly one bag. Avoids E1005 "over quantity" on some Zebra/DataFlex # firmware when many labels are sent on one TCP session without a per-job quantity. - return f"""^XA + host_id = _dataflex_host_identification_sgd_prefix(job_order_id) + return host_id + f"""^XA ^PQ1,0,1,N ^CI28 -^PW700 -^LL500 +^PW{DATAFLEX_LABEL_PW} +^LL{DATAFLEX_LABEL_LL} ^PO N ^FO10,20 ^BQN,2,4^FDQA,{qr_value}^FS @@ -447,10 +513,7 @@ def send_dataflex_preprint_reset(ip: str, port: int, *, force: bool = False) -> sock.connect((ip, port)) sock.sendall(DATAFLEX_PREPRINT_BYTES) time.sleep(DATAFLEX_POST_PREPRINT_DELAY_SEC) - try: - sock.shutdown(socket.SHUT_WR) - except OSError: - pass + _dataflex_shutdown_write_maybe(sock) finally: sock.close() @@ -472,10 +535,7 @@ def send_dataflex_job_counter_reset(ip: str, port: int, *, force: bool = False) sock.connect((ip, port)) sock.sendall(_dataflex_full_recovery_payload()) time.sleep(DATAFLEX_POST_FULL_RECOVERY_DELAY_SEC) - try: - sock.shutdown(socket.SHUT_WR) - except OSError: - pass + _dataflex_shutdown_write_maybe(sock) finally: sock.close() @@ -527,10 +587,7 @@ def send_dataflex_reset_and_labels( time.sleep(DATAFLEX_POST_LABEL_SETTLE_SEC) if i < copies - 1: time.sleep(delay_sec) - try: - sock.shutdown(socket.SHUT_WR) - except OSError: - pass + _dataflex_shutdown_write_maybe(sock) finally: sock.close() @@ -879,10 +936,7 @@ def send_zpl_to_dataflex(ip: str, port: int, zpl: str) -> None: sock.connect((ip, port)) sock.sendall(_dataflex_zpl_bytes(zpl)) time.sleep(DATAFLEX_POST_LABEL_SETTLE_SEC) - try: - sock.shutdown(socket.SHUT_WR) - except OSError: - pass + _dataflex_shutdown_write_maybe(sock) finally: sock.close() @@ -907,6 +961,10 @@ def query_dataflex_host_status(ip: str, port: int) -> str: data = sock.recv(4096) except socket.timeout: break + except OSError as ex: + if _dataflex_is_benign_tcp_reset(ex): + break + raise if not data: break chunks.append(data) @@ -2451,6 +2509,7 @@ def main() -> None: item_id=item_id, stock_in_line_id=stock_in_line_id, lot_no=lot_no, + job_order_id=j.get("id"), ) label_text = (lot_no or b).strip() if continuous: diff --git a/python/__pycache__/Bag3.cpython-313.pyc b/python/__pycache__/Bag3.cpython-313.pyc new file mode 100644 index 0000000..a01ec70 Binary files /dev/null and b/python/__pycache__/Bag3.cpython-313.pyc differ diff --git a/src/main/java/com/ffii/fpsms/m18/entity/M18BomShopSyncLog.kt b/src/main/java/com/ffii/fpsms/m18/entity/M18BomShopSyncLog.kt new file mode 100644 index 0000000..5d85df5 --- /dev/null +++ b/src/main/java/com/ffii/fpsms/m18/entity/M18BomShopSyncLog.kt @@ -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() { + + @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 +} diff --git a/src/main/java/com/ffii/fpsms/m18/entity/M18BomShopSyncLogRepository.kt b/src/main/java/com/ffii/fpsms/m18/entity/M18BomShopSyncLogRepository.kt new file mode 100644 index 0000000..bf6c04c --- /dev/null +++ b/src/main/java/com/ffii/fpsms/m18/entity/M18BomShopSyncLogRepository.kt @@ -0,0 +1,5 @@ +package com.ffii.fpsms.m18.entity + +import com.ffii.core.support.AbstractRepository + +interface M18BomShopSyncLogRepository : AbstractRepository diff --git a/src/main/java/com/ffii/fpsms/m18/model/M18BomForShopSaveRequest.kt b/src/main/java/com/ffii/fpsms/m18/model/M18BomForShopSaveRequest.kt new file mode 100644 index 0000000..7b83edc --- /dev/null +++ b/src/main/java/com/ffii/fpsms/m18/model/M18BomForShopSaveRequest.kt @@ -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, +) + +/** + * 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, +) + +/** + * 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, +) diff --git a/src/main/java/com/ffii/fpsms/m18/model/M18BomShopSyncTriggerResult.kt b/src/main/java/com/ffii/fpsms/m18/model/M18BomShopSyncTriggerResult.kt new file mode 100644 index 0000000..8f78e08 --- /dev/null +++ b/src/main/java/com/ffii/fpsms/m18/model/M18BomShopSyncTriggerResult.kt @@ -0,0 +1,14 @@ +package com.ffii.fpsms.m18.model + +/** + * Result of [com.ffii.fpsms.modules.master.service.BomService.pushBomToM18ShopIfAllowed] + * (e.g. POST /m18/test/bom-shop-sync/{bomId}). + */ +data class M18BomShopSyncTriggerResult( + val bomId: Long, + val synced: Boolean, + val skippedReason: String? = null, + val recordId: Long? = null, + val status: Boolean? = null, + val messageSummary: String? = null, +) diff --git a/src/main/java/com/ffii/fpsms/m18/service/M18BomForShopService.kt b/src/main/java/com/ffii/fpsms/m18/service/M18BomForShopService.kt new file mode 100644 index 0000000..4ab7dbf --- /dev/null +++ b/src/main/java/com/ffii/fpsms/m18/service/M18BomForShopService.kt @@ -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 { + 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 { + val queryParams = LinkedMultiValueMap().apply { + add("menuCode", menuCode) + } + val qs = queryParams.entries.flatMap { (k, v) -> v.map { "$k=$it" } }.joinToString("&") + val fullUrl = "${m18Config.BASE_URL}$savePath?$qs" + val bodyJson = objectMapper.writeValueAsString(request) + logger.info("[M18 BOM udfBomForShop] PUT url=$fullUrl bodyUtf8Bytes=${bodyJson.toByteArray(StandardCharsets.UTF_8).size}") + logger.debug("[M18 BOM udfBomForShop] PUT body=$bodyJson") + return apiCallerService.putWithJsonString( + urlPath = savePath, + queryParams = queryParams, + bodyJson = bodyJson, + ).doOnSuccess { r -> + logger.info("[M18 BOM udfBomForShop] response status=${r.status} recordId=${r.recordId} messages=${r.messages}") + }.doOnError { e -> + logger.error("[M18 BOM udfBomForShop] failed: ${e.message}", e) + } + } +} diff --git a/src/main/java/com/ffii/fpsms/m18/web/M18TestController.kt b/src/main/java/com/ffii/fpsms/m18/web/M18TestController.kt index 5fbafb9..4da2df9 100644 --- a/src/main/java/com/ffii/fpsms/m18/web/M18TestController.kt +++ b/src/main/java/com/ffii/fpsms/m18/web/M18TestController.kt @@ -4,8 +4,9 @@ import com.ffii.core.utils.JwtTokenUtil import com.ffii.fpsms.m18.M18Config import com.ffii.fpsms.m18.model.SyncResult import com.ffii.fpsms.m18.service.* +import com.ffii.fpsms.m18.model.M18BomShopSyncTriggerResult import com.ffii.fpsms.m18.web.models.M18CommonRequest -import com.ffii.fpsms.modules.common.SettingNames +import com.ffii.fpsms.modules.master.service.BomService import com.ffii.fpsms.modules.common.scheduler.service.SchedulerService import com.ffii.fpsms.modules.master.entity.ItemUom import com.ffii.fpsms.modules.master.entity.Items @@ -35,6 +36,7 @@ class M18TestController ( private val m18DeliveryOrderService: M18DeliveryOrderService, val schedulerService: SchedulerService, private val settingsService: SettingsService, + private val bomService: BomService, ) { var logger: Logger = LoggerFactory.getLogger(JwtTokenUtil::class.java) @@ -65,6 +67,14 @@ class M18TestController ( return schedulerService.getM18Pos(); } + @PostMapping("/test/bom-shop-sync/{bomId}") + fun testBomShopSync( + @PathVariable bomId: Long, + @RequestParam(required = false) m18HeaderId: Long?, + ): M18BomShopSyncTriggerResult { + return bomService.pushBomToM18ShopIfAllowed(bomId, m18HeaderId) + } + @GetMapping("/test/po-by-code") fun testSyncPoByCode(@RequestParam code: String): SyncResult { return m18PurchaseOrderService.savePurchaseOrderByCode(code) diff --git a/src/main/java/com/ffii/fpsms/modules/common/SettingNames.java b/src/main/java/com/ffii/fpsms/modules/common/SettingNames.java index 61cb2b7..e0fa020 100644 --- a/src/main/java/com/ffii/fpsms/modules/common/SettingNames.java +++ b/src/main/java/com/ffii/fpsms/modules/common/SettingNames.java @@ -41,6 +41,11 @@ public abstract class SettingNames { */ public static final String M18_UNITS_SYNC_INITIAL_FULL_SYNC_DONE = "M18.units.sync.initialFullSyncDone"; + /** + * When "true", FPSMS may push BOM header + materials to M18 udfBomForShop. + */ + public static final String M18_BOM_SHOP_SYNC_ENABLED = "M18.bom.shop.sync.enabled"; + /** Post completed DN and process M18 GRN (cron, e.g. "0 40 23 * * *" for 23:40 daily) */ public static final String SCHEDULE_POST_COMPLETED_DN_GRN = "SCHEDULE.postCompletedDn.grn"; diff --git a/src/main/java/com/ffii/fpsms/modules/common/scheduler/service/SchedulerService.kt b/src/main/java/com/ffii/fpsms/modules/common/scheduler/service/SchedulerService.kt index ba3b879..beaabe3 100644 --- a/src/main/java/com/ffii/fpsms/modules/common/scheduler/service/SchedulerService.kt +++ b/src/main/java/com/ffii/fpsms/modules/common/scheduler/service/SchedulerService.kt @@ -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( diff --git a/src/main/java/com/ffii/fpsms/modules/jobOrder/service/PlasticBagPrinterService.kt b/src/main/java/com/ffii/fpsms/modules/jobOrder/service/PlasticBagPrinterService.kt index fcad435..78dcb59 100644 --- a/src/main/java/com/ffii/fpsms/modules/jobOrder/service/PlasticBagPrinterService.kt +++ b/src/main/java/com/ffii/fpsms/modules/jobOrder/service/PlasticBagPrinterService.kt @@ -1367,15 +1367,18 @@ class PlasticBagPrinterService( } val qrValue = zplEscape(qrPayload) - // Must match python Bag2.py generate_zpl_dataflex() + // Must match python Bag3.py generate_zpl_dataflex() field layout / fonts. val fontRegular = "E:STXihei.ttf" val fontBold = "E:STXihei.ttf" + // Match python Bag3.py DataFlex defaults: narrower ^PW so job preview is not mostly empty on the right (^A@R fields are tall, not wide). + val labelPw = 400 + val labelLl = 500 return """ ^XA ^CI28 - ^PW700 - ^LL500 + ^PW$labelPw + ^LL$labelLl ^PO N ^FO10,20 ^BQN,2,4^FDQA,$qrValue^FS diff --git a/src/main/java/com/ffii/fpsms/modules/master/service/BomService.kt b/src/main/java/com/ffii/fpsms/modules/master/service/BomService.kt index 47bc322..efa8f93 100644 --- a/src/main/java/com/ffii/fpsms/modules/master/service/BomService.kt +++ b/src/main/java/com/ffii/fpsms/modules/master/service/BomService.kt @@ -34,6 +34,15 @@ import java.util.Comparator import com.ffii.fpsms.modules.jobOrder.entity.JobOrderRepository import com.ffii.fpsms.modules.productProcess.entity.ProductProcessRepository import org.springframework.transaction.annotation.Transactional +import com.fasterxml.jackson.databind.ObjectMapper +import com.ffii.fpsms.m18.service.M18BomForShopService +import com.ffii.fpsms.m18.model.M18BomShopSyncTriggerResult +import com.ffii.fpsms.m18.model.GoodsReceiptNoteResponse +import com.ffii.fpsms.m18.entity.M18BomShopSyncLog +import com.ffii.fpsms.m18.entity.M18BomShopSyncLogRepository +import com.ffii.fpsms.modules.common.SettingNames +import com.ffii.fpsms.modules.settings.entity.Settings +import com.ffii.fpsms.modules.settings.service.SettingsService @Service open class BomService( @@ -52,6 +61,10 @@ open class BomService( private val itemUomService: ItemUomService, private val jobOrderRepository: JobOrderRepository, private val productProcessRepository: ProductProcessRepository, + private val m18BomForShopService: M18BomForShopService, + private val m18BomShopSyncLogRepository: M18BomShopSyncLogRepository, + private val objectMapper: ObjectMapper, + private val settingsService: SettingsService, @Value("\${bom.import.temp-dir:\${java.io.tmpdir}/fpsms-bom-import}") private val bomImportTempDir: String, ) { open fun uploadBomFiles(files: List): BomUploadResponse { @@ -119,6 +132,29 @@ open class BomService( ?: bomRepository.findAllByItemIdAndDeletedIsFalse(itemId).firstOrNull() } + /** Resolve BOM header for a finished-good item code ([Items.code] on [Bom.item]). */ + open fun findBomSummaryByItemCode(itemCodeTrimmed: String): BomIdByItemCodeResponse { + val code = itemCodeTrimmed.trim() + val item = itemsRepository.findByCodeAndDeletedFalse(code) + ?: return BomIdByItemCodeResponse( + itemCode = code, + message = "Item not found for code", + ) + val bom = findByItemId(item.id!!) + ?: return BomIdByItemCodeResponse( + itemCode = code, + itemId = item.id, + message = "No BOM linked to this item", + ) + return BomIdByItemCodeResponse( + itemCode = code, + itemId = item.id, + bomId = bom.id, + bomCode = bom.code, + bomM18Id = bom.m18Id, + ) + } + open fun saveBom(request: SaveBomRequest): SaveBomResponse { val item = request.code.let { itemsService.findByM18BomCode(it) } ?: request.itemId?.let { itemsService.findById(it) } @@ -371,6 +407,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() diff --git a/src/main/java/com/ffii/fpsms/modules/master/service/ItemUomService.kt b/src/main/java/com/ffii/fpsms/modules/master/service/ItemUomService.kt index adc2179..275b80c 100644 --- a/src/main/java/com/ffii/fpsms/modules/master/service/ItemUomService.kt +++ b/src/main/java/com/ffii/fpsms/modules/master/service/ItemUomService.kt @@ -282,6 +282,20 @@ open class ItemUomService( return finalizePreciseStockQty(stockUnit, stockQty) } + /** + * Convert quantity from [uomId] (must exist on `item_uom` for [itemId]) to the item's **base unit** quantity. + * Returns null when no `item_uom` row links the item to that UOM. + */ + open fun convertQtyToBaseQtyPrecise(itemId: Long, uomId: Long, sourceQty: BigDecimal): BigDecimal? { + val itemUom = findFirstByItemIdAndUomId(itemId, uomId) ?: return null + val one = BigDecimal.ONE + val calcScale = 10 + return sourceQty + .multiply(itemUom.ratioN ?: one) + .divide(itemUom.ratioD ?: one, calcScale, RoundingMode.HALF_UP) + .stripTrailingZeros() + } + // See if need to update the response open fun saveItemUom(request: ItemUomRequest): ItemUom { val itemUom = request.m18Id?.let { itemUomRespository.findFirstByM18IdOrderByIdDesc(it) } diff --git a/src/main/java/com/ffii/fpsms/modules/master/web/BomController.kt b/src/main/java/com/ffii/fpsms/modules/master/web/BomController.kt index 79be196..5e97b94 100644 --- a/src/main/java/com/ffii/fpsms/modules/master/web/BomController.kt +++ b/src/main/java/com/ffii/fpsms/modules/master/web/BomController.kt @@ -29,6 +29,8 @@ import com.ffii.fpsms.modules.master.web.models.ImportBomRequestPayload import com.ffii.fpsms.modules.master.web.models.BomDetailResponse import com.ffii.fpsms.modules.master.web.models.EditBomRequest import com.ffii.fpsms.modules.master.web.models.BomExcelCheckProgress +import com.ffii.fpsms.modules.master.web.models.BomIdByItemCodeResponse +import com.ffii.core.exception.BadRequestException import java.util.logging.Logger import java.nio.file.Files import org.springframework.core.io.FileSystemResource @@ -120,6 +122,16 @@ fun downloadBomFormatIssueLog( // fun exportProblematicBom() { // return bomService.importBOM() // } + + /** Testing: FPSMS BOM id by finished-good item code (same item as BOM header). */ + @GetMapping("/by-item-code") + fun getBomByItemCode(@RequestParam code: String): BomIdByItemCodeResponse { + if (code.isBlank()) { + throw BadRequestException("query parameter code is required") + } + return bomService.findBomSummaryByItemCode(code.trim()) + } + @GetMapping("/{id}/detail") fun getBomDetail(@PathVariable id: Long): BomDetailResponse { return bomService.getBomDetail(id) diff --git a/src/main/java/com/ffii/fpsms/modules/master/web/models/BomIdByItemCodeResponse.kt b/src/main/java/com/ffii/fpsms/modules/master/web/models/BomIdByItemCodeResponse.kt new file mode 100644 index 0000000..da7f821 --- /dev/null +++ b/src/main/java/com/ffii/fpsms/modules/master/web/models/BomIdByItemCodeResponse.kt @@ -0,0 +1,14 @@ +package com.ffii.fpsms.modules.master.web.models + +/** + * Testing / lookup: resolve FPSMS BOM from finished-good [item] code (bom.item → [Items.code]). + */ +data class BomIdByItemCodeResponse( + val itemCode: String, + val itemId: Long? = null, + val bomId: Long? = null, + val bomCode: String? = null, + val bomM18Id: Long? = null, + /** e.g. item not found, or no BOM for item */ + val message: String? = null, +) diff --git a/src/main/resources/db/changelog/changes/20260118_fai/01_insert_scheduler.sql b/src/main/resources/db/changelog/changes/20260118_fai/01_insert_scheduler.sql index 0b3cee9..ad4287b 100644 --- a/src/main/resources/db/changelog/changes/20260118_fai/01_insert_scheduler.sql +++ b/src/main/resources/db/changelog/changes/20260118_fai/01_insert_scheduler.sql @@ -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' diff --git a/src/main/resources/db/changelog/changes/20260512_fai/01_m18_bom_shop_sync_settings.sql b/src/main/resources/db/changelog/changes/20260512_fai/01_m18_bom_shop_sync_settings.sql new file mode 100644 index 0000000..cf1102c --- /dev/null +++ b/src/main/resources/db/changelog/changes/20260512_fai/01_m18_bom_shop_sync_settings.sql @@ -0,0 +1,20 @@ +--liquibase formatted sql +--changeset fai:20260512_m18_bom_shop_sync_settings + +INSERT INTO `settings` (`name`, `value`, `category`, `type`) +SELECT v.name, v.value, v.category, v.type +FROM ( + SELECT 'M18.bom.shop.sync.enabled' AS name, 'false' AS value, 'M18' AS category, 'boolean' AS type +) v +WHERE NOT EXISTS ( + SELECT 1 FROM `settings` s WHERE s.name = 'M18.bom.shop.sync.enabled' +); + +INSERT INTO `settings` (`name`, `value`, `category`, `type`) +SELECT v.name, v.value, v.category, v.type +FROM ( + SELECT 'M18.bom.shop.sync.allowedBomIds' AS name, '78,274' AS value, 'M18' AS category, 'string' AS type +) v +WHERE NOT EXISTS ( + SELECT 1 FROM `settings` s WHERE s.name = 'M18.bom.shop.sync.allowedBomIds' +); diff --git a/src/main/resources/db/changelog/changes/20260512_fai/02_m18_bom_shop_sync_log.sql b/src/main/resources/db/changelog/changes/20260512_fai/02_m18_bom_shop_sync_log.sql new file mode 100644 index 0000000..e0e1805 --- /dev/null +++ b/src/main/resources/db/changelog/changes/20260512_fai/02_m18_bom_shop_sync_log.sql @@ -0,0 +1,24 @@ +--liquibase formatted sql +--changeset fai:20260512_m18_bom_shop_sync_log + +CREATE TABLE IF NOT EXISTS `m18_bom_shop_sync_log` ( + `id` BIGINT NOT NULL AUTO_INCREMENT, + `created` DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP, + `createdBy` VARCHAR(30) NULL DEFAULT NULL, + `version` INT NOT NULL DEFAULT '0', + `modified` DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP, + `modifiedBy` VARCHAR(30) NULL DEFAULT NULL, + `deleted` TINYINT(1) NOT NULL DEFAULT '0', + + `bom_id` BIGINT NOT NULL COMMENT 'FPSMS bom.id', + `m18_record_id` BIGINT NULL DEFAULT NULL COMMENT 'M18 udfBomForShop record id when returned', + `m18_api_status` TINYINT(1) NOT NULL COMMENT 'M18 response status field', + `synced` TINYINT(1) NOT NULL COMMENT 'FPSMS treat as success (e.g. updated bom.m18Id)', + `message` VARCHAR(4000) NULL DEFAULT NULL COMMENT 'Summary / errors', + `request_json` LONGTEXT NULL COMMENT 'PUT body sent to M18', + `response_json` LONGTEXT NULL COMMENT 'Parsed M18 response or error JSON', + + CONSTRAINT pk_m18_bom_shop_sync_log PRIMARY KEY (`id`), + KEY `idx_m18_bom_shop_sync_log_bom_id` (`bom_id`), + KEY `idx_m18_bom_shop_sync_log_created` (`created`) +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci; diff --git a/src/main/resources/db/changelog/changes/20260513_do2_1pm/01_update_do2_schedule_to_13.sql b/src/main/resources/db/changelog/changes/20260513_do2_1pm/01_update_do2_schedule_to_13.sql new file mode 100644 index 0000000..0c849bb --- /dev/null +++ b/src/main/resources/db/changelog/changes/20260513_do2_1pm/01_update_do2_schedule_to_13.sql @@ -0,0 +1,4 @@ +--liquibase formatted sql +--changeset fpsms:20260513_do2_schedule_1pm + +UPDATE `settings` SET `value` = '0 0 13 * * *' WHERE `name` = 'SCHEDULE.m18.do2'; diff --git a/src/main/resources/log4j2-prod-linux.yml b/src/main/resources/log4j2-prod-linux.yml index c90a291..f19e1f1 100644 --- a/src/main/resources/log4j2-prod-linux.yml +++ b/src/main/resources/log4j2-prod-linux.yml @@ -11,6 +11,7 @@ Configutation: filePattern: ${log_location}fpsms-all.log.%i.gz PatternLayout: Pattern: "%d %p [%l] - %m%n" + charset: UTF-8 Policies: SizeBasedTriggeringPolicy: size: 4096KB diff --git a/src/main/resources/log4j2-prod-win.yml b/src/main/resources/log4j2-prod-win.yml index d4f8a75..3152168 100644 --- a/src/main/resources/log4j2-prod-win.yml +++ b/src/main/resources/log4j2-prod-win.yml @@ -11,6 +11,7 @@ Configutation: filePattern: ${log_location}fpsms-all.log.%i.gz PatternLayout: Pattern: "%d %p [%l] - %m%n" + charset: UTF-8 Policies: SizeBasedTriggeringPolicy: size: 4096KB diff --git a/src/main/resources/log4j2.yml b/src/main/resources/log4j2.yml index e3b20ff..8977b61 100644 --- a/src/main/resources/log4j2.yml +++ b/src/main/resources/log4j2.yml @@ -10,6 +10,7 @@ Configutation: target: SYSTEM_OUT PatternLayout: pattern: ${log_pattern} + charset: UTF-8 Loggers: Root: level: info