| @@ -4,7 +4,7 @@ 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 (EZCAD job must bind that field). | |||
| and as the 4th laser TCP param, always printed as `Expiry Date 20260826`. | |||
| Bag3 remains the line without expiry on DataFlex; do not share settings files. | |||
| @@ -430,33 +430,63 @@ def _dataflex_shutdown_write_maybe(sock: socket.socket) -> None: | |||
| raise | |||
| EXPIRY_PRINT_PREFIX = "Expiry Date " | |||
| EXPIRY_LABEL_PREFIX = "Expiry " | |||
| def format_expiry_print_label(d: date) -> str: | |||
| """Printed bag wording, e.g. Expiry Date 20260821.""" | |||
| return f"Expiry Date {d.strftime('%Y%m%d')}" | |||
| """DataFlex / laser wording: `Expiry Date 20260826`.""" | |||
| return f"{EXPIRY_PRINT_PREFIX}{d.strftime('%Y%m%d')}" | |||
| def format_expiry_label_compact(d: date) -> str: | |||
| """標簽機 wording (narrow): `Expiry 20260826`.""" | |||
| return f"{EXPIRY_LABEL_PREFIX}{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.""" | |||
| """Accept ISO `yyyy-MM-dd`, compact `yyyyMMdd`, `Expiry Date yyyyMMdd`, or `[yyyy,M,d]`.""" | |||
| if raw is None or raw == "": | |||
| return None | |||
| if isinstance(raw, datetime): | |||
| return raw.date() | |||
| if isinstance(raw, date): | |||
| return raw | |||
| 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: | |||
| s = str(raw).strip().replace(";", ",") | |||
| if not s: | |||
| return None | |||
| if s.lower().startswith(EXPIRY_PRINT_PREFIX.lower()): | |||
| s = s[len(EXPIRY_PRINT_PREFIX):].strip() | |||
| elif s.lower().startswith(EXPIRY_LABEL_PREFIX.lower()): | |||
| s = s[len(EXPIRY_LABEL_PREFIX):].strip() | |||
| digits = "".join(ch for ch in s if ch.isdigit()) | |||
| if len(digits) >= 8: | |||
| try: | |||
| return date(int(digits[0:4]), int(digits[4:6]), int(digits[6:8])) | |||
| except ValueError: | |||
| return None | |||
| return None | |||
| def standardize_expiry_print_label(raw) -> str: | |||
| """Normalize any expiry input to `Expiry Date 20260826`, or empty if unknown.""" | |||
| parsed = _parse_api_expiry_date(raw) | |||
| return format_expiry_print_label(parsed) if parsed else "" | |||
| def standardize_expiry_label_compact(raw) -> str: | |||
| """Normalize any expiry input to `Expiry 20260826` for 標簽機, or empty if unknown.""" | |||
| parsed = _parse_api_expiry_date(raw) | |||
| return format_expiry_label_compact(parsed) if parsed else "" | |||
| def job_expiry_zpl_text(jo: dict) -> Optional[str]: | |||
| """Expiry yyyyMMdd from print-time shelf life days, else API expiryDate.""" | |||
| """Expiry as `Expiry Date yyyyMMdd` from shelf-life days, else API expiryDate.""" | |||
| days = jo.get("defaultShelfLifeDays") | |||
| if isinstance(days, bool): | |||
| days = None | |||
| @@ -464,12 +494,12 @@ 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)) | |||
| parsed = _parse_api_expiry_date(jo.get("expiryDate")) | |||
| return format_expiry_print_label(parsed) if parsed else None | |||
| label = standardize_expiry_print_label(jo.get("expiryDate")) | |||
| return label or None | |||
| def job_expiry_laser_param(jo: dict) -> str: | |||
| """Same expiry as DataFlex ZPL, sanitized for `;`-separated laser TCP.""" | |||
| """Same `Expiry Date yyyyMMdd` as DataFlex, sanitized for `;`-separated laser TCP.""" | |||
| return (job_expiry_zpl_text(jo) or "").replace(";", ",") | |||
| @@ -496,7 +526,7 @@ def generate_zpl_dataflex( | |||
| code = _zpl_escape((item_code or "—").strip()) | |||
| label_line = (lot_no or batch_no or "").strip() | |||
| label_esc = _zpl_escape(label_line) | |||
| exp_raw = (expiry_text or "").strip() | |||
| exp_raw = standardize_expiry_print_label(expiry_text) | |||
| exp_esc = _zpl_escape(exp_raw) if exp_raw else "" | |||
| # QR payload: prefer JSON {"itemId":..., "stockInLineId":...} when both present; else fall back to lot/batch text | |||
| if item_id is not None and stock_in_line_id is not None: | |||
| @@ -651,10 +681,12 @@ def generate_zpl_label_small( | |||
| item_id: Optional[int] = None, | |||
| stock_in_line_id: Optional[int] = None, | |||
| lot_no: Optional[str] = None, | |||
| expiry_text: Optional[str] = None, | |||
| font: str = "MingLiUHKSCS", | |||
| ) -> str: | |||
| """ | |||
| ZPL for 標簽機. Row 1: item name. Row 2: QR left | item code + lot no (or batch) right. | |||
| ZPL for 標簽機. Row 1: item name. Row 2: QR left | item code + lot + expiry right. | |||
| Expiry is always `Expiry yyyyMMdd` when present (narrow label). | |||
| QR contains {"itemId": xxx, "stockInLineId": xxx} when both present; else batch_no. | |||
| Unicode (^CI28); font set for Big-5 (e.g. MingLiUHKSCS). | |||
| """ | |||
| @@ -662,10 +694,14 @@ def generate_zpl_label_small( | |||
| code = _zpl_escape((item_code or "—").strip()) | |||
| label_line2 = (lot_no or batch_no or "—").strip() | |||
| label_line2_esc = _zpl_escape(label_line2) | |||
| exp_esc = _zpl_escape(standardize_expiry_label_compact(expiry_text)) | |||
| if item_id is not None and stock_in_line_id is not None: | |||
| qr_data = _zpl_escape(json.dumps({"itemId": item_id, "stockInLineId": stock_in_line_id})) | |||
| else: | |||
| qr_data = f"QA,{batch_no}" | |||
| exp_zpl = f""" | |||
| ^FO150,230 | |||
| ^A@N,36,36,{font}^FD{exp_esc}^FS""" if exp_esc else "" | |||
| return f"""^XA | |||
| ^CI28 | |||
| ^PW500 | |||
| @@ -678,7 +714,7 @@ def generate_zpl_label_small( | |||
| ^FO150,110 | |||
| ^A@N,48,48,{font}^FD{code}^FS | |||
| ^FO150,175 | |||
| ^A@N,40,40,{font}^FD{label_line2_esc}^FS | |||
| ^A@N,40,40,{font}^FD{label_line2_esc}^FS{exp_zpl} | |||
| ^XZ""" | |||
| @@ -690,6 +726,7 @@ LABEL_PADDING = 23 | |||
| LABEL_FONT_NAME_SIZE = 42 | |||
| LABEL_FONT_CODE_SIZE = 49 | |||
| LABEL_FONT_BATCH_SIZE = 34 | |||
| LABEL_FONT_EXPIRY_SIZE = 34 | |||
| LABEL_QR_SIZE = 210 | |||
| @@ -745,9 +782,11 @@ def render_label_to_image( | |||
| item_id: Optional[int] = None, | |||
| stock_in_line_id: Optional[int] = None, | |||
| lot_no: Optional[str] = None, | |||
| expiry_text: Optional[str] = None, | |||
| ) -> "Image.Image": | |||
| """ | |||
| Render 標簽機 label as a PIL Image (white bg, black text + QR). | |||
| Lot line is followed by `Expiry yyyyMMdd` when [expiry_text] is set. | |||
| Use this image for printing so Chinese displays correctly; words are drawn bigger. | |||
| Requires Pillow and qrcode. Raises RuntimeError if not available. | |||
| """ | |||
| @@ -772,6 +811,7 @@ def render_label_to_image( | |||
| font_name = _get_chinese_font(LABEL_FONT_NAME_SIZE) | |||
| font_code = _get_chinese_font(LABEL_FONT_CODE_SIZE) | |||
| font_batch = _get_chinese_font(LABEL_FONT_BATCH_SIZE) | |||
| font_expiry = _get_chinese_font(LABEL_FONT_EXPIRY_SIZE) | |||
| x_right = LABEL_PADDING + LABEL_QR_SIZE + LABEL_PADDING | |||
| y_line = LABEL_PADDING | |||
| # Line 1: item name (wrap within remaining width) | |||
| @@ -852,6 +892,13 @@ def render_label_to_image( | |||
| draw.text((x_right, y_line), batch_str, font=font_batch, fill="black") | |||
| else: | |||
| draw.text((x_right, y_line), batch_str, fill="black") | |||
| y_line += LABEL_FONT_BATCH_SIZE + 6 | |||
| exp_str = standardize_expiry_label_compact(expiry_text) | |||
| if exp_str: | |||
| if font_expiry: | |||
| draw.text((x_right, y_line), exp_str, font=font_expiry, fill="black") | |||
| else: | |||
| draw.text((x_right, y_line), exp_str, fill="black") | |||
| return img | |||
| @@ -1270,7 +1317,7 @@ def send_job_to_laser( | |||
| """ | |||
| code_str = (item_code or "").strip().replace(";", ",") | |||
| name_str = (item_name or "").strip().replace(";", ",") | |||
| exp_str = (expiry_text or "").strip().replace(";", ",") | |||
| exp_str = standardize_expiry_print_label(expiry_text).replace(";", ",") | |||
| if item_id is not None and stock_in_line_id is not None: | |||
| # Use compact JSON so device-side parser doesn't get spaces. | |||
| @@ -2905,6 +2952,7 @@ def main() -> None: | |||
| b, item_code, item_name, | |||
| item_id=item_id, stock_in_line_id=stock_in_line_id, | |||
| lot_no=lot_no, | |||
| expiry_text=job_expiry_zpl_text(j), | |||
| ) | |||
| zpl_img = _image_to_zpl_gfa(label_img) | |||
| run_label_print_batch_thread( | |||