| @@ -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). | 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 | 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. | 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 | raise | ||||
| EXPIRY_PRINT_PREFIX = "Expiry Date " | |||||
| EXPIRY_LABEL_PREFIX = "Expiry " | |||||
| def format_expiry_print_label(d: date) -> str: | 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]: | 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 == "": | if raw is None or raw == "": | ||||
| return None | return None | ||||
| if isinstance(raw, datetime): | |||||
| return raw.date() | |||||
| if isinstance(raw, date): | |||||
| return raw | |||||
| if isinstance(raw, (list, tuple)) and len(raw) >= 3: | if isinstance(raw, (list, tuple)) and len(raw) >= 3: | ||||
| try: | try: | ||||
| return date(int(raw[0]), int(raw[1]), int(raw[2])) | return date(int(raw[0]), int(raw[1]), int(raw[2])) | ||||
| except (TypeError, ValueError): | except (TypeError, ValueError): | ||||
| return None | 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 | 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]: | 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") | days = jo.get("defaultShelfLifeDays") | ||||
| if isinstance(days, bool): | if isinstance(days, bool): | ||||
| days = None | days = None | ||||
| @@ -464,12 +494,12 @@ 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)) | ||||
| 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: | 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(";", ",") | return (job_expiry_zpl_text(jo) or "").replace(";", ",") | ||||
| @@ -496,7 +526,7 @@ def generate_zpl_dataflex( | |||||
| code = _zpl_escape((item_code or "—").strip()) | code = _zpl_escape((item_code or "—").strip()) | ||||
| label_line = (lot_no or batch_no or "").strip() | label_line = (lot_no or batch_no or "").strip() | ||||
| label_esc = _zpl_escape(label_line) | 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 "" | 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 | # 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: | 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, | item_id: Optional[int] = None, | ||||
| stock_in_line_id: Optional[int] = None, | stock_in_line_id: Optional[int] = None, | ||||
| lot_no: Optional[str] = None, | lot_no: Optional[str] = None, | ||||
| expiry_text: Optional[str] = None, | |||||
| font: str = "MingLiUHKSCS", | font: str = "MingLiUHKSCS", | ||||
| ) -> str: | ) -> 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. | QR contains {"itemId": xxx, "stockInLineId": xxx} when both present; else batch_no. | ||||
| Unicode (^CI28); font set for Big-5 (e.g. MingLiUHKSCS). | 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()) | code = _zpl_escape((item_code or "—").strip()) | ||||
| label_line2 = (lot_no or batch_no or "—").strip() | label_line2 = (lot_no or batch_no or "—").strip() | ||||
| label_line2_esc = _zpl_escape(label_line2) | 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: | 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})) | qr_data = _zpl_escape(json.dumps({"itemId": item_id, "stockInLineId": stock_in_line_id})) | ||||
| else: | else: | ||||
| qr_data = f"QA,{batch_no}" | 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 | return f"""^XA | ||||
| ^CI28 | ^CI28 | ||||
| ^PW500 | ^PW500 | ||||
| @@ -678,7 +714,7 @@ def generate_zpl_label_small( | |||||
| ^FO150,110 | ^FO150,110 | ||||
| ^A@N,48,48,{font}^FD{code}^FS | ^A@N,48,48,{font}^FD{code}^FS | ||||
| ^FO150,175 | ^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""" | ^XZ""" | ||||
| @@ -690,6 +726,7 @@ LABEL_PADDING = 23 | |||||
| LABEL_FONT_NAME_SIZE = 42 | LABEL_FONT_NAME_SIZE = 42 | ||||
| LABEL_FONT_CODE_SIZE = 49 | LABEL_FONT_CODE_SIZE = 49 | ||||
| LABEL_FONT_BATCH_SIZE = 34 | LABEL_FONT_BATCH_SIZE = 34 | ||||
| LABEL_FONT_EXPIRY_SIZE = 34 | |||||
| LABEL_QR_SIZE = 210 | LABEL_QR_SIZE = 210 | ||||
| @@ -745,9 +782,11 @@ def render_label_to_image( | |||||
| item_id: Optional[int] = None, | item_id: Optional[int] = None, | ||||
| stock_in_line_id: Optional[int] = None, | stock_in_line_id: Optional[int] = None, | ||||
| lot_no: Optional[str] = None, | lot_no: Optional[str] = None, | ||||
| expiry_text: Optional[str] = None, | |||||
| ) -> "Image.Image": | ) -> "Image.Image": | ||||
| """ | """ | ||||
| Render 標簽機 label as a PIL Image (white bg, black text + QR). | 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. | Use this image for printing so Chinese displays correctly; words are drawn bigger. | ||||
| Requires Pillow and qrcode. Raises RuntimeError if not available. | 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_name = _get_chinese_font(LABEL_FONT_NAME_SIZE) | ||||
| font_code = _get_chinese_font(LABEL_FONT_CODE_SIZE) | font_code = _get_chinese_font(LABEL_FONT_CODE_SIZE) | ||||
| font_batch = _get_chinese_font(LABEL_FONT_BATCH_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 | x_right = LABEL_PADDING + LABEL_QR_SIZE + LABEL_PADDING | ||||
| y_line = LABEL_PADDING | y_line = LABEL_PADDING | ||||
| # Line 1: item name (wrap within remaining width) | # 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") | draw.text((x_right, y_line), batch_str, font=font_batch, fill="black") | ||||
| else: | else: | ||||
| draw.text((x_right, y_line), batch_str, fill="black") | 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 | return img | ||||
| @@ -1270,7 +1317,7 @@ def send_job_to_laser( | |||||
| """ | """ | ||||
| code_str = (item_code or "").strip().replace(";", ",") | code_str = (item_code or "").strip().replace(";", ",") | ||||
| name_str = (item_name 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: | 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. | # Use compact JSON so device-side parser doesn't get spaces. | ||||
| @@ -2905,6 +2952,7 @@ def main() -> None: | |||||
| b, item_code, item_name, | b, item_code, item_name, | ||||
| item_id=item_id, stock_in_line_id=stock_in_line_id, | item_id=item_id, stock_in_line_id=stock_in_line_id, | ||||
| lot_no=lot_no, | lot_no=lot_no, | ||||
| expiry_text=job_expiry_zpl_text(j), | |||||
| ) | ) | ||||
| zpl_img = _image_to_zpl_gfa(label_img) | zpl_img = _image_to_zpl_gfa(label_img) | ||||
| run_label_print_batch_thread( | run_label_print_batch_thread( | ||||