From 112adad86329bd44f89eeef17f404e4689a0840c Mon Sep 17 00:00:00 2001 From: Fai Luk Date: Sun, 23 Aug 2026 13:20:10 +0800 Subject: [PATCH] fix for exp date --- python/Bag4.py | 36 ++++++++---- .../service/PlasticBagPrinterService.kt | 56 ++++++++++++------- .../web/PlasticBagPrinterController.kt | 12 +++- .../model/FlexibleExpiryDateDeserializer.kt | 33 +++++++++++ .../web/model/LaserBag2SendRequest.kt | 8 ++- .../jobOrder/service/LaserBag2PayloadTest.kt | 28 ++++++++++ 6 files changed, 140 insertions(+), 33 deletions(-) create mode 100644 src/main/java/com/ffii/fpsms/modules/jobOrder/web/model/FlexibleExpiryDateDeserializer.kt diff --git a/python/Bag4.py b/python/Bag4.py index b5f924f..388d64d 100644 --- a/python/Bag4.py +++ b/python/Bag4.py @@ -3,7 +3,8 @@ Bag4 v4.0 – FPSMS job orders by plan date, with DataFlex expiry under Lot. Uses the public API GET /py/job-orders and POST /py/job-order-print-submit (no login required). -Same as Bag3, plus expiry (today + defaultShelfLifeDays) on DataFlex ZPL and as the 4th laser TCP param. +Same as Bag3, plus expiry (today + defaultShelfLifeDays) on DataFlex ZPL +and as the 4th laser TCP param (EZCAD job must bind that field). Bag3 remains the line without expiry on DataFlex; do not share settings files. @@ -434,6 +435,26 @@ def format_expiry_print_label(d: date) -> str: return f"Expiry Date {d.strftime('%Y%m%d')}" +def _parse_api_expiry_date(raw) -> Optional[date]: + """ISO `yyyy-MM-dd` or Jackson date-array `[yyyy,M,d]` from @EnableWebMvc.""" + if raw is None or raw == "": + return None + if isinstance(raw, (list, tuple)) and len(raw) >= 3: + try: + return date(int(raw[0]), int(raw[1]), int(raw[2])) + except (TypeError, ValueError): + return None + s = str(raw).strip() + if s.startswith("[") and "," in s: + return None + iso = s[:10] + try: + y, m, day = (int(p) for p in iso.split("-")) + return date(y, m, day) + except ValueError: + return None + + def job_expiry_zpl_text(jo: dict) -> Optional[str]: """Expiry yyyyMMdd from print-time shelf life days, else API expiryDate.""" days = jo.get("defaultShelfLifeDays") @@ -443,15 +464,8 @@ def job_expiry_zpl_text(jo: dict) -> Optional[str]: days = int(days) if isinstance(days, int) and days > 0: return format_expiry_print_label(date.today() + timedelta(days=days)) - iso = jo.get("expiryDate") - if iso: - s = str(iso).strip()[:10] - try: - y, m, day = (int(p) for p in s.split("-")) - return format_expiry_print_label(date(y, m, day)) - except ValueError: - return None - return None + parsed = _parse_api_expiry_date(jo.get("expiryDate")) + return format_expiry_print_label(parsed) if parsed else None def job_expiry_laser_param(jo: dict) -> str: @@ -1251,7 +1265,7 @@ def send_job_to_laser( {"itemId": itemId, "stockInLineId": stockInLineId} ; itemCode ; itemName ; [Expiry Date yyyyMMdd] ;; conn_ref: [socket or None] - reused across calls; closed only when switching printer. When both item_id and stock_in_line_id present, sends JSON first param; else fallback: 0;item_code;item_name;; - Expiry is the 4th field when present (same wording as DataFlex). + Expiry is the 4th field when present (same wording as DataFlex). The EZCAD job must bind it. Returns (success, message). """ code_str = (item_code or "").strip().replace(";", ",") 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 42a5cc1..862c8be 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 @@ -120,9 +120,9 @@ class PlasticBagPrinterService( private const val ONPACK_EXPIRY_BMP_HEIGHT = 79 /** - * Bag2/Bag4 laser TCP payload. When [expiryDate] is present: - * `json;itemCode;itemName;Expiry Date yyyyMMdd;;` - * otherwise the original 3-field form `json;itemCode;itemName;;`. + * Laser TCP: `json;itemCode;itemName;;` or, when [expiryDate] is set, + * `json;itemCode;itemName;Expiry Date yyyyMMdd;;`. + * The lemon EZCAD job must bind a 4th text param or it replies invalid. */ fun buildLaserBag2Payload( itemId: Long?, @@ -146,6 +146,9 @@ class PlasticBagPrinterService( } } + fun laserAckLooksInvalid(ack: String?): Boolean = + ack?.contains("invalid", ignoreCase = true) == true + /** ISO `yyyy-MM-dd`, compact `yyyyMMdd`, or already `Expiry Date yyyyMMdd`. */ fun formatLaserExpiryParam(expiryDate: String?): String { val raw = expiryDate?.trim().orEmpty() @@ -286,6 +289,18 @@ class PlasticBagPrinterService( * Bag2.py [send_job_to_laser] / [send_job_to_laser_with_retry]: UTF-8 TCP payload and optional ack read. */ fun sendLaserBag2Job(request: LaserBag2SendRequest): LaserBag2SendResponse { + return try { + sendLaserBag2JobInner(request) + } catch (e: Exception) { + logger.error("sendLaserBag2Job unexpected error", e) + LaserBag2SendResponse( + success = false, + message = "送出失敗:${e.message ?: e.javaClass.simpleName}", + ) + } + } + + private fun sendLaserBag2JobInner(request: LaserBag2SendRequest): LaserBag2SendResponse { val ip = (request.printerIp?.trim()?.takeIf { it.isNotEmpty() } ?: resolveLaserBag2Host()).trim() val port = request.printerPort ?: resolveLaserBag2Port() @@ -298,16 +313,10 @@ class PlasticBagPrinterService( itemName = request.itemName, expiryDate = request.expiryDate, ) - val response = if (first.success) { - LaserBag2SendResponse( - success = true, - message = first.message, - payloadSent = first.payload, - printerAck = first.printerAck, - receiveAcknowledged = first.receiveAcknowledged, - ) + val chosen = if (first.success) { + first } else { - val second = sendLaserBag2TcpOnce( + sendLaserBag2TcpOnce( ip = ip, port = port, itemId = request.itemId, @@ -316,14 +325,14 @@ class PlasticBagPrinterService( itemName = request.itemName, expiryDate = request.expiryDate, ) - LaserBag2SendResponse( - success = second.success, - message = second.message, - payloadSent = second.payload, - printerAck = second.printerAck, - receiveAcknowledged = second.receiveAcknowledged, - ) } + val response = LaserBag2SendResponse( + success = chosen.success, + message = chosen.message, + payloadSent = chosen.payload, + printerAck = chosen.printerAck, + receiveAcknowledged = chosen.receiveAcknowledged, + ) if (response.success && response.receiveAcknowledged) { try { persistLaserLastReceiveSuccess(request, response.printerAck) @@ -436,6 +445,15 @@ class PlasticBagPrinterService( } catch (_: SocketTimeoutException) { // Same as Python Bag3: ignore read timeout, payload was still sent } + if (laserAckLooksInvalid(ackRaw)) { + return LaserBag2TcpResult( + false, + "檸檬機回覆 invalid,未接受指令。", + payload, + ackRaw, + false, + ) + } val msg = if (receiveAck) { "已送出激光機:$payload(已確認)" } else { diff --git a/src/main/java/com/ffii/fpsms/modules/jobOrder/web/PlasticBagPrinterController.kt b/src/main/java/com/ffii/fpsms/modules/jobOrder/web/PlasticBagPrinterController.kt index 9ffd326..e8c51e1 100644 --- a/src/main/java/com/ffii/fpsms/modules/jobOrder/web/PlasticBagPrinterController.kt +++ b/src/main/java/com/ffii/fpsms/modules/jobOrder/web/PlasticBagPrinterController.kt @@ -61,12 +61,20 @@ class PlasticBagPrinterController( } /** - * Bag2/Bag4 laser TCP protocol: `{"itemId":n,"stockInLineId":m};code;name;;` or + * Bag2/Bag4 laser TCP: `{"itemId":n,"stockInLineId":m};code;name;;` or * `{"itemId":n,"stockInLineId":m};code;name;Expiry Date yyyyMMdd;;` when expiryDate is set. */ @PostMapping("/print-laser-bag2") fun printLaserBag2(@RequestBody request: LaserBag2SendRequest): ResponseEntity { - val resp = plasticBagPrinterService.sendLaserBag2Job(request) + val resp = try { + plasticBagPrinterService.sendLaserBag2Job(request) + } catch (e: Exception) { + logger.error("print-laser-bag2 failed", e) + LaserBag2SendResponse( + success = false, + message = "送出失敗:${e.message ?: e.javaClass.simpleName}", + ) + } return if (resp.success) { ResponseEntity.ok(resp) } else { diff --git a/src/main/java/com/ffii/fpsms/modules/jobOrder/web/model/FlexibleExpiryDateDeserializer.kt b/src/main/java/com/ffii/fpsms/modules/jobOrder/web/model/FlexibleExpiryDateDeserializer.kt new file mode 100644 index 0000000..503aff0 --- /dev/null +++ b/src/main/java/com/ffii/fpsms/modules/jobOrder/web/model/FlexibleExpiryDateDeserializer.kt @@ -0,0 +1,33 @@ +package com.ffii.fpsms.modules.jobOrder.web.model + +import com.fasterxml.jackson.core.JsonParser +import com.fasterxml.jackson.core.JsonToken +import com.fasterxml.jackson.databind.DeserializationContext +import com.fasterxml.jackson.databind.JsonDeserializer + +/** + * Accepts `yyyy-MM-dd`, compact `yyyyMMdd`, `Expiry Date yyyyMMdd`, + * or Jackson date-array `[2026,8,27]` (happens when `@EnableWebMvc` uses a raw ObjectMapper). + */ +class FlexibleExpiryDateDeserializer : JsonDeserializer() { + override fun deserialize(p: JsonParser, ctxt: DeserializationContext): String? { + return when (p.currentToken) { + JsonToken.VALUE_NULL -> null + JsonToken.VALUE_STRING -> p.valueAsString?.trim()?.takeIf { it.isNotEmpty() } + JsonToken.VALUE_NUMBER_INT -> p.valueAsString + JsonToken.START_ARRAY -> { + val y = p.nextIntValue(0) + val m = p.nextIntValue(0) + val d = p.nextIntValue(0) + while (p.nextToken() != JsonToken.END_ARRAY && p.currentToken != null) { + // skip extra tokens (e.g. nanoseconds on a datetime array) + } + if (y <= 0 || m <= 0 || d <= 0) null else "%04d-%02d-%02d".format(y, m, d) + } + else -> { + p.skipChildren() + null + } + } + } +} diff --git a/src/main/java/com/ffii/fpsms/modules/jobOrder/web/model/LaserBag2SendRequest.kt b/src/main/java/com/ffii/fpsms/modules/jobOrder/web/model/LaserBag2SendRequest.kt index 370b957..c4d30e0 100644 --- a/src/main/java/com/ffii/fpsms/modules/jobOrder/web/model/LaserBag2SendRequest.kt +++ b/src/main/java/com/ffii/fpsms/modules/jobOrder/web/model/LaserBag2SendRequest.kt @@ -1,13 +1,18 @@ package com.ffii.fpsms.modules.jobOrder.web.model +import com.fasterxml.jackson.annotation.JsonIgnoreProperties +import com.fasterxml.jackson.databind.annotation.JsonDeserialize + /** * Body for Bag2/Bag4-style laser TCP send: `json;itemCode;itemName;;` or * `json;itemCode;itemName;Expiry Date yyyyMMdd;;` when [expiryDate] is set (UTF-8). + * The lemon EZCAD job must bind the 4th `;` field or it replies invalid. * Optional [printerIp] / [printerPort] override system settings [LASER_PRINT.host] / [LASER_PRINT.port]. * * Optional job metadata is used to persist [com.ffii.fpsms.modules.common.SettingNames.LASER_PRINT_LAST_RECEIVE_SUCCESS] * when the printer returns a receive ack. */ +@JsonIgnoreProperties(ignoreUnknown = true) data class LaserBag2SendRequest( val itemId: Long? = null, val stockInLineId: Long? = null, @@ -20,8 +25,9 @@ data class LaserBag2SendRequest( val lotNo: String? = null, /** * Print-time expiry from the job-order list (`yyyy-MM-dd`, compact `yyyyMMdd`, - * or already `Expiry Date yyyyMMdd`). Sent as the 4th TCP field. + * Jackson `[yyyy,M,d]`, or already `Expiry Date yyyyMMdd`). Sent as the 4th TCP field. */ + @JsonDeserialize(using = FlexibleExpiryDateDeserializer::class) val expiryDate: String? = null, /** AUTO (auto-send) or MANUAL (/laserPrint); optional. */ val source: String? = null, diff --git a/src/test/kotlin/com/ffii/fpsms/modules/jobOrder/service/LaserBag2PayloadTest.kt b/src/test/kotlin/com/ffii/fpsms/modules/jobOrder/service/LaserBag2PayloadTest.kt index 376fb3d..fe9ce57 100644 --- a/src/test/kotlin/com/ffii/fpsms/modules/jobOrder/service/LaserBag2PayloadTest.kt +++ b/src/test/kotlin/com/ffii/fpsms/modules/jobOrder/service/LaserBag2PayloadTest.kt @@ -1,6 +1,9 @@ package com.ffii.fpsms.modules.jobOrder.service +import com.fasterxml.jackson.module.kotlin.jacksonObjectMapper +import com.ffii.fpsms.modules.jobOrder.web.model.LaserBag2SendRequest import org.junit.jupiter.api.Assertions.assertEquals +import org.junit.jupiter.api.Assertions.assertNull import org.junit.jupiter.api.Test class LaserBag2PayloadTest { @@ -25,6 +28,31 @@ class LaserBag2PayloadTest { ) } + @Test + fun laserAckLooksInvalid_detects_plugin_reject() { + assertEquals(false, PlasticBagPrinterService.laserAckLooksInvalid(null)) + assertEquals(false, PlasticBagPrinterService.laserAckLooksInvalid("receive;;")) + assertEquals(true, PlasticBagPrinterService.laserAckLooksInvalid("errorinvalid data")) + assertEquals(true, PlasticBagPrinterService.laserAckLooksInvalid("INVALID")) + } + + @Test + fun sendRequest_accepts_expiry_as_iso_or_jackson_array() { + val mapper = jacksonObjectMapper() + val fromIso = mapper.readValue( + """{"expiryDate":"2026-08-27","source":"MANUAL"}""", + LaserBag2SendRequest::class.java, + ) + assertEquals("2026-08-27", fromIso.expiryDate) + val fromArray = mapper.readValue( + """{"expiryDate":[2026,8,27],"source":"MANUAL"}""", + LaserBag2SendRequest::class.java, + ) + assertEquals("2026-08-27", fromArray.expiryDate) + val missing = mapper.readValue("{}", LaserBag2SendRequest::class.java) + assertNull(missing.expiryDate) + } + @Test fun formatLaserExpiryParam_accepts_iso_compact_and_print_label() { assertEquals("Expiry Date 20260821", PlasticBagPrinterService.formatLaserExpiryParam("2026-08-21"))