| @@ -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(";", ",") | |||
| @@ -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 { | |||
| @@ -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<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) { | |||
| ResponseEntity.ok(resp) | |||
| } 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 | |||
| 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, | |||
| @@ -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")) | |||