| @@ -3,7 +3,8 @@ | |||||
| Bag4 v4.0 – FPSMS job orders by plan date, with DataFlex expiry under Lot. | 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). | 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. | 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')}" | 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]: | def job_expiry_zpl_text(jo: dict) -> Optional[str]: | ||||
| """Expiry yyyyMMdd from print-time shelf life days, else API expiryDate.""" | """Expiry yyyyMMdd from print-time shelf life days, else API expiryDate.""" | ||||
| days = jo.get("defaultShelfLifeDays") | days = jo.get("defaultShelfLifeDays") | ||||
| @@ -443,15 +464,8 @@ def job_expiry_zpl_text(jo: dict) -> Optional[str]: | |||||
| days = int(days) | days = int(days) | ||||
| if isinstance(days, int) and days > 0: | if isinstance(days, int) and days > 0: | ||||
| return format_expiry_print_label(date.today() + timedelta(days=days)) | 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: | 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] ;; | {"itemId": itemId, "stockInLineId": stockInLineId} ; itemCode ; itemName ; [Expiry Date yyyyMMdd] ;; | ||||
| conn_ref: [socket or None] - reused across calls; closed only when switching printer. | 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;; | 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). | Returns (success, message). | ||||
| """ | """ | ||||
| code_str = (item_code or "").strip().replace(";", ",") | code_str = (item_code or "").strip().replace(";", ",") | ||||
| @@ -120,9 +120,9 @@ class PlasticBagPrinterService( | |||||
| private const val ONPACK_EXPIRY_BMP_HEIGHT = 79 | 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( | fun buildLaserBag2Payload( | ||||
| itemId: Long?, | 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`. */ | /** ISO `yyyy-MM-dd`, compact `yyyyMMdd`, or already `Expiry Date yyyyMMdd`. */ | ||||
| fun formatLaserExpiryParam(expiryDate: String?): String { | fun formatLaserExpiryParam(expiryDate: String?): String { | ||||
| val raw = expiryDate?.trim().orEmpty() | 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. | * 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 { | 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() } | val ip = (request.printerIp?.trim()?.takeIf { it.isNotEmpty() } | ||||
| ?: resolveLaserBag2Host()).trim() | ?: resolveLaserBag2Host()).trim() | ||||
| val port = request.printerPort ?: resolveLaserBag2Port() | val port = request.printerPort ?: resolveLaserBag2Port() | ||||
| @@ -298,16 +313,10 @@ class PlasticBagPrinterService( | |||||
| itemName = request.itemName, | itemName = request.itemName, | ||||
| expiryDate = request.expiryDate, | 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 { | } else { | ||||
| val second = sendLaserBag2TcpOnce( | |||||
| sendLaserBag2TcpOnce( | |||||
| ip = ip, | ip = ip, | ||||
| port = port, | port = port, | ||||
| itemId = request.itemId, | itemId = request.itemId, | ||||
| @@ -316,14 +325,14 @@ class PlasticBagPrinterService( | |||||
| itemName = request.itemName, | itemName = request.itemName, | ||||
| expiryDate = request.expiryDate, | 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) { | if (response.success && response.receiveAcknowledged) { | ||||
| try { | try { | ||||
| persistLaserLastReceiveSuccess(request, response.printerAck) | persistLaserLastReceiveSuccess(request, response.printerAck) | ||||
| @@ -436,6 +445,15 @@ class PlasticBagPrinterService( | |||||
| } catch (_: SocketTimeoutException) { | } catch (_: SocketTimeoutException) { | ||||
| // Same as Python Bag3: ignore read timeout, payload was still sent | // 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) { | val msg = if (receiveAck) { | ||||
| "已送出激光機:$payload(已確認)" | "已送出激光機:$payload(已確認)" | ||||
| } else { | } else { | ||||
| @@ -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. | * `{"itemId":n,"stockInLineId":m};code;name;Expiry Date yyyyMMdd;;` when expiryDate is set. | ||||
| */ | */ | ||||
| @PostMapping("/print-laser-bag2") | @PostMapping("/print-laser-bag2") | ||||
| fun printLaserBag2(@RequestBody request: LaserBag2SendRequest): ResponseEntity<LaserBag2SendResponse> { | fun printLaserBag2(@RequestBody request: LaserBag2SendRequest): ResponseEntity<LaserBag2SendResponse> { | ||||
| 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) { | return if (resp.success) { | ||||
| ResponseEntity.ok(resp) | ResponseEntity.ok(resp) | ||||
| } else { | } else { | ||||
| @@ -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<String>() { | |||||
| 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 | |||||
| } | |||||
| } | |||||
| } | |||||
| } | |||||
| @@ -1,13 +1,18 @@ | |||||
| package com.ffii.fpsms.modules.jobOrder.web.model | 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 | * Body for Bag2/Bag4-style laser TCP send: `json;itemCode;itemName;;` or | ||||
| * `json;itemCode;itemName;Expiry Date yyyyMMdd;;` when [expiryDate] is set (UTF-8). | * `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 [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] | * 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. | * when the printer returns a receive ack. | ||||
| */ | */ | ||||
| @JsonIgnoreProperties(ignoreUnknown = true) | |||||
| data class LaserBag2SendRequest( | data class LaserBag2SendRequest( | ||||
| val itemId: Long? = null, | val itemId: Long? = null, | ||||
| val stockInLineId: Long? = null, | val stockInLineId: Long? = null, | ||||
| @@ -20,8 +25,9 @@ data class LaserBag2SendRequest( | |||||
| val lotNo: String? = null, | val lotNo: String? = null, | ||||
| /** | /** | ||||
| * Print-time expiry from the job-order list (`yyyy-MM-dd`, compact `yyyyMMdd`, | * 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, | val expiryDate: String? = null, | ||||
| /** AUTO (auto-send) or MANUAL (/laserPrint); optional. */ | /** AUTO (auto-send) or MANUAL (/laserPrint); optional. */ | ||||
| val source: String? = null, | val source: String? = null, | ||||
| @@ -1,6 +1,9 @@ | |||||
| package com.ffii.fpsms.modules.jobOrder.service | 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.assertEquals | ||||
| import org.junit.jupiter.api.Assertions.assertNull | |||||
| import org.junit.jupiter.api.Test | import org.junit.jupiter.api.Test | ||||
| class LaserBag2PayloadTest { | 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 | @Test | ||||
| fun formatLaserExpiryParam_accepts_iso_compact_and_print_label() { | fun formatLaserExpiryParam_accepts_iso_compact_and_print_label() { | ||||
| assertEquals("Expiry Date 20260821", PlasticBagPrinterService.formatLaserExpiryParam("2026-08-21")) | assertEquals("Expiry Date 20260821", PlasticBagPrinterService.formatLaserExpiryParam("2026-08-21")) | ||||