diff --git a/build.gradle b/build.gradle index 7907d7f..cb4ad20 100644 --- a/build.gradle +++ b/build.gradle @@ -93,6 +93,10 @@ kotlin { jvmToolchain(17) } +test { + useJUnitPlatform() +} + bootRun { // Use db-local profile by default so datasource is loaded (application-db-local.yml). // Override with: ./gradlew bootRun --args='--spring.profiles.active=prod' diff --git a/docs/deploy/20260819_po-m18id-remap.md b/docs/deploy/20260819_po-m18id-remap.md new file mode 100644 index 0000000..4bca16f --- /dev/null +++ b/docs/deploy/20260819_po-m18id-remap.md @@ -0,0 +1,85 @@ +# Deploy note — PO line m18Id rematch (recode) +Date: 2026-08-19 +Branch / build: uncommitted (QA before prod) +Author: agent + QA + +## Summary +- PO/DO sync failed when M18 recoded a product (`proId` changed, item **code** unchanged), e.g. `PFP002PO26080303` lines `20022` / `20023` / `20024`. +- Sync now **links** the new M18 id onto the existing local item (same **code + type**) and **does not** rebuild/delete `item_uom`. + +## Scope +- Backend: `ItemsService.saveItem`, `M18MasterDataService.saveProduct` / `saveProducts`, remap helper +- Frontend: none +- DB / Liquibase: none +- Config / ops: none (use existing `GET /m18/test/po-by-code`) + +## Commits +- (not committed yet) + +## Safety rules (what this deploy will / will not do) + +| Situation | Behaviour | +|-----------|-----------| +| Local item already has that `m18Id` | Unchanged: normal product/PO update | +| Same **code + type**, new unused `m18Id` | Update `items.m18Id` only. **No** UOM delete/rebuild, **no** QC/name wipe | +| New `m18Id` already on **another** item | Refuse (keep old mapping) | +| Same code, **different** type | Will **not** auto-link (same as before for type mismatch) | +| Brand-new M18 product, no local code | Creates item + UOMs as before | +| Scheduled product sync of already-mapped items | Unchanged full product/UOM sync | +| Local PO status not `PENDING` | PO resync is **skipped** (existing rule) | + +## Pre-check SQL (prod or staging, before resync) + +```sql +-- 1) Failed PO lines for this PO +SELECT d.id, d.m18Id AS m18_line_id, d.status, d.dataLog, d.createDate +FROM m18_data_log d +WHERE d.refType = 'Purchase Order Line' + AND d.status = 'FAIL' + AND d.dataLog LIKE '%PFP002PO26080303%' +ORDER BY d.id DESC +LIMIT 20; + +-- 2) Local PO must be PENDING +SELECT id, code, status, m18Id +FROM purchase_order +WHERE code = 'PFP002PO26080303' AND deleted = 0; + +-- 3) After you know the 3 M18 product codes, confirm one local row each +SELECT id, code, name, type, m18Id +FROM items +WHERE deleted = 0 AND code IN ('CODE1', 'CODE2', 'CODE3'); +``` + +Confirm each of the three codes has **exactly one** local row, and its current `m18Id` is **not** already `20022`/`20023`/`20024` on a *different* item: + +```sql +SELECT id, code, m18Id FROM items +WHERE deleted = 0 AND m18Id IN (20022, 20023, 20024); +``` + +Expected before fix: **no rows** (or only unrelated items — if any, **do not** auto-resync; fix mapping manually). + +## Test plan + +| # | Steps (who / where / data) | Expected result | +|---|----|-----| +| 1 | **Staging first.** Snapshot `items.m18Id` + `item_uom` counts for the 3 item codes. | Baseline recorded | +| 2 | Confirm M18 products `20022`/`20023`/`20024` still have the **same codes** as local items. | Codes match; types still map to the same FPSMS `type` | +| 3 | Confirm local PO `PFP002PO26080303` status is **PENDING**. | If not pending, stop — resync will skip the whole PO | +| 4 | Call `GET /m18/test/po-by-code?code=PFP002PO26080303` (auth as ops). | HTTP 200; `totalFail` for those 3 lines is 0 (or success list includes the PO) | +| 5 | SQL: `items.m18Id` for the 3 codes is now 20022/20023/20024. `item_uom` **row count and ids unchanged**. QC category / shelf life unchanged. | Remap-only; no UOM wipe | +| 6 | UI: PO workbench / PO detail for `PFP002PO26080303` shows **6 lines** (previously 3 missing). | Failed lines created | +| 7 | **Regression:** pick a normal mapped item, run scheduled or `GET /m18/product/{existingM18Id}`. | Item still updates as before; UOMs still sync | +| 8 | Confirm no new `PO_LINE FAIL` email for this PO after resync. | Alert not re-sent for these 3 proIds | + +## Out of scope / not tested +- Recoded products whose **item code also changed** (will not auto-link) +- Non-`PENDING` PO overwrite (by design skipped) +- DO with the same recode (same `resolveLocalItemId` path; spot-check only if a DO uses these 3 items) +- Live M18 UOM id change on the recoded product (PO qty conversion still uses existing purchase UOM) + +## Rollback +- Revert backend deploy (no Liquibase, no frontend). +- If only `items.m18Id` was updated, restore previous `m18Id` from the pre-check snapshot. PO lines already inserted stay; delete/fix only if QA rejects the PO. +- Do **not** run a full product resync on the new proIds expecting to undo UOMs — this path does not rewrite UOMs. diff --git a/python/Bag4.py b/python/Bag4.py new file mode 100644 index 0000000..b5f924f --- /dev/null +++ b/python/Bag4.py @@ -0,0 +1,3074 @@ +#!/usr/bin/env python3 +""" +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. + +Bag3 remains the line without expiry on DataFlex; do not share settings files. + +Run: python Bag4.py +""" + +import errno +import json +import os +import select +import socket +import sys +import tempfile +import threading +import time +import tkinter as tk +from dataclasses import dataclass +from datetime import date, datetime, timedelta +from tkinter import messagebox, ttk +from typing import Callable, Optional, Tuple + +import requests + +# UI "列印機" check uses short TCP probes; DataFlex may refuse connections briefly during E1005 recovery. +_DATAFLEX_RECOVERY_GRACE_UNTIL: float = 0.0 + + +def touch_dataflex_recovery_grace(seconds: float = 22.0) -> None: + """While host reset runs, avoid flashing printer status to red.""" + global _DATAFLEX_RECOVERY_GRACE_UNTIL + u = time.time() + max(0.0, seconds) + if u > _DATAFLEX_RECOVERY_GRACE_UNTIL: + _DATAFLEX_RECOVERY_GRACE_UNTIL = u + + +try: + import serial +except ImportError: + serial = None # type: ignore + +try: + import win32print # type: ignore[import] + import win32ui # type: ignore[import] + import win32con # type: ignore[import] + import win32gui # type: ignore[import] +except ImportError: + win32print = None # type: ignore[assignment] + win32ui = None # type: ignore[assignment] + win32con = None # type: ignore[assignment] + win32gui = None # type: ignore[assignment] + +try: + from PIL import Image, ImageDraw, ImageFont, ImageOps + try: + from PIL import ImageWin # type: ignore + except Exception: + ImageWin = None # type: ignore[assignment] + import qrcode + _HAS_PIL_QR = True +except ImportError: + Image = None # type: ignore[assignment] + ImageDraw = None # type: ignore[assignment] + ImageFont = None # type: ignore[assignment] + ImageOps = None # type: ignore[assignment] + ImageWin = None # type: ignore[assignment] + qrcode = None # type: ignore[assignment] + _HAS_PIL_QR = False + +APP_VERSION = "4.0" + +DEFAULT_BASE_URL = os.environ.get("FPSMS_BASE_URL", "http://localhost:8090/api") +# When run as PyInstaller exe, save settings next to the exe; otherwise next to script +if getattr(sys, "frozen", False): + _SETTINGS_DIR = os.path.dirname(sys.executable) +else: + _SETTINGS_DIR = os.path.dirname(os.path.abspath(__file__)) +# Bag4 has its own settings file so it doesn't share with Bag1/Bag2/Bag3. +SETTINGS_FILE = os.path.join(_SETTINGS_DIR, "bag4_settings.json") +LASER_COUNTER_FILE = os.path.join(_SETTINGS_DIR, "bag4_last_batch_count.txt") + +DEFAULT_SETTINGS = { + "api_ip": "localhost", + "api_port": "8090", + "dabag_ip": "", + "dabag_port": "3008", + "laser_ip": "192.168.17.10", + "laser_port": "45678", + # For 標簽機 on Windows, this is the Windows printer name, e.g. "TSC TTP-246M Pro" + "label_com": "TSC TTP-246M Pro", +} + + +def load_settings() -> dict: + """Load settings from JSON file; return defaults if missing or invalid.""" + try: + if os.path.isfile(SETTINGS_FILE): + with open(SETTINGS_FILE, "r", encoding="utf-8") as f: + data = json.load(f) + return {**DEFAULT_SETTINGS, **data} + except Exception: + pass + return dict(DEFAULT_SETTINGS) + + +def save_settings(settings: dict) -> None: + """Save settings to JSON file.""" + with open(SETTINGS_FILE, "w", encoding="utf-8") as f: + json.dump(settings, f, indent=2, ensure_ascii=False) + + +def build_base_url(api_ip: str, api_port: str) -> str: + ip = (api_ip or "localhost").strip() + port = (api_port or "8090").strip() + return f"http://{ip}:{port}/api" + + +def try_printer_connection(printer_name: str, sett: dict) -> bool: + """Try to connect to the selected printer (TCP IP:port or COM). Returns True if OK.""" + if printer_name == "打袋機 DataFlex": + ip = (sett.get("dabag_ip") or "").strip() + port_str = (sett.get("dabag_port") or "3008").strip() + if not ip: + return False + try: + port = int(port_str) + except ValueError: + return False + # Retry once: firmware often busy for ~1s after E1005 / blank label. + timeout = max(PRINTER_SOCKET_TIMEOUT, 6.0) + for attempt in range(2): + try: + s = socket.create_connection((ip, port), timeout=timeout) + s.close() + return True + except (socket.error, OSError): + if attempt == 0: + time.sleep(0.35) + continue + return False + if printer_name == "激光機": + ip = (sett.get("laser_ip") or "").strip() + port_str = (sett.get("laser_port") or "45678").strip() + if not ip: + return False + try: + port = int(port_str) + s = socket.create_connection((ip, port), timeout=PRINTER_SOCKET_TIMEOUT) + s.close() + return True + except (socket.error, ValueError, OSError): + return False + if printer_name == "標簽機": + target = (sett.get("label_com") or "").strip() + if not target: + return False + # On Windows, allow using a Windows printer name (e.g. "TSC TTP-246M Pro") + # as an alternative to a COM port. If it doesn't look like a COM port, + # try opening it via the Windows print spooler. + if os.name == "nt" and not target.upper().startswith("COM"): + if win32print is None: + return False + try: + handle = win32print.OpenPrinter(target) + win32print.ClosePrinter(handle) + return True + except Exception: + return False + # Fallback: treat as serial COM port (original behaviour) + if serial is None: + return False + try: + ser = serial.Serial(target, timeout=1) + ser.close() + return True + except (serial.SerialException, OSError): + return False + return False + +# Larger font for aged users (point size) +FONT_SIZE = 16 +FONT_SIZE_BUTTONS = 15 +# Printer selector: field + dropdown (use tk.OptionMenu so menu font is respected on Windows) +FONT_SIZE_COMBO = 18 +FONT_SIZE_QTY = 12 # smaller for 需求數量 under batch no. +FONT_SIZE_META = 11 # single-line 需求/已印 (compact list) +# Less vertical padding so ~30 rows fit more comfortably +LIST_ROW_PADY = 2 +LIST_ROW_IPADY = 5 +FONT_SIZE_ITEM = 20 # item code and item name (larger for readability) +FONT_FAMILY = "Microsoft JhengHei UI" # Traditional Chinese; fallback to TkDefaultFont +FONT_SIZE_ITEM_CODE = 20 # item code (larger for readability) +FONT_SIZE_ITEM_NAME = 26 # item name (bigger than item code) +# Column widths: fixed frame widths so 品號/品名 columns line up across rows +LEFT_COL_WIDTH_PX = 300 # 工單 + 需求/已印 block +ITEM_CODE_WRAP = 140 # Label wraplength (px) +# Narrower than wrap+padding so short codes sit closer to 品名 (still aligned across rows) +CODE_COL_WIDTH_PX = ITEM_CODE_WRAP + 6 +ITEM_NAME_WRAP = 640 # item name wraps in remaining space + +# Light blue theme (softer than pure grey) +BG_TOP = "#E8F4FC" +BG_LIST = "#D4E8F7" +BG_ROOT = "#E1F0FF" +BG_ROW = "#C5E1F5" +BG_ROW_SELECTED = "#6BB5FF" # highlighted when selected (for printing) +# Connection status bar +BG_STATUS_ERROR = "#FFCCCB" # red background when disconnected +FG_STATUS_ERROR = "#B22222" # red text +BG_STATUS_OK = "#90EE90" # light green when connected +FG_STATUS_OK = "#006400" # green text +RETRY_MS = 30 * 1000 # 30 seconds reconnect +# POST /py/job-order-print-submit: retries when server is briefly unavailable +PRINT_SUBMIT_MAX_ATTEMPTS = 5 +PRINT_SUBMIT_RETRY_DELAY_SEC = 1.5 +PRINTER_CHECK_MS = 60 * 1000 # 1 minute when printer OK +PRINTER_RETRY_MS = 30 * 1000 # 30 seconds when printer failed +PRINTER_SOCKET_TIMEOUT = 3 +DATAFLEX_SEND_TIMEOUT = 10 # seconds when sending ZPL to DataFlex + + +def _dataflex_float_env(name: str, default: float) -> float: + raw = (os.environ.get(name) or "").strip() + if not raw: + return default + try: + return float(raw) + except ValueError: + return default + + +def _dataflex_bool_env(name: str, default: bool) -> bool: + raw = (os.environ.get(name) or "").strip().lower() + if not raw: + return default + return raw in ("1", "true", "yes", "on") + + +def _dataflex_int_env(name: str, default: int) -> int: + raw = (os.environ.get(name) or "").strip() + if not raw: + return default + try: + return int(raw) + except ValueError: + return default + + +# Job list auto-refresh interval (ms). 0 = off (list rebuild can hide DataFlex「停止列印」). +# Re-enable: FPSMS_JOB_LIST_REFRESH_MS=60000 +JOB_LIST_AUTO_REFRESH_MS = max(0, _dataflex_int_env("FPSMS_JOB_LIST_REFRESH_MS", 0)) +# Defer full list rebuild while printing; retry after this interval until idle. FPSMS_JOB_LIST_DEFER_WHILE_PRINTING_MS +JOB_LIST_DEFER_WHILE_PRINTING_MS = max( + 500, + _dataflex_int_env("FPSMS_JOB_LIST_DEFER_WHILE_PRINTING_MS", 1500), +) + + +# Gap between bag labels (after each job has fully left the client). Tune if bags are blank/skipped. +# Override: FPSMS_DATAFLEX_INTER_LABEL_DELAY_SEC (e.g. 3.5 if ~3–5% blanks per 100). +DATAFLEX_INTER_LABEL_DELAY_SEC = _dataflex_float_env( + "FPSMS_DATAFLEX_INTER_LABEL_DELAY_SEC", 0.3 +) +# Brief pause after each ZPL send so firmware can commit before we FIN the socket (reduces dropped/blank jobs). +# Override: FPSMS_DATAFLEX_POST_LABEL_SETTLE_SEC +DATAFLEX_POST_LABEL_SETTLE_SEC = _dataflex_float_env( + "FPSMS_DATAFLEX_POST_LABEL_SETTLE_SEC", 0.08 +) +# Before each print job: light reset only (~JA + ~RO). Must finish before first ^XA or first label can be lost. +# Override: FPSMS_DATAFLEX_POST_PREPRINT_DELAY_SEC +DATAFLEX_PREPRINT_BYTES = b"~JA\r\n~RO1\r\n~RO2\r\n" +DATAFLEX_POST_PREPRINT_DELAY_SEC = _dataflex_float_env( + "FPSMS_DATAFLEX_POST_PREPRINT_DELAY_SEC", 0.55 +) +# Whether each new print job starts with full reset (~JR) so DataFlex batch counter returns to 0. +# Set FPSMS_DATAFLEX_FULL_RESET_EACH_JOB=0 to keep only light preprint reset. +DATAFLEX_FULL_RESET_EACH_JOB = _dataflex_bool_env( + "FPSMS_DATAFLEX_FULL_RESET_EACH_JOB", False +) +# Extra-safe mode: run light preprint reset (~JA/~RO) before EVERY bag. +# Slower, but reduces E1005 on unstable firmware. +DATAFLEX_PREPRINT_EACH_LABEL = _dataflex_bool_env( + "FPSMS_DATAFLEX_PREPRINT_EACH_LABEL", False +) +DATAFLEX_VERIFY_STATUS_AFTER_SEND = _dataflex_bool_env( + "FPSMS_DATAFLEX_VERIFY_STATUS_AFTER_SEND", False +) +# Hard-disable automatic reset/counter commands during printing. +# When False, normal print path sends ZPL only (no ~JA/~RO/~JR). +DATAFLEX_AUTO_RESET_ENABLED = _dataflex_bool_env( + "FPSMS_DATAFLEX_AUTO_RESET_ENABLED", False +) +# After a failed TCP send, always run host recovery + retry (recommended when E1005 stops the run). +DATAFLEX_RECOVER_ON_SEND_ERROR = _dataflex_bool_env( + "FPSMS_DATAFLEX_RECOVER_ON_SEND_ERROR", True +) +DATAFLEX_STATUS_QUERY_TIMEOUT_SEC = _dataflex_float_env( + "FPSMS_DATAFLEX_STATUS_QUERY_TIMEOUT_SEC", 0.8 +) +DATAFLEX_RECOVERY_MAX_ATTEMPTS = max( + 1, + _dataflex_int_env("FPSMS_DATAFLEX_RECOVERY_MAX_ATTEMPTS", 2), +) +DATAFLEX_RECOVERY_WAIT_SEC = _dataflex_float_env( + "FPSMS_DATAFLEX_RECOVERY_WAIT_SEC", 0.8 +) +# Prevent cumulative thermal/mechanical fault in long runs (E1000 after ~40 bags on some units): +# pause briefly every N bags. +DATAFLEX_COOLDOWN_EVERY_LABELS = max( + 0, + _dataflex_int_env("FPSMS_DATAFLEX_COOLDOWN_EVERY_LABELS", 8), +) +DATAFLEX_COOLDOWN_SEC = _dataflex_float_env( + "FPSMS_DATAFLEX_COOLDOWN_SEC", 3.5 +) +# Extra long pause every M bags (head cool-down). 0 = off. FPSMS_DATAFLEX_THERMAL_REST_EVERY_LABELS +DATAFLEX_THERMAL_REST_EVERY_LABELS = max( + 0, + _dataflex_int_env("FPSMS_DATAFLEX_THERMAL_REST_EVERY_LABELS", 20), +) +DATAFLEX_THERMAL_REST_SEC = _dataflex_float_env( + "FPSMS_DATAFLEX_THERMAL_REST_SEC", 5.0 +) +# Light ~HS check every N bags. Default off — periodic checks + recovery caused long stalls with E1005 on some units. +DATAFLEX_VERIFY_EVERY_LABELS = max( + 0, + _dataflex_int_env("FPSMS_DATAFLEX_VERIFY_EVERY_LABELS", 0), +) +# Status bar progress while printing (main thread). 0 = off. +DATAFLEX_UI_PROGRESS_EVERY = max( + 0, + _dataflex_int_env("FPSMS_DATAFLEX_UI_PROGRESS_EVERY", 5), +) +# One TCP send: single ZPL with ^PQn (n identical bags). Some DataFlex units may fault (E1005); default off. +DATAFLEX_SINGLE_TCP_JOB = _dataflex_bool_env( + "FPSMS_DATAFLEX_SINGLE_TCP_JOB", False +) +# Link-OS SGD: raw-ZPL job shows this host id instead of a generic name (e.g. "ZPL. EMULATION"). +# Same id when the same job order is printed again. Disable: FPSMS_DATAFLEX_HOST_IDENTIFICATION_SGD=0 +DATAFLEX_HOST_IDENTIFICATION_SGD = _dataflex_bool_env( + "FPSMS_DATAFLEX_HOST_IDENTIFICATION_SGD", True +) +# Bag ZPL size (dots). ^PW700 matched little content (mostly vertical ^A@R), so previews showed a wide strip with empty right margin. +DATAFLEX_LABEL_PW = max( + 280, + _dataflex_int_env("FPSMS_DATAFLEX_LABEL_PW", 400), +) +DATAFLEX_LABEL_LL = max( + 200, + _dataflex_int_env("FPSMS_DATAFLEX_LABEL_LL", 500), +) +# Some Zebra/DataFlex units RST the socket on host half-close; Windows surfaces WinError 10054. +# Set FPSMS_DATAFLEX_SKIP_SHUTDOWN_WR=1 to omit shutdown(SHUT_WR) and only close() (often avoids RST). +DATAFLEX_SKIP_SHUTDOWN_WR = _dataflex_bool_env( + "FPSMS_DATAFLEX_SKIP_SHUTDOWN_WR", False +) +# Full recovery (~JR soft reset) — used by「打袋重設」only; longer delay for firmware +DATAFLEX_POST_FULL_RECOVERY_DELAY_SEC = 1.2 +# Zebra ~RO only (used when FPSMS_DATAFLEX_NO_JR is set for full recovery) +DATAFLEX_RESET_BYTES = b"~RO1\r\n~RO2\r\n" +# Full host recovery: ~JA clear buffers, ~RO counters, ~JR soft reset (clears latched errors without power cycle) +DATAFLEX_FULL_RECOVERY_BYTES = b"~JA\r\n~RO1\r\n~RO2\r\n~JR\r\n" + + +def _dataflex_full_recovery_payload() -> bytes: + """~JA+~RO+~JR for manual「打袋重設」; set env FPSMS_DATAFLEX_NO_JR=1 to skip ~JR.""" + if os.environ.get("FPSMS_DATAFLEX_NO_JR", "").strip().lower() in ("1", "true", "yes"): + return b"~JA\r\n" + DATAFLEX_RESET_BYTES + return DATAFLEX_FULL_RECOVERY_BYTES + + +def _zpl_escape(s: str) -> str: + """Escape text for ZPL ^FD...^FS (backslash and caret).""" + return s.replace("\\", "\\\\").replace("^", "\\^") + + +def _dataflex_host_identification_sgd_prefix(job_order_id: Optional[int]) -> str: + """ + Optional ASCII prefix before ^XA: set zpl.host_identification so the printer lists the job + under the job order id instead of a generic raw-ZPL label. + """ + if not DATAFLEX_HOST_IDENTIFICATION_SGD or job_order_id is None: + return "" + try: + jid = str(int(job_order_id)) + except (TypeError, ValueError): + return "" + if not jid.isdigit(): + return "" + return f'! U1 setvar "zpl.host_identification" "{jid}"\r\n' + + +def _dataflex_zpl_bytes(zpl: str) -> bytes: + """UTF-8 ZPL with one trailing CRLF so the printer sees a clear job boundary.""" + s = (zpl or "").rstrip("\r\n") + return (s + "\r\n").encode("utf-8") + + +def _dataflex_is_benign_tcp_reset(err: BaseException) -> bool: + """True when peer closed with RST/FIN in a way that is normal for raw printer TCP (Windows 10054).""" + if isinstance(err, (BrokenPipeError, ConnectionResetError, ConnectionAbortedError)): + return True + if isinstance(err, OSError): + if getattr(err, "winerror", None) == 10054: # WSAECONNRESET + return True + if err.errno in ( + errno.ECONNRESET, + errno.EPIPE, + errno.ECONNABORTED, + ): + return True + return False + + +def _dataflex_shutdown_write_maybe(sock: socket.socket) -> None: + """Half-close write side; ignore printer RST (common after ZPL on port 9100-style links).""" + if DATAFLEX_SKIP_SHUTDOWN_WR: + return + try: + sock.shutdown(socket.SHUT_WR) + except OSError as e: + if _dataflex_is_benign_tcp_reset(e): + return + raise + + +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')}" + + +def job_expiry_zpl_text(jo: dict) -> Optional[str]: + """Expiry yyyyMMdd from print-time shelf life days, else API expiryDate.""" + days = jo.get("defaultShelfLifeDays") + if isinstance(days, bool): + days = None + if isinstance(days, float) and days.is_integer(): + 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 + + +def job_expiry_laser_param(jo: dict) -> str: + """Same expiry as DataFlex ZPL, sanitized for `;`-separated laser TCP.""" + return (job_expiry_zpl_text(jo) or "").replace(";", ",") + + +def generate_zpl_dataflex( + batch_no: str, + item_code: str, + item_name: str, + item_id: Optional[int] = None, + stock_in_line_id: Optional[int] = None, + lot_no: Optional[str] = None, + job_order_id: Optional[int] = None, + expiry_text: Optional[str] = None, + font_regular: str = "E:STXihei.ttf", + font_bold: str = "E:STXihei.ttf", +) -> str: + """ + Row 1 (from zero): QR code, then item name (rotated 90°). + Row 2: smaller Lot, then expiry at the next X, then item code. + Text is ^A@R (90° CW); the next line under Lot is +X, not +Y. + Label and QR use lotNo from API when present, else batch_no (Bxxxxx). + Light preprint (~JA/~RO) is sent before labels; full ~JR recovery is only for「打袋重設」. + """ + desc = _zpl_escape((item_name or "—").strip()) + 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_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: + qr_payload = json.dumps({"itemId": item_id, "stockInLineId": stock_in_line_id}) + else: + qr_payload = label_line if label_line else batch_no.strip() + qr_value = _zpl_escape(qr_payload) + # Explicit ^PQ1: each ^XA…^XZ is exactly one bag. Avoids E1005 "over quantity" on some Zebra/DataFlex + # firmware when many labels are sent on one TCP session without a per-job quantity. + host_id = _dataflex_host_identification_sgd_prefix(job_order_id) + if exp_esc: + row2 = f"""^FO0,200 +^A@R,40,40,{font_regular}^FD{label_esc}^FS +^FO42,200 +^A@R,40,40,{font_regular}^FD{exp_esc}^FS +^FO88,200 +^A@R,88,88,{font_bold}^FD{code}^FS""" + else: + row2 = f"""^FO0,200 +^A@R,72,72,{font_regular}^FD{label_esc}^FS +^FO55,200 +^A@R,88,88,{font_bold}^FD{code}^FS""" + return host_id + f"""^XA +^PQ1,0,1,N +^CI28 +^PW{DATAFLEX_LABEL_PW} +^LL{DATAFLEX_LABEL_LL} +^PO N +^FO10,20 +^BQN,2,4^FDQA,{qr_value}^FS +^FO170,20 +^A@R,72,72,{font_regular}^FD{desc}^FS +{row2} +^XZ""" + + +def dataflex_zpl_set_print_quantity(zpl: str, copies: int) -> str: + """ + Replace the fixed ^PQ1 line from generate_zpl_dataflex() with ^PQn so one ZPL job prints + n identical bags over one TCP connection. + """ + if copies < 1: + copies = 1 + old = "^PQ1,0,1,N" + if old not in zpl: + raise RuntimeError( + "DataFlex ZPL 缺少預期的 ^PQ1 列(無法改為單次連線多張)。" + ) + return zpl.replace(old, f"^PQ{copies},0,1,N", 1) + + +def send_dataflex_preprint_reset(ip: str, port: int, *, force: bool = False) -> None: + """ + Fast prep before printing: ~JA + ~RO (no ~JR). Clears buffer and zeros batch counters so the first + bag starts quickly. Use before fixed-qty batch and continuous mode. + """ + if not force and not DATAFLEX_AUTO_RESET_ENABLED: + return + sock = socket.socket(socket.AF_INET, socket.SOCK_STREAM) + try: + sock.setsockopt(socket.IPPROTO_TCP, socket.TCP_NODELAY, 1) + except OSError: + pass + sock.settimeout(DATAFLEX_SEND_TIMEOUT) + try: + sock.connect((ip, port)) + sock.sendall(DATAFLEX_PREPRINT_BYTES) + time.sleep(DATAFLEX_POST_PREPRINT_DELAY_SEC) + _dataflex_shutdown_write_maybe(sock) + finally: + sock.close() + + +def send_dataflex_job_counter_reset(ip: str, port: int, *, force: bool = False) -> None: + """ + Full host recovery for「打袋重設」: ~JA, ~RO, and ~JR (soft reset) to clear latched E1005. + Slower than [send_dataflex_preprint_reset]; do not use on every row click. + """ + if not force and not DATAFLEX_AUTO_RESET_ENABLED: + return + sock = socket.socket(socket.AF_INET, socket.SOCK_STREAM) + try: + sock.setsockopt(socket.IPPROTO_TCP, socket.TCP_NODELAY, 1) + except OSError: + pass + sock.settimeout(DATAFLEX_SEND_TIMEOUT) + try: + sock.connect((ip, port)) + sock.sendall(_dataflex_full_recovery_payload()) + time.sleep(DATAFLEX_POST_FULL_RECOVERY_DELAY_SEC) + _dataflex_shutdown_write_maybe(sock) + finally: + sock.close() + + +def send_dataflex_start_job_reset(ip: str, port: int, *, force: bool = False) -> None: + """ + Start-of-job reset sequence. + + Full reset first (default) ensures printer-side batch quantity returns to 0 for each job; + then light preprint reset prepares the first bag send. + + Use force=True for the start of each print job and when selecting a job row so batch + counter resets even if FPSMS_DATAFLEX_AUTO_RESET_ENABLED=0 (that flag mainly gates + extra per-label / recovery traffic). + """ + if not force and not DATAFLEX_AUTO_RESET_ENABLED: + return + if DATAFLEX_FULL_RESET_EACH_JOB: + send_dataflex_job_counter_reset(ip, port, force=force) + send_dataflex_preprint_reset(ip, port, force=force) + + +def send_dataflex_reset_and_labels( + ip: str, + port: int, + zpl: str, + copies: int, + delay_sec: float, +) -> None: + """ + One TCP connection: light preprint (~JA + ~RO), short pause, then `copies` identical ZPL labels + with delay_sec between copies (not after the last). Avoids rapid connect/disconnect per bag. + """ + if copies < 1: + return + raw_zpl = _dataflex_zpl_bytes(zpl) + sock = socket.socket(socket.AF_INET, socket.SOCK_STREAM) + try: + sock.setsockopt(socket.IPPROTO_TCP, socket.TCP_NODELAY, 1) + except OSError: + pass + sock.settimeout(DATAFLEX_SEND_TIMEOUT) + try: + sock.connect((ip, port)) + sock.sendall(DATAFLEX_PREPRINT_BYTES) + time.sleep(DATAFLEX_POST_PREPRINT_DELAY_SEC) + for i in range(copies): + sock.sendall(raw_zpl) + time.sleep(DATAFLEX_POST_LABEL_SETTLE_SEC) + if i < copies - 1: + time.sleep(delay_sec) + _dataflex_shutdown_write_maybe(sock) + finally: + sock.close() + + +def generate_zpl_label_small( + batch_no: str, + item_code: str, + item_name: str, + item_id: Optional[int] = None, + stock_in_line_id: Optional[int] = None, + lot_no: Optional[str] = None, + font: str = "MingLiUHKSCS", +) -> str: + """ + ZPL for 標簽機. Row 1: item name. Row 2: QR left | item code + lot no (or batch) right. + QR contains {"itemId": xxx, "stockInLineId": xxx} when both present; else batch_no. + Unicode (^CI28); font set for Big-5 (e.g. MingLiUHKSCS). + """ + desc = _zpl_escape((item_name or "—").strip()) + code = _zpl_escape((item_code or "—").strip()) + label_line2 = (lot_no or batch_no or "—").strip() + label_line2_esc = _zpl_escape(label_line2) + 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}" + return f"""^XA +^CI28 +^PW500 +^LL500 +^FO10,15 +^FB480,3,0,L,0 +^A@N,38,38,{font}^FD{desc}^FS +^FO10,110 +^BQN,2,6^FD{qr_data}^FS +^FO150,110 +^A@N,48,48,{font}^FD{code}^FS +^FO150,175 +^A@N,40,40,{font}^FD{label_line2_esc}^FS +^XZ""" + + +# Label image size (pixels) for 標簽機 image printing. +# Enlarged for readability (approx +90% scale). +LABEL_IMAGE_W = 720 +LABEL_IMAGE_H = 530 +LABEL_PADDING = 23 +LABEL_FONT_NAME_SIZE = 42 +LABEL_FONT_CODE_SIZE = 49 +LABEL_FONT_BATCH_SIZE = 34 +LABEL_QR_SIZE = 210 + + +def _get_chinese_font(size: int) -> Optional["ImageFont.FreeTypeFont"]: + """Return a Chinese-capable font for PIL, or None to use default.""" + if ImageFont is None: + return None + # Prefer real font files on Windows (font *names* may fail and silently fallback). + if os.name == "nt": + fonts_dir = os.path.join(os.environ.get("WINDIR", r"C:\Windows"), "Fonts") + for rel in ( + "msjh.ttc", # Microsoft JhengHei + "msjhl.ttc", # Microsoft JhengHei Light + "msjhbd.ttc", # Microsoft JhengHei Bold + "mingliu.ttc", + "mingliub.ttc", + "kaiu.ttf", + "msyh.ttc", # Microsoft YaHei + "msyhbd.ttc", + "simhei.ttf", + "simsun.ttc", + ): + p = os.path.join(fonts_dir, rel) + try: + if os.path.exists(p): + return ImageFont.truetype(p, size) + except (OSError, IOError): + continue + # Fallback: try common font names (may still work depending on Pillow build) + for name in ( + "Microsoft JhengHei UI", + "Microsoft JhengHei", + "MingLiU", + "MingLiU_HKSCS", + "Microsoft YaHei", + "SimHei", + "SimSun", + ): + try: + return ImageFont.truetype(name, size) + except (OSError, IOError): + continue + try: + return ImageFont.load_default() + except Exception: + return None + + +def render_label_to_image( + batch_no: str, + item_code: str, + item_name: str, + item_id: Optional[int] = None, + stock_in_line_id: Optional[int] = None, + lot_no: Optional[str] = None, +) -> "Image.Image": + """ + Render 標簽機 label as a PIL Image (white bg, black text + QR). + Use this image for printing so Chinese displays correctly; words are drawn bigger. + Requires Pillow and qrcode. Raises RuntimeError if not available. + """ + if not _HAS_PIL_QR or Image is None or qrcode is None: + raise RuntimeError("Pillow and qrcode are required for image labels. Run: pip install Pillow qrcode[pil]") + img = Image.new("RGB", (LABEL_IMAGE_W, LABEL_IMAGE_H), "white") + draw = ImageDraw.Draw(img) + # QR payload (same as ZPL) + if item_id is not None and stock_in_line_id is not None: + qr_data = json.dumps({"itemId": item_id, "stockInLineId": stock_in_line_id}) + else: + qr_data = f"QA,{batch_no}" + # Draw QR top-left area + qr = qrcode.QRCode(box_size=4, border=2) + qr.add_data(qr_data) + qr.make(fit=True) + qr_img = qr.make_image(fill_color="black", back_color="white") + _resample = getattr(Image, "Resampling", Image).NEAREST + qr_img = qr_img.resize((LABEL_QR_SIZE, LABEL_QR_SIZE), _resample) + img.paste(qr_img, (LABEL_PADDING, LABEL_PADDING)) + # Fonts (bigger for readability) + 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) + x_right = LABEL_PADDING + LABEL_QR_SIZE + LABEL_PADDING + y_line = LABEL_PADDING + # Line 1: item name (wrap within remaining width) + name_str = (item_name or "—").strip() + max_name_w = LABEL_IMAGE_W - x_right - LABEL_PADDING + if font_name: + # Wrap rule: after 7 "words" (excl. parentheses). ()() not counted; +=*/. and A–Z/a–z count as 0.5. + def _wrap_text(text: str, font, max_width: int) -> list: + ignore = set("()()") + half = set("+=*/.abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ") + max_count = 6.5 + lines: list[str] = [] + current: list[str] = [] + count = 0.0 + + for ch in text: + if ch == "\n": + lines.append("".join(current).strip()) + current = [] + count = 0.0 + continue + + ch_count = 0.0 if ch in ignore else (0.5 if ch in half else 1.0) + if count + ch_count > max_count and current: + lines.append("".join(current).strip()) + current = [] + count = 0.0 + + current.append(ch) + count += ch_count + + if current: + lines.append("".join(current).strip()) + + # Max 2 rows for item name. If still long, keep everything in row 2. + if len(lines) > 2: + lines = [lines[0], "".join(lines[1:]).strip()] + + # Safety: if any line still exceeds pixel width, wrap by width as well. + if hasattr(draw, "textbbox"): + out: list[str] = [] + for ln in lines: + buf: list[str] = [] + for ch in ln: + buf.append(ch) + bbox = draw.textbbox((0, 0), "".join(buf), font=font) + if bbox[2] - bbox[0] > max_width and len(buf) > 1: + out.append("".join(buf[:-1]).strip()) + buf = [buf[-1]] + if buf: + out.append("".join(buf).strip()) + out = [x for x in out if x] + if len(out) > 2: + out = [out[0], "".join(out[1:]).strip()] + return out + + lines = [x for x in lines if x] + if len(lines) > 2: + lines = [lines[0], "".join(lines[1:]).strip()] + return lines + lines = _wrap_text(name_str, font_name, max_name_w) + for i, ln in enumerate(lines): + draw.text((x_right, y_line + i * (LABEL_FONT_NAME_SIZE + 4)), ln, font=font_name, fill="black") + y_line += len(lines) * (LABEL_FONT_NAME_SIZE + 4) + 8 + else: + draw.text((x_right, y_line), name_str[:30], fill="black") + y_line += LABEL_FONT_NAME_SIZE + 12 + # Item code (bigger) + code_str = (item_code or "—").strip() + if font_code: + draw.text((x_right, y_line), code_str, font=font_code, fill="black") + else: + draw.text((x_right, y_line), code_str, fill="black") + y_line += LABEL_FONT_CODE_SIZE + 6 + # Batch/lot line + batch_str = (lot_no or batch_no or "—").strip() + if font_batch: + draw.text((x_right, y_line), batch_str, font=font_batch, fill="black") + else: + draw.text((x_right, y_line), batch_str, fill="black") + return img + + +def _image_to_zpl_gfa(pil_image: "Image.Image") -> str: + """ + Convert a PIL image into ZPL ^GFA (ASCII hex) so we can print Chinese reliably + on ZPL printers (USB/Windows printer or COM) without relying on GDI drivers. + """ + if Image is None or ImageOps is None: + raise RuntimeError("Pillow is required for image-to-ZPL conversion.") + # Convert to 1-bit monochrome bitmap. Invert so '1' bits represent black in ZPL. + img_bw = ImageOps.invert(pil_image.convert("L")).convert("1") + w, h = img_bw.size + bytes_per_row = (w + 7) // 8 + raw = img_bw.tobytes() + total = bytes_per_row * h + # Ensure length matches expected (Pillow should already pack per row). + if len(raw) != total: + raw = raw[:total].ljust(total, b"\x00") + hex_data = raw.hex().upper() + return f"""^XA +^PW{w} +^LL{h} +^FO0,0 +^GFA,{total},{total},{bytes_per_row},{hex_data} +^FS +^XZ""" + + +def zpl_apply_print_quantity(zpl: str, copies: int) -> str: + """ + Ask the printer to output `copies` identical labels from one ZPL format by inserting ^PQ after ^XA. + Results in a **single** spool job / one write — no N separate Windows jobs, no chained ^XA blocks + (which broke some TSC drivers with white-on-white ^GFA output). + """ + if copies <= 1: + return zpl + first_fmt = zpl.split("^XZ", 1)[0] if "^XZ" in zpl else zpl + if "^PQ" in first_fmt.upper(): + return zpl + lines = zpl.splitlines() + new_lines: list[str] = [] + inserted = False + for line in lines: + new_lines.append(line) + if not inserted and line.strip() == "^XA": + # ZPL II: q labels, 0 pause between, 1 replicate (non-serial), N = default options. + # Same graphic (^GFA) is repeated q times — e.g. 需求數量 150 → 150 identical labels, one spool job. + new_lines.append(f"^PQ{copies},0,1,N") + inserted = True + if not inserted: + raise RuntimeError( + "標籤 ZPL 無法插入 ^PQ(格式非預期)。請聯絡程式維護。" + ) + ending = "\n" if zpl.endswith("\n") else "" + return "\n".join(new_lines) + ending + + +def send_image_to_label_printer(printer_name: str, pil_image: "Image.Image") -> None: + """ + Send a PIL Image to 標簽機 via Windows GDI (so Chinese and graphics print correctly). + Only supported when target is a Windows printer name (not COM port). Requires pywin32. + """ + dest = (printer_name or "").strip() + if not dest: + raise ValueError("Label printer destination is empty.") + if os.name != "nt" or dest.upper().startswith("COM"): + raise RuntimeError("Image printing is only supported for a Windows printer name (e.g. TSC TTP-246M Pro).") + if win32print is None or win32ui is None or win32con is None or win32gui is None: + raise RuntimeError("pywin32 is required. Run: pip install pywin32") + dc = win32ui.CreateDC() + dc.CreatePrinterDC(dest) + dc.StartDoc("FPSMS Label") + dc.StartPage() + try: + bmp_w = pil_image.width + bmp_h = pil_image.height + # Scale-to-fit printable area (important for smaller physical labels). + try: + page_w = int(dc.GetDeviceCaps(win32con.HORZRES)) + page_h = int(dc.GetDeviceCaps(win32con.VERTRES)) + except Exception: + page_w, page_h = bmp_w, bmp_h + if page_w <= 0 or page_h <= 0: + page_w, page_h = bmp_w, bmp_h + scale = min(page_w / max(1, bmp_w), page_h / max(1, bmp_h)) + out_w = max(1, int(bmp_w * scale)) + out_h = max(1, int(bmp_h * scale)) + x0 = max(0, (page_w - out_w) // 2) + y0 = max(0, (page_h - out_h) // 2) + + # Most reliable: render via Pillow ImageWin directly to printer DC. + if ImageWin is not None: + dib = ImageWin.Dib(pil_image.convert("RGB")) + dib.draw(dc.GetHandleOutput(), (x0, y0, x0 + out_w, y0 + out_h)) + else: + # Fallback: Draw image to printer DC via temp BMP (GDI uses BMP) + with tempfile.NamedTemporaryFile(suffix=".bmp", delete=False) as f: + tmp_bmp = f.name + try: + pil_image.save(tmp_bmp, "BMP") + hbm = win32gui.LoadImage( + 0, tmp_bmp, win32con.IMAGE_BITMAP, 0, 0, + win32con.LR_LOADFROMFILE | win32con.LR_CREATEDIBSECTION, + ) + if hbm == 0: + raise RuntimeError("Failed to load label image as bitmap.") + try: + mem_dc = win32ui.CreateDCFromHandle(win32gui.CreateCompatibleDC(dc.GetSafeHdc())) + bmp = getattr(win32ui, "CreateBitmapFromHandle", lambda h: win32ui.PyCBitmap.FromHandle(h))(hbm) + mem_dc.SelectObject(bmp) + dc.StretchBlt((x0, y0), (out_w, out_h), mem_dc, (0, 0), (bmp_w, bmp_h), win32con.SRCCOPY) + finally: + win32gui.DeleteObject(hbm) + finally: + try: + os.unlink(tmp_bmp) + except OSError: + pass + finally: + dc.EndPage() + dc.EndDoc() + + +def send_zpl_to_dataflex(ip: str, port: int, zpl: str) -> None: + """Send ZPL label (^XA…^XZ) to DataFlex printer via TCP. Raises on connection/send error.""" + sock = socket.socket(socket.AF_INET, socket.SOCK_STREAM) + try: + sock.setsockopt(socket.IPPROTO_TCP, socket.TCP_NODELAY, 1) + except OSError: + pass + sock.settimeout(DATAFLEX_SEND_TIMEOUT) + try: + sock.connect((ip, port)) + sock.sendall(_dataflex_zpl_bytes(zpl)) + time.sleep(DATAFLEX_POST_LABEL_SETTLE_SEC) + _dataflex_shutdown_write_maybe(sock) + finally: + sock.close() + + +def query_dataflex_host_status(ip: str, port: int) -> str: + """ + Query DataFlex/Zebra host status (~HS). Returns decoded status text, or empty string + when device does not return host status. + """ + sock = socket.socket(socket.AF_INET, socket.SOCK_STREAM) + try: + sock.setsockopt(socket.IPPROTO_TCP, socket.TCP_NODELAY, 1) + except OSError: + pass + sock.settimeout(max(0.2, DATAFLEX_STATUS_QUERY_TIMEOUT_SEC)) + try: + sock.connect((ip, port)) + sock.sendall(b"~HS\r\n") + chunks: list[bytes] = [] + while True: + try: + data = sock.recv(4096) + except socket.timeout: + break + except OSError as ex: + if _dataflex_is_benign_tcp_reset(ex): + break + raise + if not data: + break + chunks.append(data) + if sum(len(c) for c in chunks) >= 16384: + break + return b"".join(chunks).decode("utf-8", errors="ignore") + finally: + sock.close() + + +def _dataflex_status_has_e1005(status_text: str) -> bool: + s = (status_text or "").lower() + return "e1005" in s or "1005" in s + + +def _dataflex_status_problem_code(status_text: str) -> Optional[str]: + """If host status (~HS) suggests a fault, return a short code like E1000; else None.""" + s = (status_text or "").lower() + for code in ("e1000", "e1005", "e1004", "e1003", "e1002", "e1001"): + if code in s: + return code.upper() + return None + + +def assert_dataflex_host_ok(ip: str, port: int) -> None: + """ + Query ~HS once. If printer reports a known fault token, stop the job early. + Empty/short replies are ignored (some firmware is quiet). + """ + st = query_dataflex_host_status(ip, port) + if not (st or "").strip(): + return + prob = _dataflex_status_problem_code(st) + if prob is not None: + raise RuntimeError( + f"打袋機狀態異常 {prob}(~HS)。請看機台畫面處理後再印。" + ) + + +def recover_dataflex_if_host_fault(ip: str, port: int) -> None: + """ + If ~HS reports E1000/E1005/etc., clear host state once and continue — do not abort the whole run. + Keeps work short so the print thread does not look "frozen". + """ + st = query_dataflex_host_status(ip, port) + if not (st or "").strip(): + return + if _dataflex_status_problem_code(st) is None: + return + touch_dataflex_recovery_grace(14.0) + send_dataflex_job_counter_reset(ip, port, force=True) + send_dataflex_preprint_reset(ip, port, force=True) + time.sleep(max(0.35, DATAFLEX_RECOVERY_WAIT_SEC)) + + +def send_dataflex_label_with_recovery(ip: str, port: int, zpl: str) -> None: + """ + Send one bag label with one automatic recovery attempt. + + If first send fails (including firmware-latched states such as E1005), + perform full recovery (~JA/~RO/~JR), then light preprint reset (~JA/~RO), + and retry once. + """ + last_err: Optional[Exception] = None + for attempt in range(DATAFLEX_RECOVERY_MAX_ATTEMPTS): + try: + if DATAFLEX_AUTO_RESET_ENABLED and DATAFLEX_PREPRINT_EACH_LABEL: + send_dataflex_preprint_reset(ip, port) + send_zpl_to_dataflex(ip, port, zpl) + if DATAFLEX_VERIFY_STATUS_AFTER_SEND: + status_text = query_dataflex_host_status(ip, port) + if _dataflex_status_has_e1005(status_text): + raise RuntimeError("DataFlex E1005 detected from host status.") + return + except (ConnectionRefusedError, socket.timeout, OSError, RuntimeError) as ex: + last_err = ex + if attempt >= DATAFLEX_RECOVERY_MAX_ATTEMPTS - 1: + break + if DATAFLEX_AUTO_RESET_ENABLED or DATAFLEX_RECOVER_ON_SEND_ERROR: + touch_dataflex_recovery_grace(14.0) + send_dataflex_job_counter_reset(ip, port, force=True) + send_dataflex_preprint_reset(ip, port, force=True) + time.sleep(max(0.35, DATAFLEX_RECOVERY_WAIT_SEC)) + + if last_err is not None: + raise last_err + raise RuntimeError("DataFlex label send failed.") + + +def send_zpl_to_label_printer(target: str, zpl: str) -> None: + """ + Send ZPL to 標簽機. + + On Windows, if target is not a COM port (e.g. "TSC TTP-246M Pro"), + send raw ZPL to the named Windows printer via the spooler. + Otherwise, treat target as a serial COM port (original behaviour). + """ + dest = (target or "").strip() + if not dest: + raise ValueError("Label printer destination is empty.") + + # Unicode (^CI28); send UTF-8 to 標簽機 + raw_bytes = zpl.encode("utf-8") + + # Windows printer name path (USB printer installed as normal printer) + if os.name == "nt" and not dest.upper().startswith("COM"): + if win32print is None: + raise RuntimeError("pywin32 not installed. Run: pip install pywin32") + handle = win32print.OpenPrinter(dest) + try: + job = win32print.StartDocPrinter(handle, 1, ("FPSMS Label", None, "RAW")) + win32print.StartPagePrinter(handle) + win32print.WritePrinter(handle, raw_bytes) + win32print.EndPagePrinter(handle) + win32print.EndDocPrinter(handle) + finally: + win32print.ClosePrinter(handle) + return + + # Fallback: serial COM port + if serial is None: + raise RuntimeError("pyserial not installed. Run: pip install pyserial") + ser = serial.Serial(dest, timeout=5) + try: + ser.write(raw_bytes) + finally: + ser.close() + + +def send_zpl_to_label_printer_batch(target: str, zpl: str, copies: int) -> None: + """ + Print multiple identical ZPL labels in **exactly one** Windows spool job (or one COM write). + + Uses ZPL ^PQ so the printer firmware repeats the format N times — never one job per label. + """ + if copies < 1: + return + zpl_out = zpl_apply_print_quantity(zpl, copies) + send_zpl_to_label_printer(target, zpl_out) + + +def load_laser_last_count() -> tuple[int, Optional[str]]: + """Load last batch count and date from laser counter file. Returns (count, date_str).""" + if not os.path.exists(LASER_COUNTER_FILE): + return 0, None + try: + with open(LASER_COUNTER_FILE, "r", encoding="utf-8") as f: + lines = f.read().strip().splitlines() + if len(lines) >= 2: + return int(lines[1].strip()), lines[0].strip() + except Exception: + pass + return 0, None + + +def save_laser_last_count(date_str: str, count: int) -> None: + """Save laser batch count and date to file.""" + try: + with open(LASER_COUNTER_FILE, "w", encoding="utf-8") as f: + f.write(f"{date_str}\n{count}") + except Exception: + pass + + +LASER_PUSH_INTERVAL = 2 # seconds between pushes (like sample script) +# Click row with 激光機 selected: send this many times, delay between sends (not after last). +LASER_ROW_SEND_COUNT = 3 +LASER_ROW_SEND_DELAY_SEC = 3 + + +def laser_push_loop( + ip: str, + port: int, + stop_event: threading.Event, + root: tk.Tk, + on_error: Callable[[str], None], +) -> None: + """ + Run in a background thread: persistent connection to EZCAD, push B{yymmdd}{count:03d};; + every LASER_PUSH_INTERVAL seconds. Resets count each new day. Uses counter file. + """ + conn = None + push_count, last_saved_date = load_laser_last_count() + while not stop_event.is_set(): + try: + if conn is None: + conn = socket.socket(socket.AF_INET, socket.SOCK_STREAM) + conn.settimeout(0.4) + conn.connect((ip, port)) + now = datetime.now() + today_str = now.strftime("%y%m%d") + if last_saved_date != today_str: + push_count = 1 + last_saved_date = today_str + batch = f"B{today_str}{push_count:03d}" + reply = f"{batch};;" + conn.sendall(reply.encode("utf-8")) + save_laser_last_count(today_str, push_count) + rlist, _, _ = select.select([conn], [], [], 0.4) + if rlist: + data = conn.recv(4096) + if not data: + conn.close() + conn = None + push_count += 1 + for _ in range(int(LASER_PUSH_INTERVAL * 2)): + if stop_event.is_set(): + break + time.sleep(0.5) + except socket.timeout: + pass + except Exception as e: + if conn: + try: + conn.close() + except Exception: + pass + conn = None + try: + root.after(0, lambda msg=str(e): on_error(msg)) + except Exception: + pass + for _ in range(6): + if stop_event.is_set(): + break + time.sleep(0.5) + if conn: + try: + conn.close() + except Exception: + pass + + +def send_job_to_laser( + conn_ref: list, + ip: str, + port: int, + item_id: Optional[int], + stock_in_line_id: Optional[int], + item_code: str, + item_name: str, + expiry_text: Optional[str] = None, +) -> tuple[bool, str]: + """ + Send to laser using `;` separated params: + {"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). + Returns (success, message). + """ + code_str = (item_code or "").strip().replace(";", ",") + name_str = (item_name or "").strip().replace(";", ",") + exp_str = (expiry_text or "").strip().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. + json_part = json.dumps( + {"itemId": item_id, "stockInLineId": stock_in_line_id}, + separators=(",", ":"), + ) + head = json_part + else: + head = "0" + if exp_str: + reply = f"{head};{code_str};{name_str};{exp_str};;" + else: + reply = f"{head};{code_str};{name_str};;" + conn = conn_ref[0] + try: + if conn is None: + conn = socket.socket(socket.AF_INET, socket.SOCK_STREAM) + conn.settimeout(3.0) + conn.connect((ip, port)) + conn_ref[0] = conn + conn.settimeout(3.0) + conn.sendall(reply.encode("utf-8")) + conn.settimeout(0.5) + try: + data = conn.recv(4096) + if data: + ack = data.decode("utf-8", errors="ignore").strip().lower() + if "receive" in ack and "invalid" not in ack: + return True, f"已送出激光機:{reply}(已確認)" + except socket.timeout: + pass + return True, f"已送出激光機:{reply}" + except (ConnectionRefusedError, socket.timeout, OSError) as e: + if conn_ref[0] is not None: + try: + conn_ref[0].close() + except Exception: + pass + conn_ref[0] = None + if isinstance(e, ConnectionRefusedError): + return False, f"無法連線至 {ip}:{port},請確認激光機已開機且 IP 正確。" + if isinstance(e, socket.timeout): + return False, f"連線逾時 ({ip}:{port}),請檢查網路與連接埠。" + return False, f"激光機送出失敗:{e}" + + +def send_job_to_laser_with_retry( + conn_ref: list, + ip: str, + port: int, + item_id: Optional[int], + stock_in_line_id: Optional[int], + item_code: str, + item_name: str, + expiry_text: Optional[str] = None, +) -> tuple[bool, str]: + """Send job to laser; on failure, retry once. Returns (success, message).""" + ok, msg = send_job_to_laser( + conn_ref, ip, port, item_id, stock_in_line_id, item_code, item_name, expiry_text, + ) + if ok: + return True, msg + ok2, msg2 = send_job_to_laser( + conn_ref, ip, port, item_id, stock_in_line_id, item_code, item_name, expiry_text, + ) + return ok2, msg2 + + +def run_laser_row_send_thread( + root: tk.Tk, + laser_conn_ref: list, + laser_busy_ref: list, + ip: str, + port: int, + item_id: Optional[int], + stock_in_line_id: Optional[int], + item_code: str, + item_name: str, + set_status_message: Callable[[str, bool], None], + base_url: Optional[str] = None, + job_order_id: Optional[int] = None, + on_recorded: Optional[Callable[[], None]] = None, + expiry_text: Optional[str] = None, +) -> None: + """ + On row click with 激光機: send LASER_ROW_SEND_COUNT times with LASER_ROW_SEND_DELAY_SEC between sends. + UI updates on main thread; work runs in background so the window does not freeze. + After success, POST LASER qty to API when job_order_id and base_url are set. + """ + if laser_busy_ref[0]: + messagebox.showwarning("激光機", "請等待目前激光發送完成。") + return + laser_busy_ref[0] = True + + def worker() -> None: + try: + n = LASER_ROW_SEND_COUNT + for i in range(n): + ok, msg = send_job_to_laser_with_retry( + laser_conn_ref, + ip, + port, + item_id, + stock_in_line_id, + item_code, + item_name, + expiry_text, + ) + if not ok: + root.after( + 0, + lambda m=msg: messagebox.showwarning("激光機", m), + ) + return + if i < n - 1: + time.sleep(LASER_ROW_SEND_DELAY_SEC) + posted = False + if base_url and job_order_id is not None: + try: + submit_job_order_print_submit(base_url, int(job_order_id), n, "LASER") + posted = True + except requests.RequestException as ex: + root.after( + 0, + lambda err=str(ex): messagebox.showwarning( + "激光機", + f"已發送,但伺服器記錄失敗:{err}", + ), + ) + elif base_url: + root.after( + 0, + lambda: messagebox.showwarning( + "激光機", + "已發送,但無工單 id,無法寫入伺服器記錄。", + ), + ) + root.after( + 0, + lambda: set_status_message("已發送", is_error=False), + ) + if on_recorded is not None and posted: + root.after(0, on_recorded) + except Exception as e: + root.after( + 0, + lambda err=str(e): messagebox.showwarning("激光機", f"送出失敗:{err}"), + ) + finally: + laser_busy_ref[0] = False + + threading.Thread(target=worker, daemon=True).start() + + +def run_dataflex_fixed_qty_thread( + root: tk.Tk, + dataflex_lock: threading.Lock, + dataflex_busy_ref: list, + ip: str, + port: int, + n: int, + zpl: str, + label_text: str, + jo_id: Optional[int], + base_url: str, + set_status_message: Callable[[str, bool], None], + on_recorded: Callable[[], None], +) -> None: + """ + Send n DataFlex labels with delay between copies. Runs off the Tk main thread so the UI + stays responsive (printer dropdown, other controls) during printing. + """ + def worker() -> None: + with dataflex_lock: + if dataflex_busy_ref[0]: + root.after( + 0, + lambda: messagebox.showwarning( + "打袋機", + "請等待目前列印完成或先停止連續列印。", + ), + ) + return + dataflex_busy_ref[0] = True + printed = 0 + used_single_tcp = False + try: + send_dataflex_start_job_reset(ip, port, force=True) + if DATAFLEX_SINGLE_TCP_JOB and n >= 1: + # One TCP connection, one ZPL, ^PQn — printer firmware prints n identical bags. + used_single_tcp = True + zpl_one = dataflex_zpl_set_print_quantity(zpl, n) + root.after( + 0, + lambda tn=n: set_status_message( + f"打袋單次發送中… {tn} 張(^PQ{tn})", + is_error=False, + ), + ) + send_dataflex_label_with_recovery(ip, port, zpl_one) + if DATAFLEX_VERIFY_EVERY_LABELS > 0: + recover_dataflex_if_host_fault(ip, port) + printed = n + else: + # One TCP job per bag. Slower but avoids E1005 on some units when ^PQ is large. + for i in range(n): + send_dataflex_label_with_recovery(ip, port, zpl) + printed += 1 + if DATAFLEX_UI_PROGRESS_EVERY > 0 and ( + printed == 1 or printed % DATAFLEX_UI_PROGRESS_EVERY == 0 + ): + p, t = printed, n + root.after( + 0, + lambda p=p, t=t: set_status_message( + f"打袋列印中… {p}/{t}", + is_error=False, + ), + ) + if ( + DATAFLEX_VERIFY_EVERY_LABELS > 0 + and printed % DATAFLEX_VERIFY_EVERY_LABELS == 0 + ): + recover_dataflex_if_host_fault(ip, port) + if ( + DATAFLEX_COOLDOWN_EVERY_LABELS > 0 + and printed % DATAFLEX_COOLDOWN_EVERY_LABELS == 0 + and i < n - 1 + ): + time.sleep(max(0.0, DATAFLEX_COOLDOWN_SEC)) + if ( + DATAFLEX_THERMAL_REST_EVERY_LABELS > 0 + and printed % DATAFLEX_THERMAL_REST_EVERY_LABELS == 0 + and i < n - 1 + ): + time.sleep(max(0.0, DATAFLEX_THERMAL_REST_SEC)) + if i < n - 1: + time.sleep(DATAFLEX_INTER_LABEL_DELAY_SEC) + root.after( + 0, + lambda u=used_single_tcp: set_status_message( + ( + f"已送出列印(單次 TCP):批次 {label_text} x {n} 張" + if u + else f"已送出列印:批次 {label_text} x {n} 張" + ), + is_error=False, + ), + ) + if jo_id is not None: + try: + submit_job_order_print_submit(base_url, int(jo_id), n, "DATAFLEX") + root.after(0, on_recorded) + except requests.RequestException as ex: + root.after( + 0, + lambda err=str(ex): messagebox.showwarning( + "打袋機", + f"列印可能已完成,但伺服器記錄失敗(可再試):{err}", + ), + ) + else: + root.after( + 0, + lambda: messagebox.showwarning( + "打袋機", + f"已送出列印 {n} 張,但無工單 id,無法寫入伺服器記錄。", + ), + ) + except ConnectionRefusedError: + root.after( + 0, + lambda: set_status_message( + f"無法連線至 {ip}:{port},已送出 {printed}/{n} 張。", + is_error=True, + ), + ) + except socket.timeout: + root.after( + 0, + lambda: set_status_message( + f"連線逾時 ({ip}:{port}),已送出 {printed}/{n} 張。", + is_error=True, + ), + ) + except OSError as err: + root.after( + 0, + lambda e=err: set_status_message( + f"列印失敗:{e}(已送出 {printed}/{n} 張)", + is_error=True, + ), + ) + except RuntimeError as err: + root.after( + 0, + lambda e=err: set_status_message( + f"打袋機錯誤:{e}(已送出 {printed}/{n} 張)", + is_error=True, + ), + ) + except Exception as err: + root.after( + 0, + lambda e=err: set_status_message( + f"打袋機例外:{e}(已送出 {printed}/{n} 張)", + is_error=True, + ), + ) + finally: + with dataflex_lock: + dataflex_busy_ref[0] = False + + threading.Thread(target=worker, daemon=True).start() + + +def run_label_print_batch_thread( + root: tk.Tk, + label_lock: threading.Lock, + label_busy_ref: list, + com: str, + zpl_img: str, + n: int, + jo_id: Optional[int], + base_url: str, + set_status_message: Callable[[str, bool], None], + on_recorded: Callable[[], None], +) -> None: + """ + Send n label copies off the main thread so DataFlex / laser / UI stay usable in parallel. + """ + def worker() -> None: + with label_lock: + if label_busy_ref[0]: + root.after( + 0, + lambda: messagebox.showwarning( + "標籤機", + "請等待目前標籤列印完成。", + ), + ) + return + label_busy_ref[0] = True + try: + send_zpl_to_label_printer_batch(com, zpl_img, n) + root.after( + 0, + lambda: set_status_message(f"已送出列印:標籤 x {n} 張", is_error=False), + ) + if jo_id is not None: + try: + submit_job_order_print_submit(base_url, int(jo_id), n, "LABEL") + root.after(0, on_recorded) + root.after( + 0, + lambda: messagebox.showinfo( + "標籤機", + f"已送出列印:{n} 張標籤(已記錄)", + ), + ) + except requests.RequestException as ex: + root.after( + 0, + lambda err=str(ex): messagebox.showwarning( + "標籤機", + f"標籤已列印 {n} 張,但伺服器記錄失敗:{err}", + ), + ) + else: + root.after( + 0, + lambda: messagebox.showwarning( + "標籤機", + f"已送出列印:{n} 張標籤(無工單 id,無法寫入伺服器記錄)", + ), + ) + except Exception as err: + root.after( + 0, + lambda e=str(err): messagebox.showerror("標籤機", f"列印失敗:{e}"), + ) + finally: + with label_lock: + label_busy_ref[0] = False + + threading.Thread(target=worker, daemon=True).start() + + +def _printed_qty_int(raw) -> int: + """Parse API printed qty field (may be float JSON) to int.""" + try: + return int(float(raw)) if raw is not None else 0 + except (TypeError, ValueError): + return 0 + + +def _filter_job_orders_by_search(data: list, needle: str) -> list: + """Substring match on item code, job order code, item name, lot (case-insensitive).""" + n = needle.strip().lower() + if not n: + return data + out: list = [] + for jo in data: + parts = [ + str(jo.get("itemCode") or ""), + str(jo.get("code") or ""), + str(jo.get("itemName") or ""), + str(jo.get("lotNo") or ""), + ] + if any(n in p.lower() for p in parts): + out.append(jo) + return out + + +def format_qty(val) -> str: + """Format quantity: integer without .0, with thousand separator.""" + if val is None: + return "—" + try: + n = float(val) + if n == int(n): + return f"{int(n):,}" + return f"{n:,.2f}".rstrip("0").rstrip(".") + except (TypeError, ValueError): + return str(val) + + +def batch_no(year: int, job_order_id: int) -> str: + """Batch no.: B + 4-digit year + jobOrderId zero-padded to 6 digits.""" + return f"B{year}{job_order_id:06d}" + + +def get_font(size: int = FONT_SIZE, bold: bool = False) -> tuple: + try: + return (FONT_FAMILY, size, "bold" if bold else "normal") + except Exception: + return ("TkDefaultFont", size, "bold" if bold else "normal") + + +def fetch_job_orders(base_url: str, plan_start: date) -> list: + """Call GET /py/job-orders and return the JSON list.""" + url = f"{base_url.rstrip('/')}/py/job-orders" + params = {"planStart": plan_start.isoformat()} + resp = requests.get(url, params=params, timeout=30) + resp.raise_for_status() + return resp.json() + + +def submit_job_order_print_submit( + base_url: str, + job_order_id: int, + qty: int, + print_channel: str = "LABEL", +) -> None: + """ + Record printed quantity in the FPSMS database via PyController. + + POST ``/api/py/job-order-print-submit`` (path under base_url) — **public endpoint, no login** + or API key required. Each successful call appends one row to ``py_job_order_print_submit``; + totals per job order and channel are aggregated server-side. + + Raises ``requests.RequestException`` if all retry attempts fail. + """ + url = f"{base_url.rstrip('/')}/py/job-order-print-submit" + payload = { + "jobOrderId": int(job_order_id), + "qty": int(qty), + "printChannel": print_channel, + } + last_err: Optional[Exception] = None + for attempt in range(PRINT_SUBMIT_MAX_ATTEMPTS): + try: + resp = requests.post(url, json=payload, timeout=30) + resp.raise_for_status() + return + except requests.RequestException as ex: + last_err = ex + if attempt < PRINT_SUBMIT_MAX_ATTEMPTS - 1: + time.sleep(PRINT_SUBMIT_RETRY_DELAY_SEC) + if last_err is not None: + raise last_err + raise RuntimeError("submit_job_order_print_submit: unexpected empty error") + + +def set_row_highlight(row_frame: tk.Frame, selected: bool) -> None: + """Set row and all nested Frame/Label children to selected or normal background.""" + bg = BG_ROW_SELECTED if selected else BG_ROW + + def _paint(w: tk.Misc) -> None: + if isinstance(w, (tk.Frame, tk.Label)): + w.configure(bg=bg) + for c in w.winfo_children(): + _paint(c) + + _paint(row_frame) + + +def on_job_order_click(jo: dict, batch: str) -> None: + """Show message and highlight row (keeps printing to selected printer).""" + item_code = jo.get("itemCode") or "—" + item_name = jo.get("itemName") or "—" + messagebox.showinfo( + "工單", + f'已點選:批次 {batch}\n品號 {item_code} {item_name}', + ) + + +def ask_label_count(parent: tk.Tk) -> Optional[int]: + """ + When printer is 標簽機, ask how many labels to print: + optional direct qty in text field (e.g. 150), +50/+10/+5/+1, 重置, then 確認送出. + That count becomes ZPL ^PQ in one job — 150 → 150 identical labels. + Returns count (>= 1), or None if cancelled. + """ + result: list[Optional[int]] = [None] + qty_var = tk.StringVar(value="0") + + win = tk.Toplevel(parent) + win.title("標簽印數") + win.geometry("580x280") + win.transient(parent) + win.grab_set() + win.configure(bg=BG_TOP) + ttk.Label(win, text="印多少個?", font=get_font(FONT_SIZE)).pack(pady=(12, 4)) + + entry_row = tk.Frame(win, bg=BG_TOP) + entry_row.pack(pady=8) + tk.Label(entry_row, text="需求數量:", font=get_font(FONT_SIZE), bg=BG_TOP).pack(side=tk.LEFT, padx=(0, 6)) + qty_entry = tk.Entry( + entry_row, + textvariable=qty_var, + width=12, + font=get_font(FONT_SIZE), + bg="white", + justify=tk.RIGHT, + ) + qty_entry.pack(side=tk.LEFT, padx=4) + + def current_qty() -> int: + s = (qty_var.get() or "").strip().replace(",", "") + if not s: + return 0 + try: + return max(0, int(s)) + except ValueError: + return 0 + + def reset_qty() -> None: + qty_var.set("0") + + ttk.Button(entry_row, text="重置", command=reset_qty, width=8).pack(side=tk.LEFT, padx=8) + + def add(n: int) -> None: + qty_var.set(str(current_qty() + n)) + + def confirm() -> None: + q = current_qty() + if q < 1: + messagebox.showwarning("標簽機", "請輸入需求數量或按 +50、+10、+5、+1。", parent=win) + return + result[0] = q + win.destroy() + + btn_row1 = tk.Frame(win, bg=BG_TOP) + btn_row1.pack(pady=8) + for label, value in [("+50", 50), ("+10", 10), ("+5", 5), ("+1", 1)]: + def make_add(v: int): + return lambda: add(v) + ttk.Button(btn_row1, text=label, command=make_add(value), width=8).pack(side=tk.LEFT, padx=4) + + ttk.Button(win, text="確認送出", command=confirm, width=14).pack(pady=12) + qty_entry.bind("", lambda e: confirm()) + win.protocol("WM_DELETE_WINDOW", win.destroy) + win.wait_window() + return result[0] + +def ask_bag_count(parent: tk.Tk) -> Optional[Tuple[int, bool]]: + """ + When printer is 打袋機 DataFlex: qty with +按鈕 then 確認送出, or big bottom「C」for continuous. + Returns (count, continuous_print). If continuous_print is True, count is ignored (use 0). + None if cancelled. + """ + result: list[Optional[Tuple[int, bool]]] = [None] + qty_var = tk.StringVar(value="0") + + win = tk.Toplevel(parent) + win.title("打袋列印數量") + win.geometry("580x420") + win.transient(parent) + win.grab_set() + win.configure(bg=BG_TOP) + ttk.Label(win, text="列印多少個袋?", font=get_font(FONT_SIZE)).pack(pady=(12, 4)) + + entry_row = tk.Frame(win, bg=BG_TOP) + entry_row.pack(pady=8) + tk.Label(entry_row, text="需求數量:", font=get_font(FONT_SIZE), bg=BG_TOP).pack(side=tk.LEFT, padx=(0, 6)) + qty_entry = tk.Entry( + entry_row, + textvariable=qty_var, + width=12, + font=get_font(FONT_SIZE), + bg="white", + justify=tk.RIGHT, + ) + qty_entry.pack(side=tk.LEFT, padx=4) + + def current_qty() -> int: + s = (qty_var.get() or "").strip().replace(",", "") + if not s: + return 0 + try: + return max(0, int(s)) + except ValueError: + return 0 + + def reset_qty() -> None: + qty_var.set("0") + + ttk.Button(entry_row, text="重置", command=reset_qty, width=8).pack(side=tk.LEFT, padx=8) + + def add(n: int) -> None: + qty_var.set(str(current_qty() + n)) + + def confirm() -> None: + q = current_qty() + if q < 1: + messagebox.showwarning("打袋機", "請輸入需求數量或按 +50、+10、+5、+1。", parent=win) + return + result[0] = (q, False) + win.destroy() + + def start_continuous() -> None: + """Big C: continuous print until 停止; counter reset at job start.""" + result[0] = (0, True) + win.destroy() + + btn_row1 = tk.Frame(win, bg=BG_TOP) + btn_row1.pack(pady=8) + for label, value in [("+50", 50), ("+10", 10), ("+5", 5), ("+1", 1)]: + def make_add(v: int): + return lambda: add(v) + ttk.Button(btn_row1, text=label, command=make_add(value), width=8).pack(side=tk.LEFT, padx=4) + + ttk.Button(win, text="確認送出", command=confirm, width=14).pack(pady=12) + qty_entry.bind("", lambda e: confirm()) + + sep = ttk.Separator(win, orient=tk.HORIZONTAL) + sep.pack(fill=tk.X, padx=16, pady=(4, 8)) + + bottom = tk.Frame(win, bg=BG_TOP) + bottom.pack(fill=tk.X, padx=12, pady=(0, 12)) + tk.Label( + bottom, + text="連續出袋 · 每單開始重設計數 · 另開視窗按「停止列印」結束", + font=get_font(FONT_SIZE_META), + bg=BG_TOP, + fg="#333333", + wraplength=540, + justify=tk.CENTER, + ).pack(fill=tk.X, pady=(0, 6)) + + tk.Button( + bottom, + text="C(連續印)", + command=start_continuous, + font=(FONT_FAMILY, 38, "bold"), + bg="#2E7D32", + fg="white", + activebackground="#1B5E20", + activeforeground="white", + relief=tk.RAISED, + bd=4, + cursor="hand2", + padx=24, + pady=18, + ).pack(fill=tk.X) + win.protocol("WM_DELETE_WINDOW", win.destroy) + win.wait_window() + return result[0] + + +@dataclass(frozen=True) +class DataflexPrintSession: + """ + Snapshot taken when the user starts DataFlex print (especially C 連續印). + The worker must use only this object — not grid row index, scroll position, or selection. + """ + + job_order_id: Optional[int] + job_code: str + item_code: str + item_name: str + label_text: str + zpl: str + printer_ip: str + printer_port: int + batch_display: str + + +def build_dataflex_print_session( + jo: dict, + batch: str, + zpl: str, + label_text: str, + printer_ip: str, + printer_port: int, +) -> DataflexPrintSession: + jo_id = jo.get("id") + jo_code = (jo.get("code") or "").strip() + if not jo_code and jo_id is not None: + jo_code = f"#{jo_id}" + elif not jo_code: + jo_code = "—" + return DataflexPrintSession( + job_order_id=int(jo_id) if jo_id is not None else None, + job_code=jo_code, + item_code=(jo.get("itemCode") or "—").strip(), + item_name=(jo.get("itemName") or "—").strip(), + label_text=label_text, + zpl=zpl, + printer_ip=printer_ip, + printer_port=printer_port, + batch_display=(batch or "—").strip(), + ) + + +def run_dataflex_continuous_thread( + root: tk.Tk, + session: DataflexPrintSession, + stop_event: threading.Event, + stop_win: tk.Toplevel, + dataflex_lock: threading.Lock, + dataflex_busy_ref: list, + dataflex_stop_win_ref: list, + active_session_ref: list, + base_url: str, + set_status_message: Callable[[str, bool], None], + on_recorded: Callable[[], None], +) -> None: + """Send bags in a loop until stop_event; all payload comes from session (in-memory snapshot).""" + + def worker() -> None: + with dataflex_lock: + if dataflex_busy_ref[0]: + active_session_ref[0] = None + + def _abort_start() -> None: + messagebox.showwarning( + "打袋機", + "請等待目前列印完成或先停止連續列印。", + ) + dataflex_stop_win_ref[0] = None + try: + stop_win.destroy() + except tk.TclError: + pass + + root.after(0, _abort_start) + return + dataflex_busy_ref[0] = True + + ip = session.printer_ip + port = session.printer_port + zpl = session.zpl + label_text = session.label_text + printed = 0 + error_shown = False + try: + send_dataflex_start_job_reset(ip, port, force=True) + while not stop_event.is_set(): + send_dataflex_label_with_recovery(ip, port, zpl) + printed += 1 + if DATAFLEX_UI_PROGRESS_EVERY > 0 and ( + printed == 1 or printed % DATAFLEX_UI_PROGRESS_EVERY == 0 + ): + p = printed + root.after( + 0, + lambda p=p, jc=session.job_code: set_status_message( + f"連續打袋 · 工單 {jc}… 已印 {p} 張", + is_error=False, + ), + ) + if ( + DATAFLEX_VERIFY_EVERY_LABELS > 0 + and printed % DATAFLEX_VERIFY_EVERY_LABELS == 0 + ): + recover_dataflex_if_host_fault(ip, port) + if ( + DATAFLEX_COOLDOWN_EVERY_LABELS > 0 + and printed % DATAFLEX_COOLDOWN_EVERY_LABELS == 0 + ): + _sleep_interruptible(stop_event, max(0.0, DATAFLEX_COOLDOWN_SEC)) + if ( + DATAFLEX_THERMAL_REST_EVERY_LABELS > 0 + and printed % DATAFLEX_THERMAL_REST_EVERY_LABELS == 0 + ): + _sleep_interruptible(stop_event, max(0.0, DATAFLEX_THERMAL_REST_SEC)) + _sleep_interruptible(stop_event, DATAFLEX_INTER_LABEL_DELAY_SEC) + except ConnectionRefusedError: + error_shown = True + root.after( + 0, + lambda: set_status_message( + f"無法連線至 {ip}:{port},請確認印表機已開機且 IP 正確。", + is_error=True, + ), + ) + except socket.timeout: + error_shown = True + root.after( + 0, + lambda: set_status_message( + f"連線逾時 ({ip}:{port}),請檢查網路與連接埠。", + is_error=True, + ), + ) + except OSError as err: + error_shown = True + root.after( + 0, + lambda e=err: set_status_message(f"列印失敗:{e}", is_error=True), + ) + except RuntimeError as err: + error_shown = True + root.after( + 0, + lambda e=err: set_status_message(f"打袋機錯誤:{e}", is_error=True), + ) + except Exception as err: + error_shown = True + root.after( + 0, + lambda e=err: set_status_message(f"打袋機例外:{e}", is_error=True), + ) + finally: + with dataflex_lock: + dataflex_busy_ref[0] = False + active_session_ref[0] = None + + def _done() -> None: + dataflex_stop_win_ref[0] = None + try: + if os.name == "nt": + stop_win.attributes("-topmost", False) + except tk.TclError: + pass + try: + stop_win.destroy() + except tk.TclError: + pass + jc = session.job_code + if printed > 0: + set_status_message( + f"連續列印結束:工單 {jc} · {label_text},已印 {printed} 張", + is_error=False, + ) + if session.job_order_id is not None: + try: + submit_job_order_print_submit( + base_url, + session.job_order_id, + printed, + "DATAFLEX", + ) + on_recorded() + except requests.RequestException as ex: + messagebox.showwarning( + "打袋機", + f"列印可能已完成,但伺服器記錄失敗(可再試):{ex}", + ) + elif not error_shown: + set_status_message("連續列印未印出或已取消", is_error=True) + + root.after(0, _done) + + threading.Thread(target=worker, daemon=True).start() + + +def _sleep_interruptible(stop_event: threading.Event, total_sec: float) -> None: + """Sleep up to total_sec but return early if stop_event is set.""" + end = time.perf_counter() + total_sec + while time.perf_counter() < end: + if stop_event.is_set(): + return + remaining = end - time.perf_counter() + if remaining <= 0: + break + time.sleep(min(0.05, remaining)) + + +def open_dataflex_stop_window( + parent: tk.Tk, + stop_event: threading.Event, + stop_win_ref: list, + session: DataflexPrintSession, +) -> tk.Toplevel: + """ + Small window with 停止列印 for DataFlex continuous mode (non-modal so stop stays usable). + + Stays above other dialogs (e.g. 標籤機 quantity) via periodic lift + optional topmost on Windows, + so switching printer and printing labels does not hide the stop control. Ref is cleared on destroy. + Job details come from the in-memory session snapshot, not the grid selection. + """ + win = tk.Toplevel(parent) + win.title("打袋機連續列印") + win.geometry("480x240") + # On Windows, transient(root) can hide this Toplevel when the menubutton / printer row + # updates (e.g. switching to 激光機); keep transient only on non-Windows. + if os.name != "nt": + win.transient(parent) + win.configure(bg=BG_TOP) + stop_win_ref[0] = win + if os.name == "nt": + try: + win.attributes("-topmost", True) + except tk.TclError: + pass + + tk.Label( + win, + text="連續列印進行中(內容以按下 C 時的工單為準,與列表捲動/日期無關)", + font=get_font(FONT_SIZE_META), + bg=BG_TOP, + wraplength=440, + justify=tk.CENTER, + ).pack(pady=(12, 6)) + detail = ( + f"工單:{session.job_code}\n" + f"品號:{session.item_code}\n" + f"品名:{session.item_name}\n" + f"批次/批號:{session.label_text}" + ) + tk.Label( + win, + text=detail, + font=get_font(FONT_SIZE), + bg=BG_TOP, + fg="#111111", + wraplength=440, + justify=tk.LEFT, + anchor=tk.W, + ).pack(padx=16, pady=(0, 8), fill=tk.X) + + def clear_topmost() -> None: + if os.name == "nt": + try: + win.attributes("-topmost", False) + except tk.TclError: + pass + + def stop() -> None: + stop_event.set() + stop_win_ref[0] = None + clear_topmost() + try: + win.destroy() + except tk.TclError: + pass + + def periodic_lift() -> None: + if stop_win_ref[0] is not win: + return + try: + if not win.winfo_exists(): + return + win.lift() + if os.name == "nt": + win.attributes("-topmost", True) + except tk.TclError: + return + parent.after(4000, periodic_lift) + + tk.Button( + win, + text="停止列印", + command=stop, + font=get_font(FONT_SIZE_BUTTONS), + bg=BG_STATUS_ERROR, + fg=FG_STATUS_ERROR, + padx=20, + pady=10, + ).pack(pady=12) + win.protocol("WM_DELETE_WINDOW", stop) + parent.after(500, periodic_lift) + return win + + +def main() -> None: + settings = load_settings() + base_url_ref = [build_base_url(settings["api_ip"], settings["api_port"])] + + root = tk.Tk() + root.title(f"FP-MTMS Bag4 v{APP_VERSION} 打袋機") + root.geometry("1120x960") + root.minsize(480, 360) + root.configure(bg=BG_ROOT) + + # Style: larger font for aged users; light blue theme + style = ttk.Style() + try: + style.configure(".", font=get_font(FONT_SIZE), background=BG_TOP) + style.configure("TButton", font=get_font(FONT_SIZE_BUTTONS), background=BG_TOP) + style.configure("TLabel", font=get_font(FONT_SIZE), background=BG_TOP) + style.configure("TEntry", font=get_font(FONT_SIZE)) + style.configure("TFrame", background=BG_TOP) + # TCombobox field (if other combos use ttk later) + style.configure("TCombobox", font=get_font(FONT_SIZE_COMBO)) + except tk.TclError: + pass + + # Status bar at top: connection state (no popup on error) + status_frame = tk.Frame(root, bg=BG_STATUS_ERROR, padx=12, pady=6) + status_frame.pack(fill=tk.X) + status_lbl = tk.Label( + status_frame, + text="連接不到服務器", + font=get_font(FONT_SIZE_BUTTONS), + bg=BG_STATUS_ERROR, + fg=FG_STATUS_ERROR, + anchor=tk.CENTER, + ) + status_lbl.pack(fill=tk.X) + + def set_status_ok(): + status_frame.configure(bg=BG_STATUS_OK) + status_lbl.configure(text="連接正常", bg=BG_STATUS_OK, fg=FG_STATUS_OK) + + def set_status_error(): + status_frame.configure(bg=BG_STATUS_ERROR) + status_lbl.configure(text="連接不到服務器", bg=BG_STATUS_ERROR, fg=FG_STATUS_ERROR) + + def set_status_message(msg: str, is_error: bool = False) -> None: + """Show a message on the status bar.""" + if is_error: + status_frame.configure(bg=BG_STATUS_ERROR) + status_lbl.configure(text=msg, bg=BG_STATUS_ERROR, fg=FG_STATUS_ERROR) + else: + status_frame.configure(bg=BG_STATUS_OK) + status_lbl.configure(text=msg, bg=BG_STATUS_OK, fg=FG_STATUS_OK) + + # Laser: keep connection open for repeated sends; close when switching away + laser_conn_ref: list = [None] + laser_send_busy_ref: list = [False] + # DataFlex: shared lock so fixed-qty and continuous jobs do not overlap (independent of laser/label) + dataflex_lock = threading.Lock() + dataflex_busy_ref: list = [False] + # Suppress transient DataFlex "disconnected" UI while we intentionally reset/print. + dataflex_status_grace_until_ref: list[float] = [0.0] + # 標籤機: own lock so label jobs do not overlap; does not block DataFlex or laser + label_lock = threading.Lock() + label_busy_ref: list = [False] + # DataFlex continuous: stop Toplevel ref so we can lift it after other dialogs + dataflex_stop_win_ref: list = [None] + # In-memory job snapshot for C 連續印 (not tied to grid row position after start) + active_dataflex_session_ref: list[Optional[DataflexPrintSession]] = [None] + + def lift_dataflex_stop_if_running() -> None: + """After closing another dialog (e.g. 標籤印數), bring the stop panel forward again.""" + w = dataflex_stop_win_ref[0] + if w is None: + return + try: + if w.winfo_exists(): + w.lift() + if os.name == "nt": + w.attributes("-topmost", True) + except tk.TclError: + pass + + def hold_dataflex_status_ok(seconds: float) -> None: + until = time.time() + max(0.0, seconds) + if until > dataflex_status_grace_until_ref[0]: + dataflex_status_grace_until_ref[0] = until + + # Top: left [前一天] [date] [後一天] | right [printer dropdown] + top = tk.Frame(root, padx=12, pady=12, bg=BG_TOP) + top.pack(fill=tk.X) + + date_var = tk.StringVar(value=date.today().isoformat()) + printer_options = ["打袋機 DataFlex", "標簽機", "激光機"] + printer_var = tk.StringVar(value=printer_options[0]) + + def go_prev_day() -> None: + try: + d = date.fromisoformat(date_var.get().strip()) + date_var.set((d - timedelta(days=1)).isoformat()) + load_job_orders(from_user_date_change=True) + except ValueError: + date_var.set(date.today().isoformat()) + load_job_orders(from_user_date_change=True) + + def go_next_day() -> None: + try: + d = date.fromisoformat(date_var.get().strip()) + date_var.set((d + timedelta(days=1)).isoformat()) + load_job_orders(from_user_date_change=True) + except ValueError: + date_var.set(date.today().isoformat()) + load_job_orders(from_user_date_change=True) + + # 前一天 (previous day) with left arrow icon + btn_prev = ttk.Button(top, text="◀ 前一天", command=go_prev_day) + btn_prev.pack(side=tk.LEFT, padx=(0, 8)) + + # Date field (no "日期:" label); shorter width + date_entry = tk.Entry( + top, + textvariable=date_var, + font=get_font(FONT_SIZE), + width=10, + bg="white", + ) + date_entry.pack(side=tk.LEFT, padx=(0, 8), ipady=4) + + # 後一天 (next day) with right arrow icon + btn_next = ttk.Button(top, text="後一天 ▶", command=go_next_day) + btn_next.pack(side=tk.LEFT, padx=(0, 8)) + + # Top right: Setup button + printer selection + right_frame = tk.Frame(top, bg=BG_TOP) + right_frame.pack(side=tk.RIGHT) + ttk.Button(right_frame, text="設定", command=lambda: open_setup_window(root, settings, base_url_ref)).pack( + side=tk.LEFT, padx=(0, 12) + ) + + def on_dataflex_host_reset() -> None: + """Send ~JA/~RO/~JR to clear E1005 latch without turning the printer off.""" + ip = (settings.get("dabag_ip") or "").strip() + port_str = (settings.get("dabag_port") or "3008").strip() + if not ip: + messagebox.showwarning("打袋機", "請先在「設定」填寫打袋機 IP。") + return + try: + port = int(port_str) + except ValueError: + port = 3008 + hold_dataflex_status_ok(12.0) + + def worker() -> None: + try: + send_dataflex_job_counter_reset(ip, port, force=True) + root.after( + 0, + lambda: messagebox.showinfo( + "打袋機", + "已送出主機重設(緩衝清除/計數/軟重設)。\n" + "若畫面仍顯示 E1005,請再按一次或關機重開。", + ), + ) + except OSError as ex: + root.after( + 0, + lambda e=str(ex): messagebox.showerror( + "打袋機", + f"連線失敗,無法重設:{e}", + ), + ) + + threading.Thread(target=worker, daemon=True).start() + + ttk.Button(right_frame, text="打袋重設", command=on_dataflex_host_reset).pack( + side=tk.LEFT, padx=(0, 8) + ) + # 列印機 label: green when printer connected, red when not (checked periodically) + printer_status_lbl = tk.Label( + right_frame, + text="列印機:", + font=get_font(FONT_SIZE), + bg=BG_STATUS_ERROR, + fg="black", + padx=6, + pady=2, + ) + printer_status_lbl.pack(side=tk.LEFT, padx=(0, 4)) + # tk.OptionMenu (not ttk.Combobox): on Windows the ttk dropdown uses OS font and stays tiny; + # OptionMenu's menu supports font= for the open list. + printer_combo = tk.OptionMenu(right_frame, printer_var, *printer_options) + _combo_font = get_font(FONT_SIZE_COMBO) + printer_combo.configure( + font=_combo_font, + bg=BG_TOP, + fg="black", + activebackground=BG_TOP, + activeforeground="black", + width=14, + anchor="w", + highlightthickness=0, + bd=1, + relief=tk.GROOVE, + ) + printer_combo["menu"].configure(font=_combo_font, tearoff=0) + printer_combo.pack(side=tk.LEFT) + + printer_after_ref = [None] + + def set_printer_status_ok(): + printer_status_lbl.configure(bg=BG_STATUS_OK, fg=FG_STATUS_OK) + + def set_printer_status_error(): + printer_status_lbl.configure(bg=BG_STATUS_ERROR, fg=FG_STATUS_ERROR) + + def check_printer() -> None: + if printer_after_ref[0] is not None: + root.after_cancel(printer_after_ref[0]) + printer_after_ref[0] = None + if printer_var.get() == "打袋機 DataFlex": + if ( + dataflex_busy_ref[0] + or time.time() < dataflex_status_grace_until_ref[0] + or time.time() < _DATAFLEX_RECOVERY_GRACE_UNTIL + ): + set_printer_status_ok() + printer_after_ref[0] = root.after(5000, check_printer) + return + ok = try_printer_connection(printer_var.get(), settings) + if ok: + set_printer_status_ok() + printer_after_ref[0] = root.after(PRINTER_CHECK_MS, check_printer) + else: + set_printer_status_error() + printer_after_ref[0] = root.after(PRINTER_RETRY_MS, check_printer) + + def on_printer_selection_changed(*args) -> None: + check_printer() + if printer_var.get() != "激光機": + if laser_conn_ref[0] is not None: + try: + laser_conn_ref[0].close() + except Exception: + pass + laser_conn_ref[0] = None + # DataFlex continuous stop panel can drop behind after OptionMenu closes; re-lift for any choice. + root.after(100, lift_dataflex_stop_if_running) + + printer_var.trace_add("write", on_printer_selection_changed) + + def open_setup_window(parent_win: tk.Tk, sett: dict, base_url_ref_list: list) -> None: + """Modal setup: API IP/port, 打袋機/激光機 IP+port, 標簽機 COM port.""" + d = tk.Toplevel(parent_win) + d.title("設定") + d.geometry("440x520") + d.transient(parent_win) + d.grab_set() + d.configure(bg=BG_TOP) + f = tk.Frame(d, padx=16, pady=16, bg=BG_TOP) + f.pack(fill=tk.BOTH, expand=True) + grid_row = [0] # use list so inner function can update + + def _ensure_dot_in_entry(entry: tk.Entry) -> None: + """Allow typing dot (.) in Entry when IME or layout blocks it (e.g. 192.168.17.27).""" + def on_key(event): + if event.keysym in ("period", "decimal"): + pos = entry.index(tk.INSERT) + entry.insert(tk.INSERT, ".") + return "break" + entry.bind("", on_key) + + def add_section(label_text: str, key_ip: str | None, key_port: str | None, key_single: str | None): + out = [] + ttk.Label(f, text=label_text, font=get_font(FONT_SIZE_BUTTONS)).grid( + row=grid_row[0], column=0, columnspan=2, sticky=tk.W, pady=(8, 2) + ) + grid_row[0] += 1 + if key_single: + ttk.Label( + f, + text="列印機名稱 (Windows):", + ).grid( + row=grid_row[0], + column=0, + sticky=tk.W, + pady=2, + ) + var = tk.StringVar(value=sett.get(key_single, "")) + e = tk.Entry(f, textvariable=var, width=22, font=get_font(FONT_SIZE), bg="white") + e.grid(row=grid_row[0], column=1, sticky=tk.W, pady=2) + _ensure_dot_in_entry(e) + grid_row[0] += 1 + return [(key_single, var)] + if key_ip: + ttk.Label(f, text="IP:").grid(row=grid_row[0], column=0, sticky=tk.W, pady=2) + var_ip = tk.StringVar(value=sett.get(key_ip, "")) + e_ip = tk.Entry(f, textvariable=var_ip, width=22, font=get_font(FONT_SIZE), bg="white") + e_ip.grid(row=grid_row[0], column=1, sticky=tk.W, pady=2) + _ensure_dot_in_entry(e_ip) + grid_row[0] += 1 + out.append((key_ip, var_ip)) + if key_port: + ttk.Label(f, text="Port:").grid(row=grid_row[0], column=0, sticky=tk.W, pady=2) + var_port = tk.StringVar(value=sett.get(key_port, "")) + e_port = tk.Entry(f, textvariable=var_port, width=12, font=get_font(FONT_SIZE), bg="white") + e_port.grid(row=grid_row[0], column=1, sticky=tk.W, pady=2) + _ensure_dot_in_entry(e_port) + grid_row[0] += 1 + out.append((key_port, var_port)) + return out + + all_vars = [] + all_vars.extend(add_section("API 伺服器", "api_ip", "api_port", None)) + all_vars.extend(add_section("打袋機 DataFlex", "dabag_ip", "dabag_port", None)) + all_vars.extend(add_section("激光機", "laser_ip", "laser_port", None)) + all_vars.extend(add_section("標簽機 (USB)", None, None, "label_com")) + + def on_save(): + for key, var in all_vars: + sett[key] = var.get().strip() + save_settings(sett) + base_url_ref_list[0] = build_base_url(sett["api_ip"], sett["api_port"]) + d.destroy() + + btn_f = tk.Frame(d, bg=BG_TOP) + btn_f.pack(pady=12) + ttk.Button(btn_f, text="儲存", command=on_save).pack(side=tk.LEFT, padx=4) + ttk.Button(btn_f, text="取消", command=d.destroy).pack(side=tk.LEFT, padx=4) + d.wait_window() + + job_orders_frame = tk.Frame(root, bg=BG_LIST) + job_orders_frame.pack(fill=tk.BOTH, expand=True, padx=12, pady=12) + + search_var = tk.StringVar() + search_frame = tk.Frame(job_orders_frame, bg=BG_LIST) + search_frame.pack(fill=tk.X, pady=(0, 6)) + tk.Label( + search_frame, + text="搜尋品號/工單/批號:", + font=get_font(FONT_SIZE_QTY), + bg=BG_LIST, + fg="black", + ).pack(side=tk.LEFT, padx=(0, 6)) + search_entry = tk.Entry( + search_frame, + textvariable=search_var, + width=32, + font=get_font(FONT_SIZE_QTY), + bg="white", + ) + search_entry.pack(side=tk.LEFT, fill=tk.X, expand=True, padx=(0, 8)) + + # Scrollable area for buttons + canvas = tk.Canvas(job_orders_frame, highlightthickness=0, bg=BG_LIST) + scrollbar = ttk.Scrollbar(job_orders_frame, orient=tk.VERTICAL, command=canvas.yview) + inner = tk.Frame(canvas, bg=BG_LIST) + + win_id = canvas.create_window((0, 0), window=inner, anchor=tk.NW) + canvas.configure(yscrollcommand=scrollbar.set) + + def _on_inner_configure(event): + canvas.configure(scrollregion=canvas.bbox("all")) + + def _on_canvas_configure(event): + canvas.itemconfig(win_id, width=event.width) + + inner.bind("", _on_inner_configure) + canvas.bind("", _on_canvas_configure) + + # Mouse wheel: default Tk scroll speed (one unit per notch) + def _on_mousewheel(event): + if getattr(event, "delta", None) is not None: + canvas.yview_scroll(int(-1 * (event.delta / 120)), "units") + elif event.num == 5: + canvas.yview_scroll(1, "units") + elif event.num == 4: + canvas.yview_scroll(-1, "units") + + canvas.bind("", _on_mousewheel) + inner.bind("", _on_mousewheel) + canvas.bind("", _on_mousewheel) + canvas.bind("", _on_mousewheel) + inner.bind("", _on_mousewheel) + inner.bind("", _on_mousewheel) + + scrollbar.pack(side=tk.RIGHT, fill=tk.Y) + canvas.pack(side=tk.LEFT, fill=tk.BOTH, expand=True) + + # Track which row is highlighted (selected for printing) and which job id + selected_row_holder = [None] # [tk.Frame | None] + selected_jo_id_ref = [None] # [int | None] job order id for selection preservation + last_data_ref = [None] # [list | None] last successful fetch for current date + last_plan_start_ref = [date.today()] # plan date for the current list (search filter uses same) + after_id_ref = [None] # [str | None] root.after id to cancel retry/refresh + + def _data_equal(a: Optional[list], b: Optional[list]) -> bool: + if a is None or b is None: + return a is b + if len(a) != len(b): + return False + for x, y in zip(a, b): + if x.get("id") != y.get("id"): + return False + for k in ("bagPrintedQty", "labelPrintedQty", "laserPrintedQty"): + if x.get(k) != y.get(k): + return False + return True + + def _build_list_from_data(data: list, plan_start: date, preserve_selection: bool) -> None: + selected_row_holder[0] = None + year = plan_start.year + selected_id = selected_jo_id_ref[0] if preserve_selection else None + found_row = None + for jo in data: + jo_id = jo.get("id") + raw_batch = batch_no(year, jo_id) if jo_id is not None else "—" + lot_no_val = jo.get("lotNo") + batch = (lot_no_val or "—").strip() if lot_no_val else "—" + jo_no_display = (jo.get("code") or "").strip() + if not jo_no_display and jo_id is not None: + jo_no_display = raw_batch + elif not jo_no_display: + jo_no_display = "—" + # Line 1: job order no.; line 2: 需求 + 已印(袋/標/激)on one row for compact scrolling + head_line = f"工單:{jo_no_display}" + item_code = jo.get("itemCode") or "—" + item_name = jo.get("itemName") or "—" + req_qty = jo.get("reqQty") + qty_str = format_qty(req_qty) + bag_pq = _printed_qty_int(jo.get("bagPrintedQty")) + label_pq = _printed_qty_int(jo.get("labelPrintedQty")) + laser_pq = _printed_qty_int(jo.get("laserPrintedQty")) + meta_line = ( + f"需求:{qty_str} " + f"已印 袋{bag_pq:,} 標{label_pq:,} 激{laser_pq:,}" + ) + # Columns: fixed-width left | fixed-width 品號 | 品名 (expand) + row = tk.Frame( + inner, + bg=BG_ROW, + relief=tk.RAISED, + bd=2, + cursor="hand2", + padx=10, + pady=LIST_ROW_IPADY, + ) + row.pack(fill=tk.X, pady=LIST_ROW_PADY) + + left = tk.Frame(row, bg=BG_ROW, width=LEFT_COL_WIDTH_PX) + left.pack_propagate(False) + left.pack(side=tk.LEFT, anchor=tk.NW, fill=tk.Y) + batch_lbl = tk.Label( + left, + text=head_line, + font=get_font(FONT_SIZE_BUTTONS), + bg=BG_ROW, + fg="black", + ) + batch_lbl.pack(anchor=tk.W) + meta_lbl = tk.Label( + left, + text=meta_line, + font=get_font(FONT_SIZE_META), + bg=BG_ROW, + fg="#222222", + anchor=tk.W, + justify=tk.LEFT, + wraplength=LEFT_COL_WIDTH_PX - 8, + ) + meta_lbl.pack(anchor=tk.W) + + code_col = tk.Frame(row, bg=BG_ROW, width=CODE_COL_WIDTH_PX) + code_col.pack_propagate(False) + code_col.pack(side=tk.LEFT, anchor=tk.NW, fill=tk.Y, padx=(6, 2)) + code_lbl = tk.Label( + code_col, + text=item_code, + font=get_font(FONT_SIZE_ITEM_CODE), + bg=BG_ROW, + fg="black", + wraplength=ITEM_CODE_WRAP, + justify=tk.LEFT, + anchor=tk.NW, + ) + code_lbl.pack(anchor=tk.NW) + + name_col = tk.Frame(row, bg=BG_ROW) + name_col.pack(side=tk.LEFT, fill=tk.BOTH, expand=True, anchor=tk.NW) + name_lbl = tk.Label( + name_col, + text=item_name or "—", + font=get_font(FONT_SIZE_ITEM_NAME), + bg=BG_ROW, + fg="black", + wraplength=ITEM_NAME_WRAP, + justify=tk.LEFT, + anchor=tk.NW, + ) + name_lbl.pack(anchor=tk.NW) + + def _on_click(e, j=jo, b=batch, r=row): + if ( + printer_var.get() == "打袋機 DataFlex" + and dataflex_busy_ref[0] + and active_dataflex_session_ref[0] is not None + ): + s = active_dataflex_session_ref[0] + messagebox.showwarning( + "打袋機", + f"連續列印進行中,請先按「停止列印」。\n\n" + f"工單:{s.job_code}\n" + f"品號:{s.item_code}\n" + f"品名:{s.item_name}", + ) + return + if selected_row_holder[0] is not None: + set_row_highlight(selected_row_holder[0], False) + set_row_highlight(r, True) + selected_row_holder[0] = r + selected_jo_id_ref[0] = j.get("id") + if printer_var.get() == "打袋機 DataFlex": + ip = (settings.get("dabag_ip") or "").strip() + port_str = (settings.get("dabag_port") or "3008").strip() + try: + port = int(port_str) + except ValueError: + port = 3008 + if not ip: + messagebox.showerror("打袋機", "請在設定中填寫打袋機 DataFlex 的 IP。") + else: + hold_dataflex_status_ok(12.0) + + def _after_row_select_reset() -> None: + bag_ans = ask_bag_count(root) + if bag_ans is not None: + n, continuous = bag_ans + hold_dataflex_status_ok(12.0) + item_code = j.get("itemCode") or "—" + item_name = j.get("itemName") or "—" + item_id = j.get("itemId") + stock_in_line_id = j.get("stockInLineId") + lot_no = j.get("lotNo") + zpl = generate_zpl_dataflex( + b, + item_code, + item_name, + item_id=item_id, + stock_in_line_id=stock_in_line_id, + lot_no=lot_no, + job_order_id=j.get("id"), + expiry_text=job_expiry_zpl_text(j), + ) + label_text = (lot_no or b).strip() + if continuous: + if dataflex_busy_ref[0]: + messagebox.showwarning( + "打袋機", + "請等待目前列印完成或先停止連續列印。", + ) + return + session = build_dataflex_print_session( + j, + b, + zpl, + label_text, + ip, + port, + ) + active_dataflex_session_ref[0] = session + stop_ev = threading.Event() + stop_win = open_dataflex_stop_window( + root, + stop_ev, + dataflex_stop_win_ref, + session, + ) + run_dataflex_continuous_thread( + root=root, + session=session, + stop_event=stop_ev, + stop_win=stop_win, + dataflex_lock=dataflex_lock, + dataflex_busy_ref=dataflex_busy_ref, + dataflex_stop_win_ref=dataflex_stop_win_ref, + active_session_ref=active_dataflex_session_ref, + base_url=base_url_ref[0], + set_status_message=set_status_message, + on_recorded=lambda: load_job_orders( + from_user_date_change=False + ), + ) + else: + run_dataflex_fixed_qty_thread( + root=root, + dataflex_lock=dataflex_lock, + dataflex_busy_ref=dataflex_busy_ref, + ip=ip, + port=port, + n=n, + zpl=zpl, + label_text=label_text, + jo_id=j.get("id"), + base_url=base_url_ref[0], + set_status_message=set_status_message, + on_recorded=lambda: load_job_orders( + from_user_date_change=False + ), + ) + + def _row_select_reset_worker() -> None: + try: + send_dataflex_start_job_reset(ip, port, force=True) + except OSError as ex: + root.after( + 0, + lambda e=str(ex): messagebox.showwarning( + "打袋機", + f"點選工單時重設批次計數失敗(仍可比對數量):{e}", + ), + ) + root.after(0, _after_row_select_reset) + + threading.Thread(target=_row_select_reset_worker, daemon=True).start() + elif printer_var.get() == "標簽機": + com = (settings.get("label_com") or "").strip() + if not com: + messagebox.showerror("標簽機", "請在設定中填寫標簽機名稱 (例如:TSC TTP-246M Pro)。") + else: + count = ask_label_count(root) + lift_dataflex_stop_if_running() + if count is not None: + item_code = j.get("itemCode") or "—" + item_name = j.get("itemName") or "—" + item_id = j.get("itemId") + stock_in_line_id = j.get("stockInLineId") + lot_no = j.get("lotNo") + n = count + try: + # Always render to image (Chinese OK), then send as ZPL graphic (^GFA). + # This is more reliable than Windows GDI and works for both Windows printer name and COM. + if not _HAS_PIL_QR: + raise RuntimeError("請先安裝 Pillow + qrcode(pip install Pillow qrcode[pil])。") + label_img = render_label_to_image( + b, item_code, item_name, + item_id=item_id, stock_in_line_id=stock_in_line_id, + lot_no=lot_no, + ) + zpl_img = _image_to_zpl_gfa(label_img) + run_label_print_batch_thread( + root=root, + label_lock=label_lock, + label_busy_ref=label_busy_ref, + com=com, + zpl_img=zpl_img, + n=n, + jo_id=j.get("id"), + base_url=base_url_ref[0], + set_status_message=set_status_message, + on_recorded=lambda: load_job_orders( + from_user_date_change=False + ), + ) + except Exception as err: + messagebox.showerror("標簽機", f"列印失敗:{err}") + elif printer_var.get() == "激光機": + ip = (settings.get("laser_ip") or "").strip() + port_str = (settings.get("laser_port") or "45678").strip() + try: + port = int(port_str) + except ValueError: + port = 45678 + if not ip: + set_status_message("請在設定中填寫激光機的 IP。", is_error=True) + else: + item_id = j.get("itemId") + stock_in_line_id = j.get("stockInLineId") + item_code_val = j.get("itemCode") or "" + item_name_val = j.get("itemName") or "" + run_laser_row_send_thread( + root=root, + laser_conn_ref=laser_conn_ref, + laser_busy_ref=laser_send_busy_ref, + ip=ip, + port=port, + item_id=item_id, + stock_in_line_id=stock_in_line_id, + item_code=item_code_val, + item_name=item_name_val, + set_status_message=set_status_message, + base_url=base_url_ref[0], + job_order_id=j.get("id"), + on_recorded=lambda: load_job_orders(from_user_date_change=False), + expiry_text=job_expiry_laser_param(j), + ) + + for w in ( + row, + left, + batch_lbl, + meta_lbl, + code_col, + code_lbl, + name_col, + name_lbl, + ): + w.bind("", _on_click) + w.bind("", _on_mousewheel) + w.bind("", _on_mousewheel) + w.bind("", _on_mousewheel) + if preserve_selection and selected_id is not None and jo.get("id") == selected_id: + found_row = row + if found_row is not None: + set_row_highlight(found_row, True) + selected_row_holder[0] = found_row + + def refresh_visible_list() -> None: + """Re-apply search filter to last fetched rows without hitting the API.""" + raw = last_data_ref[0] + if raw is None: + return + ps = last_plan_start_ref[0] + needle = search_var.get().strip() + shown = _filter_job_orders_by_search(raw, needle) if needle else raw + for w in inner.winfo_children(): + w.destroy() + _build_list_from_data(shown, ps, preserve_selection=True) + + search_entry.bind("", lambda e: refresh_visible_list()) + + def load_job_orders(from_user_date_change: bool = False) -> None: + if after_id_ref[0] is not None: + root.after_cancel(after_id_ref[0]) + after_id_ref[0] = None + date_str = date_var.get().strip() + try: + plan_start = date.fromisoformat(date_str) + except ValueError: + messagebox.showerror("日期錯誤", f"請使用 yyyy-MM-dd 格式。目前:{date_str}") + return + if from_user_date_change: + selected_row_holder[0] = None + selected_jo_id_ref[0] = None + try: + data = fetch_job_orders(base_url_ref[0], plan_start) + except requests.RequestException: + set_status_error() + after_id_ref[0] = root.after(RETRY_MS, lambda: load_job_orders(from_user_date_change=False)) + return + set_status_ok() + old_data = last_data_ref[0] + last_data_ref[0] = data + last_plan_start_ref[0] = plan_start + data_changed = not _data_equal(old_data, data) + if data_changed or from_user_date_change: + printing_busy = ( + dataflex_busy_ref[0] + or label_busy_ref[0] + or laser_send_busy_ref[0] + ) + # Do not destroy/rebuild all rows while printing — that removes click bindings and + # can hide DataFlex「停止列印」. Retry until idle (one deferred pass at a time). + if printing_busy and not from_user_date_change: + after_id_ref[0] = root.after( + JOB_LIST_DEFER_WHILE_PRINTING_MS, + lambda: load_job_orders(from_user_date_change=False), + ) + else: + # Rebuild list: clear and rebuild from current data (last_data_ref already updated) + for w in inner.winfo_children(): + w.destroy() + preserve = not from_user_date_change + needle = search_var.get().strip() + shown = _filter_job_orders_by_search(data, needle) if needle else data + _build_list_from_data(shown, plan_start, preserve_selection=preserve) + if from_user_date_change: + canvas.yview_moveto(0) + if JOB_LIST_AUTO_REFRESH_MS > 0: + after_id_ref[0] = root.after( + JOB_LIST_AUTO_REFRESH_MS, + lambda: load_job_orders(from_user_date_change=False), + ) + + # Load default (today) on start; then start printer connection check + root.after(100, lambda: load_job_orders(from_user_date_change=True)) + root.after(300, check_printer) + + root.mainloop() + + +def _startup_error_log_path() -> str: + if getattr(sys, "frozen", False): + base = os.path.dirname(sys.executable) + else: + base = os.path.dirname(os.path.abspath(__file__)) + return os.path.join(base, "bag4_startup_error.log") + + +if __name__ == "__main__": + try: + main() + except SystemExit: + raise + except Exception: + import traceback + + log_path = _startup_error_log_path() + try: + with open(log_path, "w", encoding="utf-8") as f: + traceback.print_exc(file=f) + except OSError: + log_path = "(could not write log file)" + msg = f"Bag4 啟動失敗,詳情已寫入:\n{log_path}" + print(msg, file=sys.stderr) + traceback.print_exc() + try: + _err_root = tk.Tk() + _err_root.withdraw() + messagebox.showerror("Bag4", msg) + _err_root.destroy() + except Exception: + pass + if getattr(sys, "frozen", False): + try: + input("按 Enter 關閉…") + except (EOFError, KeyboardInterrupt): + pass + sys.exit(1) \ No newline at end of file diff --git a/python/Bag4.spec b/python/Bag4.spec new file mode 100644 index 0000000..b531464 --- /dev/null +++ b/python/Bag4.spec @@ -0,0 +1,38 @@ +# -*- mode: python ; coding: utf-8 -*- + + +a = Analysis( + ['Bag4.py'], + pathex=[], + binaries=[], + datas=[], + hiddenimports=[], + hookspath=[], + hooksconfig={}, + runtime_hooks=[], + excludes=[], + noarchive=False, + optimize=0, +) +pyz = PYZ(a.pure) + +exe = EXE( + pyz, + a.scripts, + a.binaries, + a.datas, + [], + name='Bag4', + debug=False, + bootloader_ignore_signals=False, + strip=False, + upx=False, + upx_exclude=[], + runtime_tmpdir=None, + console=False, + disable_windowed_traceback=False, + argv_emulation=False, + target_arch=None, + codesign_identity=None, + entitlements_file=None, +) diff --git a/python/__pycache__/Bag4.cpython-314.pyc b/python/__pycache__/Bag4.cpython-314.pyc new file mode 100644 index 0000000..267a392 Binary files /dev/null and b/python/__pycache__/Bag4.cpython-314.pyc differ diff --git a/python/installAndExe.txt b/python/installAndExe.txt index fb00b31..b05456d 100644 --- a/python/installAndExe.txt +++ b/python/installAndExe.txt @@ -1,24 +1,30 @@ -# Bag3 Windows exe build (run all commands in this python/ folder) +# Bag3 / Bag4 Windows exe build (run all commands in this python/ folder) py -m pip install --upgrade pyinstaller py -m pip install --upgrade pywin32 -py -m pip install --upgrade Pillow "qrcode[pil]" requests +py -m pip install --upgrade Pillow "qrcode[pil]" requests pyserial -py -m PyInstaller --noconfirm --clean Bag3.spec +# --- Bag4 (DataFlex with expiry) --- +py -m PyInstaller --noconfirm --clean Bag4.spec +# Output: dist\Bag4.exe (one-file). Copy Bag4.exe to the client PC. +# Settings are created next to the exe: bag4_settings.json +# Do not overwrite Bag3.exe; Bag3 and Bag4 are separate. -# Output: dist\Bag3\Bag3.exe plus dist\Bag3\_internal\... -# Copy the ENTIRE dist\Bag3\ folder to the client PC (not only Bag3.exe). +# --- Bag3 (DataFlex without expiry) --- +py -m PyInstaller --noconfirm --clean Bag3.spec +# Output: dist\Bag3.exe # --- If the client exe flashes and closes --- -1) On the client PC, open cmd in the Bag3 folder and run: - Bag3.exe - You should see the error in the console, or open bag3_startup_error.log next to Bag3.exe. +1) On the client PC, open cmd in the folder that contains the exe and run: + Bag4.exe + You should see the error in the console, or open bag4_startup_error.log next to Bag4.exe. + (Bag3: bag3_startup_error.log) 2) Compare BUILD machines (both should match): py --version py -m PyInstaller --version - py -m pip show pyinstaller pywin32 Pillow qrcode requests + py -m pip show pyinstaller pywin32 Pillow qrcode requests pyserial A broken build is often caused by: - Different Python major version (e.g. 3.13 vs 3.11) @@ -28,8 +34,8 @@ py -m PyInstaller --noconfirm --clean Bag3.spec 3) Rebuild on the machine that works, or reinstall Python from python.org (64-bit) and reinstall deps above, then rebuild. -4) Bag3.spec disables UPX (upx=False) for stability; do not re-enable unless you test on the client. +4) Spec files disable UPX (upx=False) for stability; do not re-enable unless you test on the client. 5) Client needs 64-bit Windows and Microsoft VC++ Redistributable (same as your Python installer). -6) Antivirus may quarantine files under _internal\ — whitelist the Bag3 folder if the log mentions missing DLL. +6) Antivirus may quarantine the exe — whitelist the folder if the log mentions missing DLL. diff --git a/scripts/generate_item_default_shelf_life_liquibase.py b/scripts/generate_item_default_shelf_life_liquibase.py new file mode 100644 index 0000000..66ab3fe --- /dev/null +++ b/scripts/generate_item_default_shelf_life_liquibase.py @@ -0,0 +1,186 @@ +#!/usr/bin/env python3 +"""Generate Liquibase seed SQL from 工埸產品保質期 Excel (joExpiry.xlsx). + +Stores both chilled defaultDays and minus18Days. +useMinus18 = 1 only when the row has no non-18 shelf life (frozen-only). +Flip useMinus18 in DB later for items that should print -18 days. + +Example: + py scripts/generate_item_default_shelf_life_liquibase.py + py scripts/generate_item_default_shelf_life_liquibase.py "C:\\Users\\Administrator\\Downloads\\joExpiry.xlsx" +""" +from __future__ import annotations + +import argparse +import datetime as dt +from pathlib import Path + +import openpyxl + +PREFERRED_SHEET = "XXXXXXXX" +OUT_SQL = Path( + "src/main/resources/db/changelog/changes/20260820_item_default_shelf_life/" + "05_seed_item_default_shelf_life_minus18_flag.sql" +) + + +def sql_str(value: str | None) -> str: + if value is None: + return "NULL" + return "'" + value.replace("\\", "\\\\").replace("'", "''") + "'" + + +def sql_int(value: int | None) -> str: + return "NULL" if value is None else str(value) + + +def norm_temp(raw) -> str | None: + if raw is None: + return None + s = str(raw).strip().replace("℃", "").replace("°C", "").replace(" ", "") + if not s or s.lower() in ("n/a", "none", "null", "-", "nan"): + return None + return s + + +def is_minus18(temp: str | None) -> bool: + return temp in ("-18", "-18.0") + + +def to_days(raw) -> int | None: + if raw is None or raw == "": + return None + if isinstance(raw, str) and raw.strip().lower() in ("n/a", "-", "none", "null"): + return None + try: + n = int(round(float(raw))) + except (TypeError, ValueError): + return None + return n if n > 0 else None + + +def pick_days(t1, d1, t2, d2) -> tuple[int | None, str | None, int | None]: + """Return (chilledDays, chilledTemp, minus18Days).""" + chilled_days = None + chilled_temp = None + minus18_days = None + for temp, days in ((norm_temp(t1), to_days(d1)), (norm_temp(t2), to_days(d2))): + if not temp or not days: + continue + if is_minus18(temp): + if minus18_days is None: + minus18_days = days + elif chilled_days is None: + chilled_days = days + chilled_temp = temp + return chilled_days, chilled_temp, minus18_days + + +def load_rows(xlsx: Path) -> tuple[str, list[dict]]: + wb = openpyxl.load_workbook(xlsx, data_only=True) + sheet_name = PREFERRED_SHEET if PREFERRED_SHEET in wb.sheetnames else wb.sheetnames[0] + ws = wb[sheet_name] + kept: list[dict] = [] + seen: set[str] = set() + for row in ws.iter_rows(min_row=4, values_only=True): + code_raw = row[3] + if not code_raw: + continue + code = str(code_raw).strip().upper() + if not code or code in seen: + continue + chilled, storage, minus18 = pick_days(row[5], row[6], row[7], row[8]) + if chilled is None and minus18 is None: + continue + seen.add(code) + use_minus18 = 1 if chilled is None else 0 + name = str(row[4]).strip() if row[4] else "" + cat = str(row[2]).strip() if row[2] else "" + remark = f"{cat} {name}".strip()[:255] + kept.append( + { + "itemCode": code, + "defaultDays": chilled, + "minus18Days": minus18, + "useMinus18": use_minus18, + "storageC": "-18" if use_minus18 else storage, + "remarks": remark, + } + ) + return sheet_name, kept + + +def render_sql(sheet_name: str, kept: list[dict], source_name: str) -> str: + today = dt.date.today().isoformat() + n_flag = sum(1 for r in kept if r["useMinus18"] == 1) + lines = [ + "--liquibase formatted sql", + "", + "--changeset fpsms:seed_item_default_shelf_life_minus18_flag", + f"--comment: Upsert joExpiry.xlsx sheet {sheet_name}: defaultDays + minus18Days + useMinus18 flag", + f"-- generated {today}; rows={len(kept)} useMinus18=1 (frozen-only default)={n_flag}", + "INSERT INTO `item_default_shelf_life`", + "(`created`, `createdBy`, `version`, `modified`, `modifiedBy`, `deleted`,", + " `itemCode`, `defaultDays`, `minus18Days`, `useMinus18`, `openedDays`, `storageC`, `remarks`)", + "VALUES", + ] + value_rows = [] + for r in kept: + value_rows.append( + "(" + + ", ".join( + [ + "NOW()", + "'system'", + "0", + "NOW()", + "'system'", + "0", + sql_str(r["itemCode"]), + sql_int(r["defaultDays"]), + sql_int(r["minus18Days"]), + str(r["useMinus18"]), + "NULL", + sql_str(r["storageC"]), + sql_str(r["remarks"]), + ] + ) + + ")" + ) + lines.append(",\n".join(value_rows)) + lines.append("ON DUPLICATE KEY UPDATE") + lines.append(" `defaultDays` = VALUES(`defaultDays`),") + lines.append(" `minus18Days` = VALUES(`minus18Days`),") + lines.append(" `useMinus18` = VALUES(`useMinus18`),") + lines.append(" `openedDays` = VALUES(`openedDays`),") + lines.append(" `storageC` = VALUES(`storageC`),") + lines.append(" `remarks` = VALUES(`remarks`),") + lines.append(" `modified` = NOW(),") + lines.append(" `modifiedBy` = 'system',") + lines.append(" `deleted` = 0;") + lines.append("") + return "\n".join(lines) + + +def main() -> None: + parser = argparse.ArgumentParser() + parser.add_argument( + "xlsx", + nargs="?", + default=str(Path.home() / "Downloads" / "joExpiry.xlsx"), + ) + args = parser.parse_args() + xlsx = Path(args.xlsx) + if not xlsx.is_file(): + raise SystemExit(f"Excel not found: {xlsx}") + sheet_name, kept = load_rows(xlsx) + sql = render_sql(sheet_name, kept, xlsx.name) + OUT_SQL.parent.mkdir(parents=True, exist_ok=True) + OUT_SQL.write_text(sql, encoding="utf-8") + n_flag = sum(1 for r in kept if r["useMinus18"] == 1) + print(f"sheet={sheet_name} rows={len(kept)} useMinus18=1={n_flag} useMinus18=0={len(kept) - n_flag}") + print(f"wrote {OUT_SQL}") + + +if __name__ == "__main__": + main() diff --git a/src/main/java/com/ffii/fpsms/m18/service/M18MasterDataService.kt b/src/main/java/com/ffii/fpsms/m18/service/M18MasterDataService.kt index 459ad0b..546f1a6 100644 --- a/src/main/java/com/ffii/fpsms/m18/service/M18MasterDataService.kt +++ b/src/main/java/com/ffii/fpsms/m18/service/M18MasterDataService.kt @@ -195,6 +195,17 @@ open class M18MasterDataService( return itemsService.findByM18Id(m18ItemId)?.id } + private fun mapM18ProductType(udfProducttype: String?): String { + return when (udfProducttype) { + M18ItemType.CONSUMABLES.type -> ItemType.CONSUMABLES.type + M18ItemType.NONCONSUMABLES.type -> ItemType.NONCONSUMABLES.type + M18ItemType.FG.type -> ItemType.FG.type + M18ItemType.SFG.type -> ItemType.SFG.type + M18ItemType.ITEM.type -> ItemType.ITEM.type + else -> ItemType.MATERIAL.type + } + } + open fun saveProduct(id: Long): MessageResponse? { try { ensureCunitSeededForAllIfEmpty() @@ -203,20 +214,14 @@ open class M18MasterDataService( val price = itemDetail?.data?.price if (itemDetail != null && pro != null) { + val mappedType = mapM18ProductType(pro.udfProducttype) val existingItem = itemsService.findByM18Id(id) val saveItemRequest = NewItemRequest( code = pro.code, name = pro.desc, // type = if (pro.seriesId == m18Config.SERIESID_PF) ProductType.MATERIAL // else ItemType.PRODUCT, - type = when (pro.udfProducttype) { - M18ItemType.CONSUMABLES.type -> ItemType.CONSUMABLES.type - M18ItemType.NONCONSUMABLES.type -> ItemType.NONCONSUMABLES.type - M18ItemType.FG.type -> ItemType.FG.type - M18ItemType.SFG.type -> ItemType.SFG.type - M18ItemType.ITEM.type -> ItemType.ITEM.type - else -> ItemType.MATERIAL.type - }, + type = mappedType, id = existingItem?.id, description = pro.desc, remarks = null, @@ -247,6 +252,12 @@ open class M18MasterDataService( logger.error("saveItem duplicate code for M18 item $id (code=${pro.code}): ${savedItem.message}") return null } + if (ItemM18IdRemapSupport.isM18IdLinkOnly(existingItem == null, savedItem.message)) { + logger.warn( + "Linked M18 product id=$id code=${pro.code} to existing local item id=$localItemId; skip UOM rebuild" + ) + return savedItem.copy(id = localItemId) + } logger.info("Processing item uom...") // Find the item uom that ready to delete (not in m18) val existingItemUoms = itemUomService.findAllByItemsId(localItemId) @@ -382,6 +393,7 @@ open class M18MasterDataService( val price = itemDetail?.data?.price if (itemDetail != null && pro != null) { + val mappedType = mapM18ProductType(pro.udfProducttype) // ── Use cache instead of direct call ──────────────────────── val existingItem = itemCache.getOrPut(item.id) { itemsService.findByM18Id(item.id) @@ -390,14 +402,7 @@ open class M18MasterDataService( val saveItemRequest = NewItemRequest( code = pro.code, name = pro.desc, - type = when (pro.udfProducttype) { - M18ItemType.CONSUMABLES.type -> ItemType.CONSUMABLES.type - M18ItemType.NONCONSUMABLES.type -> ItemType.NONCONSUMABLES.type - M18ItemType.FG.type -> ItemType.FG.type - M18ItemType.SFG.type -> ItemType.SFG.type - M18ItemType.ITEM.type -> ItemType.ITEM.type - else -> ItemType.MATERIAL.type - }, + type = mappedType, id = existingItem?.id, description = pro.desc, remarks = null, @@ -430,6 +435,13 @@ open class M18MasterDataService( logger.error("saveItem duplicate code for M18 item ${item.id} (code=${pro.code}): ${savedItem.message}") return@forEach } + if (ItemM18IdRemapSupport.isM18IdLinkOnly(existingItem == null, savedItem.message)) { + logger.warn( + "Linked M18 product id=${item.id} code=${pro.code} to existing local item id=$localItemId; skip UOM rebuild" + ) + successList.add(item.id) + return@forEach + } logger.info("Processing item uom...") val existingItemUoms = itemUomService.findAllByItemsId(localItemId) diff --git a/src/main/java/com/ffii/fpsms/modules/jobOrder/entity/OnPackTemplateFile.kt b/src/main/java/com/ffii/fpsms/modules/jobOrder/entity/OnPackTemplateFile.kt new file mode 100644 index 0000000..75206f0 --- /dev/null +++ b/src/main/java/com/ffii/fpsms/modules/jobOrder/entity/OnPackTemplateFile.kt @@ -0,0 +1,41 @@ +package com.ffii.fpsms.modules.jobOrder.entity + +import com.ffii.core.entity.BaseEntity +import jakarta.persistence.Column +import jakarta.persistence.Entity +import jakarta.persistence.Lob +import jakarta.persistence.Table +import jakarta.validation.constraints.NotNull +import jakarta.validation.constraints.Size +import org.hibernate.annotations.JdbcTypeCode +import org.hibernate.type.SqlTypes + +@Entity +@Table(name = "onpack_template_file") +open class OnPackTemplateFile : BaseEntity() { + + @NotNull + @Size(max = 20) + @Column(name = "machine", length = 20, nullable = false) + open var machine: String? = null + + @NotNull + @Size(max = 50) + @Column(name = "itemCode", length = 50, nullable = false) + open var itemCode: String? = null + + @NotNull + @Size(max = 200) + @Column(name = "fileName", length = 200, nullable = false) + open var fileName: String? = null + + @NotNull + @Column(name = "byteSize", nullable = false) + open var byteSize: Int? = null + + @NotNull + @Lob + @JdbcTypeCode(SqlTypes.BLOB) + @Column(name = "fileBytes", nullable = false, columnDefinition = "MEDIUMBLOB") + open var fileBytes: ByteArray? = null +} diff --git a/src/main/java/com/ffii/fpsms/modules/jobOrder/entity/OnPackTemplateFileRepository.kt b/src/main/java/com/ffii/fpsms/modules/jobOrder/entity/OnPackTemplateFileRepository.kt new file mode 100644 index 0000000..55c0484 --- /dev/null +++ b/src/main/java/com/ffii/fpsms/modules/jobOrder/entity/OnPackTemplateFileRepository.kt @@ -0,0 +1,29 @@ +package com.ffii.fpsms.modules.jobOrder.entity + +import com.ffii.core.support.AbstractRepository +import org.springframework.stereotype.Repository + +@Repository +interface OnPackTemplateFileRepository : AbstractRepository { + + fun findByDeletedFalseAndMachineAndItemCodeIgnoreCaseAndFileNameIgnoreCase( + machine: String, + itemCode: String, + fileName: String, + ): OnPackTemplateFile? + + fun findFirstByDeletedFalseAndMachineAndFileNameIgnoreCase( + machine: String, + fileName: String, + ): OnPackTemplateFile? + + fun findByMachineAndItemCodeIgnoreCaseAndFileNameIgnoreCase( + machine: String, + itemCode: String, + fileName: String, + ): OnPackTemplateFile? + + fun findByDeletedFalseAndMachineOrderByItemCodeAscFileNameAsc(machine: String): List + + fun findByDeletedFalseOrderByMachineAscItemCodeAscFileNameAsc(): List +} diff --git a/src/main/java/com/ffii/fpsms/modules/jobOrder/service/LaserBag2AutoSendService.kt b/src/main/java/com/ffii/fpsms/modules/jobOrder/service/LaserBag2AutoSendService.kt index 69c887c..3bb4f60 100644 --- a/src/main/java/com/ffii/fpsms/modules/jobOrder/service/LaserBag2AutoSendService.kt +++ b/src/main/java/com/ffii/fpsms/modules/jobOrder/service/LaserBag2AutoSendService.kt @@ -83,6 +83,7 @@ class LaserBag2AutoSendService( jobOrderId = jo.id, jobOrderNo = jo.code, lotNo = jo.lotNo, + expiryDate = jo.expiryDate?.toString(), source = "AUTO", ), ) diff --git a/src/main/java/com/ffii/fpsms/modules/jobOrder/service/OnPackImageTemplateCodec.kt b/src/main/java/com/ffii/fpsms/modules/jobOrder/service/OnPackImageTemplateCodec.kt new file mode 100644 index 0000000..a80fa24 --- /dev/null +++ b/src/main/java/com/ffii/fpsms/modules/jobOrder/service/OnPackImageTemplateCodec.kt @@ -0,0 +1,45 @@ +package com.ffii.fpsms.modules.jobOrder.service + +import java.nio.charset.StandardCharsets + +/** + * OnPack `.image` templates are often UTF-16 LE with BOM (Windows export). + * Decode/encode must round-trip the same encoding or SmartDate X40 will refuse the job. + */ +object OnPackImageTemplateCodec { + + private val bomUtf16Le = byteArrayOf(0xFF.toByte(), 0xFE.toByte()) + private val bomUtf16Be = byteArrayOf(0xFE.toByte(), 0xFF.toByte()) + private val bomUtf8 = byteArrayOf(0xEF.toByte(), 0xBB.toByte(), 0xBF.toByte()) + + fun decode(bytes: ByteArray): Pair ByteArray> { + return when { + bytes.size >= 2 && bytes[0] == bomUtf16Le[0] && bytes[1] == bomUtf16Le[1] -> { + val body = bytes.copyOfRange(2, bytes.size) + val text = String(body, StandardCharsets.UTF_16LE) + text to { s -> bomUtf16Le + s.toByteArray(StandardCharsets.UTF_16LE) } + } + bytes.size >= 2 && bytes[0] == bomUtf16Be[0] && bytes[1] == bomUtf16Be[1] -> { + val body = bytes.copyOfRange(2, bytes.size) + val text = String(body, StandardCharsets.UTF_16BE) + text to { s -> bomUtf16Be + s.toByteArray(StandardCharsets.UTF_16BE) } + } + bytes.size >= 3 && bytes[0] == bomUtf8[0] && bytes[1] == bomUtf8[1] && bytes[2] == bomUtf8[2] -> { + val body = bytes.copyOfRange(3, bytes.size) + val text = String(body, StandardCharsets.UTF_8) + text to { s -> bomUtf8 + s.toByteArray(StandardCharsets.UTF_8) } + } + bytes.size >= 2 && bytes[0] == 0x3C.toByte() && bytes[1] == 0x00.toByte() -> { + val text = String(bytes, StandardCharsets.UTF_16LE) + text to { s -> s.toByteArray(StandardCharsets.UTF_16LE) } + } + else -> { + val utf8 = String(bytes, StandardCharsets.UTF_8) + utf8 to { s -> s.toByteArray(StandardCharsets.UTF_8) } + } + } + } + + fun hasUtf16LeBom(bytes: ByteArray): Boolean = + bytes.size >= 2 && bytes[0] == bomUtf16Le[0] && bytes[1] == bomUtf16Le[1] +} diff --git a/src/main/java/com/ffii/fpsms/modules/jobOrder/service/OnPackJuiceExpiryXml.kt b/src/main/java/com/ffii/fpsms/modules/jobOrder/service/OnPackJuiceExpiryXml.kt new file mode 100644 index 0000000..ad3c0f4 --- /dev/null +++ b/src/main/java/com/ffii/fpsms/modules/jobOrder/service/OnPackJuiceExpiryXml.kt @@ -0,0 +1,152 @@ +package com.ffii.fpsms.modules.jobOrder.service + +/** + * Puts today's expiry BMP into 汁水機 OnPack `.image` XML without replacing LOGO_3 + * (production / print date). + * + * If the designer template already has an expiry slot (`LOGO_5` or `LOGO_EXP`), that + * field's FileName is rewritten and geometry is left as in the XML (no second field). + * Old 4-logo templates still get a generated `LOGO_EXP` (QR moved down). + */ +object OnPackJuiceExpiryXml { + + private val hasLogo4 = Regex("""\s*LOGO_4\s*""") + private val logo3X = Regex( + """(?s)\s*LOGO_3\s*[\s\S]*?[\s\S]*?\s*(\d+)\s*""", + ) + private val logo3Y = Regex( + """(?s)\s*LOGO_3\s*[\s\S]*?[\s\S]*?\s*(\d+)\s*""", + ) + private val logo3HeightReplace = Regex( + """(?s)(\s*LOGO_3\s*[\s\S]*?)\s*\d+\s*()""", + ) + private val logo4Height = Regex( + """(?s)\s*LOGO_4\s*[\s\S]*?\s*(\d+)\s*""", + ) + private val logo4YReplace = Regex( + """(?s)(\s*LOGO_4\s*[\s\S]*?[\s\S]*?)\s*\d+\s*()""", + ) + private val logo4HeightReplace = Regex( + """(?s)(\s*LOGO_4\s*[\s\S]*?)\s*\d+\s*()""", + ) + private val canvasSize = Regex( + """(?s)\s*(\d+)\s*\s*\s*(\d+)\s*\s*]*>\s*\s*LOGO_EXP\s*[\s\S]*?""", + ) + private val logo4Open = Regex( + """(?s)(]*>\s*\s*LOGO_4\s*)""", + ) + + fun applyExpiry(xml: String, bmpFileName: String, bmpPixelWidth: Int): String { + val name = OnPackXml.escapeText(bmpFileName.trim()) + if (name.isEmpty() || !xml.contains("")) { + return xml + } + existingExpirySlotName(xml)?.let { slot -> + return rewriteLogoFileName(xml, slot, name) + } + if (!hasLogo4.containsMatchIn(xml)) { + return xml + } + val layout = layoutFor(xml, bmpPixelWidth) + var out = logo3HeightReplace.replaceFirst(xml, "$1${layout.dateHeight}$2") + out = logo4YReplace.replaceFirst(out, "$1${layout.qrY}$2") + out = logo4HeightReplace.replaceFirst(out, "$1${layout.qrHeight}$2") + val field = expiryLogoXml( + layout.x, + layout.expY, + name, + layout.expWidth, + layout.expHeight, + OnPackXml.nextId(xml), + ) + return if (logoExpBlock.containsMatchIn(out)) { + logoExpBlock.replace(out, field) + } else { + insertLogoExp(out, field) + } + } + + /** Designer expiry slot: prefer LOGO_EXP, then LOGO_5 (CoLOS export). */ + fun existingExpirySlotName(xml: String): String? { + if (Regex("""\s*LOGO_EXP\s*""").containsMatchIn(xml)) return "LOGO_EXP" + if (Regex("""\s*LOGO_5\s*""").containsMatchIn(xml)) return "LOGO_5" + return null + } + + fun rewriteLogoFileName(xml: String, slot: String, fileName: String): String { + val re = Regex( + """(?s)(\s*${Regex.escape(slot)}\s*[\s\S]*?)([^<]*)()""", + ) + return re.replaceFirst(xml, "$1$fileName$3") + } + + internal fun layoutFor(xml: String, bmpPixelWidth: Int): JuiceExpiryLayout { + val x = logo3X.find(xml)?.groupValues?.get(1)?.toIntOrNull() ?: 500 + val logo3YVal = logo3Y.find(xml)?.groupValues?.get(1)?.toIntOrNull() ?: 2500 + val qrH0 = logo4Height.find(xml)?.groupValues?.get(1)?.toIntOrNull() ?: 3000 + val canvas = canvasSize.find(xml) + val canvasW = canvas?.groupValues?.get(1)?.toIntOrNull() ?: 5300 + val canvasH = canvas?.groupValues?.get(2)?.toIntOrNull() ?: 7100 + val dateHeight = DATE_HEIGHT_UNITS + val expHeight = EXP_HEIGHT_UNITS + val expY = logo3YVal + dateHeight + GAP_AFTER_DATE + val qrBottomMax = (canvasH - 80).coerceAtLeast(expY + expHeight + 40) + var qrY = expY + expHeight + GAP_AFTER_EXP + if (qrY >= qrBottomMax) { + qrY = (expY + expHeight + 80).coerceAtMost(qrBottomMax - 1) + } + val qrHeight = qrH0.coerceAtMost((qrBottomMax - qrY).coerceAtLeast(1)) + val expWidth = (bmpPixelWidth * expHeight / BMP_TARGET_HEIGHT).coerceIn(1800, 4000) + .coerceAtMost((canvasW - x).coerceAtLeast(200)) + return JuiceExpiryLayout( + x = x, + dateHeight = dateHeight, + expY = expY, + expWidth = expWidth, + expHeight = expHeight, + qrY = qrY, + qrHeight = qrHeight, + canvasWidth = canvasW, + canvasHeight = canvasH, + ) + } + + private fun insertLogoExp(xml: String, field: String): String { + if (logo4Open.containsMatchIn(xml)) { + return logo4Open.replaceFirst(xml, "$field$1") + } + return xml.replaceFirst("", "$field") + } + + private fun expiryLogoXml( + x: Int, + y: Int, + bmpFileName: String, + width: Int, + height: Int, + id: Int, + ): String { + return """LOGO_EXP$id$x$y0BLACK$bmpFileName$width$height""" + } + + private const val DATE_HEIGHT_UNITS = 640 + private const val EXP_HEIGHT_UNITS = 260 + private const val GAP_AFTER_DATE = 80 + private const val GAP_AFTER_EXP = 800 + private const val BMP_TARGET_HEIGHT = 180 +} + +internal data class JuiceExpiryLayout( + val x: Int, + val dateHeight: Int, + val expY: Int, + val expWidth: Int, + val expHeight: Int, + val qrY: Int, + val qrHeight: Int, + val canvasWidth: Int = 5300, + val canvasHeight: Int = 7100, +) diff --git a/src/main/java/com/ffii/fpsms/modules/jobOrder/service/OnPackLemonExpiryXml.kt b/src/main/java/com/ffii/fpsms/modules/jobOrder/service/OnPackLemonExpiryXml.kt new file mode 100644 index 0000000..b7e699d --- /dev/null +++ b/src/main/java/com/ffii/fpsms/modules/jobOrder/service/OnPackLemonExpiryXml.kt @@ -0,0 +1,61 @@ +package com.ffii.fpsms.modules.jobOrder.service + +/** + * Injects an expiry text field into OnPack2023 lemon `.image` XML without replacing + * TEXT_3 (production / print date, TimeDate offset 0). + * + * Lot-like TEXT_2 is at a higher X; TEXT_3 is lower X (90° rotation). Expiry sits between them. + */ +object OnPackLemonExpiryXml { + + private val text2X = Regex( + """(?s)\s*TEXT_2\s*[\s\S]*?[\s\S]*?\s*(\d+)\s*""", + ) + private val text3X = Regex( + """(?s)\s*TEXT_3\s*[\s\S]*?[\s\S]*?\s*(\d+)\s*""", + ) + private val text2Y = Regex( + """(?s)\s*TEXT_2\s*[\s\S]*?[\s\S]*?\s*(\d+)\s*""", + ) + private val text2PointSize = Regex( + """(?s)(\s*TEXT_2\s*[\s\S]*?)(\d+)()""", + ) + private val textExpStatic = Regex( + """(?s)(\s*TEXT_EXP\s*[\s\S]*?]*>\s*)([^<]*)()""", + ) + + private val hasText3 = Regex("""\s*TEXT_3\s*""") + + fun applyExpiry(xml: String, expiryText: String): String { + val compact = OnPackXml.escapeText(expiryText.trim()) + if (compact.isEmpty() || !hasText3.containsMatchIn(xml) || !xml.contains("")) { + return xml + } + val smallerLot = shrinkText2(xml) + if (textExpStatic.containsMatchIn(smallerLot)) { + return textExpStatic.replace(smallerLot) { m -> + m.groupValues[1] + compact + m.groupValues[3] + } + } + return insertTextExp(smallerLot, compact) + } + + internal fun shrinkText2(xml: String): String { + return text2PointSize.replaceFirst(xml, "$1${TEXT_2_POINT_SIZE}$3") + } + + private fun insertTextExp(xml: String, expiryYyyymmdd: String): String { + val lotX = text2X.find(xml)?.groupValues?.get(1)?.toIntOrNull() ?: 2900 + val dateX = text3X.find(xml)?.groupValues?.get(1)?.toIntOrNull() ?: 2000 + val y = text2Y.find(xml)?.groupValues?.get(1)?.toIntOrNull() ?: 2250 + val expX = (lotX + dateX) / 2 + val field = expiryFieldXml(expX, y, expiryYyyymmdd, OnPackXml.nextId(xml)) + return xml.replaceFirst("", "$field") + } + + private fun expiryFieldXml(x: Int, y: Int, expiryText: String, id: Int): String { + return """TEXT_EXP$id$x$y90BLACKfalsefalseMERGEArial560arialbd.ttf93$expiryText""" + } + + private const val TEXT_2_POINT_SIZE = "520" +} diff --git a/src/main/java/com/ffii/fpsms/modules/jobOrder/service/OnPackTemplateFileService.kt b/src/main/java/com/ffii/fpsms/modules/jobOrder/service/OnPackTemplateFileService.kt new file mode 100644 index 0000000..49e0bcd --- /dev/null +++ b/src/main/java/com/ffii/fpsms/modules/jobOrder/service/OnPackTemplateFileService.kt @@ -0,0 +1,364 @@ +package com.ffii.fpsms.modules.jobOrder.service + +import com.ffii.core.support.JdbcDao +import com.ffii.fpsms.modules.jobOrder.entity.OnPackTemplateFile +import com.ffii.fpsms.modules.jobOrder.entity.OnPackTemplateFileRepository +import com.ffii.fpsms.modules.jobOrder.web.model.OnPackSupportedCatalogDto +import com.ffii.fpsms.modules.jobOrder.web.model.OnPackSupportedItemDto +import com.ffii.fpsms.modules.jobOrder.web.model.OnPackTemplateFileDto +import com.ffii.fpsms.modules.jobOrder.web.model.OnPackTemplateUploadResponse +import org.slf4j.LoggerFactory +import org.springframework.core.io.support.PathMatchingResourcePatternResolver +import org.springframework.dao.DataAccessException +import org.springframework.stereotype.Service +import org.springframework.transaction.annotation.Transactional +import org.springframework.web.multipart.MultipartFile +import java.sql.Timestamp +import java.time.LocalDateTime +import java.time.format.DateTimeFormatter + +@Service +open class OnPackTemplateFileService( + private val repository: OnPackTemplateFileRepository, + private val jdbcDao: JdbcDao, +) { + private val logger = LoggerFactory.getLogger(javaClass) + private val resourceResolver = PathMatchingResourcePatternResolver() + + @Transactional(readOnly = true) + open fun list(machine: String?): List { + return templateTableOr(emptyList()) { + val normalized = machine?.takeIf { it.isNotBlank() }?.let { normalizeMachine(it) } + val sql = buildString { + append( + """ + SELECT id, machine, itemCode, fileName, byteSize, modified + FROM onpack_template_file + WHERE deleted = 0 + """.trimIndent(), + ) + if (normalized != null) append(" AND machine = :machine") + append(" ORDER BY machine, itemCode, fileName") + } + val params = if (normalized == null) emptyMap() else mapOf("machine" to normalized) + jdbcDao.queryForList(sql, params).map { row -> + OnPackTemplateFileDto( + id = (row["id"] as Number).toLong(), + machine = row["machine"]?.toString().orEmpty(), + itemCode = row["itemCode"]?.toString().orEmpty(), + fileName = row["fileName"]?.toString().orEmpty(), + byteSize = (row["byteSize"] as? Number)?.toInt() ?: 0, + modified = formatModified(row["modified"]), + ) + } + } + } + + @Transactional + open fun upload(machineRaw: String, itemCodeRaw: String, files: List): OnPackTemplateUploadResponse { + val machine = normalizeMachine(machineRaw) + val itemCode = normalizeItemCode(itemCodeRaw) + require(files.isNotEmpty()) { "請選擇要上傳的檔案(.image / .bmp / .job)" } + val saved = mutableListOf() + files.forEach { part -> + if (part.isEmpty) return@forEach + val original = part.originalFilename ?: part.name + val storedName = storedFileName(machine, itemCode, original) + val bytes = part.bytes + require(bytes.isNotEmpty()) { "空檔案:$original" } + require(bytes.size <= MAX_BYTES) { "檔案過大(上限 ${MAX_BYTES / (1024 * 1024)}MB):$original" } + val row = try { + repository.findByMachineAndItemCodeIgnoreCaseAndFileNameIgnoreCase( + machine, + itemCode, + storedName, + ) ?: OnPackTemplateFile().apply { + this.machine = machine + this.itemCode = itemCode + this.fileName = storedName + } + } catch (e: DataAccessException) { + throw IllegalArgumentException( + "無法儲存 OnPack 模板。請重啟後端以執行 Liquibase(建立 onpack_template_file)。", + e, + ) + } + row.deleted = false + row.fileBytes = bytes + row.byteSize = bytes.size + row.fileName = storedName + row.itemCode = itemCode + row.machine = machine + try { + repository.save(row) + } catch (e: DataAccessException) { + throw IllegalArgumentException( + "無法儲存 OnPack 模板。請重啟後端以執行 Liquibase(建立 onpack_template_file)。", + e, + ) + } + saved.add(storedName) + } + require(saved.isNotEmpty()) { "沒有可儲存的檔案" } + try { + ensureOnPackQrRow(itemCode, machine, saved) + } catch (e: DataAccessException) { + logger.warn("onpack_qr insert after template upload failed for {}", itemCode, e) + } + return OnPackTemplateUploadResponse(machine = machine, itemCode = itemCode, saved = saved) + } + + @Transactional + open fun softDelete(id: Long) { + val row = try { + repository.findById(id).orElseThrow { IllegalArgumentException("找不到該模板檔") } + } catch (e: DataAccessException) { + throw IllegalArgumentException( + "無法刪除 OnPack 模板。請重啟後端以執行 Liquibase(建立 onpack_template_file)。", + e, + ) + } + row.deleted = true + repository.save(row) + } + + @Transactional(readOnly = true) + open fun loadImage(machine: String, itemCode: String): ByteArray? { + val code = itemCode.trim() + if (code.isEmpty()) return null + return templateTableOr(null) { + imageFileNameCandidates(machine, code).firstNotNullOfOrNull { name -> + repository.findByDeletedFalseAndMachineAndItemCodeIgnoreCaseAndFileNameIgnoreCase( + machine, + code, + name, + )?.fileBytes + } + } + } + + @Transactional(readOnly = true) + open fun loadAsset(machine: String, fileName: String): ByteArray? { + val safe = sanitizeFileName(fileName) ?: return null + return templateTableOr(null) { + repository.findFirstByDeletedFalseAndMachineAndFileNameIgnoreCase(machine, safe)?.fileBytes + } + } + + @Transactional(readOnly = true) + open fun itemCodesWithImage(machine: String): Set { + return templateTableOr(emptySet()) { + jdbcDao.queryForStrings( + """ + SELECT DISTINCT UPPER(TRIM(itemCode)) + FROM onpack_template_file + WHERE deleted = 0 + AND machine = :machine + AND LOWER(fileName) LIKE '%.image' + """.trimIndent(), + mapOf("machine" to machine), + ).mapNotNull { it.trim().uppercase().takeIf { code -> code.isNotEmpty() } }.toSet() + } + } + + @Transactional(readOnly = true) + open fun supportedCatalog(): OnPackSupportedCatalogDto { + return OnPackSupportedCatalogDto( + juice = supportedItems(MACHINE_JUICE), + lemon = supportedItems(MACHINE_LEMON), + ) + } + + private fun supportedItems(machine: String): List { + val registered = registeredCodes(machine) + val inDatabase = itemCodesWithImage(machine) + val builtin = builtinImageCodes(machine) + return mergeSupported(registered, inDatabase, builtin) + } + + private fun registeredCodes(machine: String): Set { + return try { + val templateType = if (machine == MACHINE_LEMON) "text" else "bmp" + val sql = if (machine == MACHINE_LEMON) { + """ + SELECT DISTINCT UPPER(TRIM(code)) AS code + FROM onpack_qr + WHERE LOWER(TRIM(template_type)) = :type + AND TRIM(code) <> '' + """.trimIndent() + } else { + """ + SELECT DISTINCT UPPER(TRIM(code)) AS code + FROM onpack_qr + WHERE COALESCE(NULLIF(TRIM(template_type), ''), 'bmp') = :type + AND TRIM(code) <> '' + """.trimIndent() + } + jdbcDao.queryForList(sql, mapOf("type" to templateType)) + .mapNotNull { it["code"]?.toString()?.trim()?.uppercase()?.takeIf { code -> code.isNotEmpty() } } + .toSet() + } catch (e: DataAccessException) { + logger.warn("onpack_qr lookup failed for machine={}", machine, e) + emptySet() + } + } + + private fun builtinImageCodes(machine: String): Set { + return try { + scanBuiltinImageCodes(machine, resourceResolver) + } catch (e: Exception) { + logger.warn("Failed to scan classpath OnPack templates for machine={}", machine, e) + emptySet() + } + } + + private fun templateTableOr(fallback: T, block: () -> T): T { + return try { + block() + } catch (e: DataAccessException) { + logger.warn( + "onpack_template_file is unavailable; using classpath templates. Restart the backend so Liquibase can create the table.", + e, + ) + fallback + } + } + + private fun formatModified(value: Any?): String? = when (value) { + null -> null + is LocalDateTime -> value.format(ISO_TS) + is Timestamp -> value.toLocalDateTime().format(ISO_TS) + else -> value.toString().takeIf { it.isNotBlank() } + } + + private fun ensureOnPackQrRow(itemCode: String, machine: String, savedNames: List) { + val templateType = if (machine == MACHINE_LEMON) "text" else "bmp" + val filename = savedNames.firstOrNull { it.endsWith(".image", ignoreCase = true) } + ?: savedNames.first() + val existing = jdbcDao.queryForList( + """ + SELECT code FROM onpack_qr + WHERE UPPER(TRIM(code)) = :code + AND LOWER(TRIM(COALESCE(NULLIF(TRIM(template_type), ''), 'bmp'))) = :type + LIMIT 1 + """.trimIndent(), + mapOf("code" to itemCode, "type" to templateType), + ) + if (existing.isNotEmpty()) return + jdbcDao.executeUpdate( + """ + INSERT INTO onpack_qr (code, filename, template_type) + VALUES (:code, :filename, :type) + """.trimIndent(), + mapOf( + "code" to itemCode, + "filename" to filename, + "type" to templateType, + ), + ) + } + + companion object { + const val MACHINE_JUICE = "juice" + const val MACHINE_LEMON = "lemon" + private const val MAX_BYTES = 8 * 1024 * 1024 + private val ISO_TS: DateTimeFormatter = DateTimeFormatter.ISO_LOCAL_DATE_TIME + + fun scanBuiltinImageCodes( + machine: String, + resolver: PathMatchingResourcePatternResolver = PathMatchingResourcePatternResolver(), + ): Set { + val pattern = if (machine == MACHINE_LEMON) { + "classpath*:onpack2030_2/*.image" + } else { + "classpath*:onpack2030/*.image" + } + return resolver.getResources(pattern).mapNotNull { resource -> + itemCodeFromImageFileName(resource.filename ?: return@mapNotNull null) + }.filter { isListedBuiltinCode(it) }.toSet() + } + private val ITEM_CODE = Regex("""^[A-Za-z0-9][A-Za-z0-9._-]{0,48}$""") + private val LISTED_BUILTIN_CODE = Regex("""^PP\d+$""") + private val ALLOWED_EXT = setOf("image", "bmp", "job") + + fun normalizeMachine(raw: String): String { + val v = raw.trim().lowercase() + return when (v) { + MACHINE_JUICE, "bmp", "onpack2030", "汁水機" -> MACHINE_JUICE + MACHINE_LEMON, "text", "onpack2030_2", "檸檬機" -> MACHINE_LEMON + else -> throw IllegalArgumentException("machine 必須是 juice(汁水機)或 lemon(檸檬機)") + } + } + + fun normalizeItemCode(raw: String): String { + val code = raw.trim().uppercase() + require(ITEM_CODE.matches(code)) { "品號格式不正確:$raw" } + return code + } + + fun sanitizeFileName(raw: String): String? { + val base = raw.replace('\\', '/').substringAfterLast('/').trim() + if (base.isEmpty() || base == "." || base == "..") return null + if (base.any { it.code < 32 || it == ':' }) return null + val ext = base.substringAfterLast('.', "").lowercase() + if (ext !in ALLOWED_EXT) return null + return base + } + + fun storedFileName(machine: String, itemCode: String, originalName: String): String { + val safe = sanitizeFileName(originalName) + ?: throw IllegalArgumentException("不支援的檔名(只接受 .image / .bmp / .job):$originalName") + return if (safe.endsWith(".image", ignoreCase = true)) { + if (machine == MACHINE_LEMON) "${itemCode.uppercase()}.image" + else "${itemCode.lowercase()}.image" + } else { + safe + } + } + + fun imageFileNameCandidates(machine: String, itemCode: String): List { + val lower = itemCode.trim().lowercase() + val upper = itemCode.trim().uppercase() + return if (machine == MACHINE_LEMON) { + listOf("$upper.image", "$lower.image") + } else { + listOf("$lower.image", "$upper.image") + } + } + + fun itemCodeFromImageFileName(fileName: String): String? { + val base = fileName.replace('\\', '/').substringAfterLast('/').substringBeforeLast('.') + val code = base.trim().uppercase() + if (code.isEmpty() || code == "DEFAULT") return null + if (!ITEM_CODE.matches(code)) return null + return code + } + + /** Classpath test/dev templates (TEST*, TT_*, LO*, LPP*) stay loadable for ZIP but are not listed as supported items. */ + fun isListedBuiltinCode(code: String): Boolean { + return LISTED_BUILTIN_CODE.matches(code.trim().uppercase()) + } + + fun mergeSupported( + registered: Set, + inDatabase: Set, + builtin: Set, + ): List { + return (registered + inDatabase + builtin) + .map { it.trim().uppercase() } + .filter { it.isNotEmpty() } + .toSortedSet() + .map { code -> + val db = code in inDatabase + val built = code in builtin + OnPackSupportedItemDto( + itemCode = code, + printable = db || built, + inDatabase = db, + builtin = built, + registered = code in registered, + ) + } + } + } +} diff --git a/src/main/java/com/ffii/fpsms/modules/jobOrder/service/OnPackXml.kt b/src/main/java/com/ffii/fpsms/modules/jobOrder/service/OnPackXml.kt new file mode 100644 index 0000000..81d25f7 --- /dev/null +++ b/src/main/java/com/ffii/fpsms/modules/jobOrder/service/OnPackXml.kt @@ -0,0 +1,13 @@ +package com.ffii.fpsms.modules.jobOrder.service + +internal object OnPackXml { + fun escapeText(value: String): String = + value.replace("&", "&").replace("<", "<").replace(">", ">") + + fun nextId(xml: String): Int { + val ids = Regex("""\s*(\d+)\s*""") + .findAll(xml) + .mapNotNull { it.groupValues[1].toIntOrNull() } + return (ids.maxOrNull() ?: 7) + 1 + } +} 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 3299060..0aa946b 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 @@ -13,6 +13,7 @@ import com.ffii.fpsms.modules.jobOrder.web.model.PrintRequest import com.ffii.fpsms.modules.jobOrder.web.model.LaserRequest import com.ffii.fpsms.modules.jobOrder.web.model.NgpclPushResponse import com.ffii.fpsms.modules.jobOrder.web.model.OnPackQrJobOrderRequest +import com.ffii.fpsms.modules.master.service.ItemDefaultShelfLifeService import com.ffii.fpsms.modules.master.service.ItemUomService import com.ffii.fpsms.modules.settings.service.SettingsService import com.ffii.fpsms.modules.stock.entity.StockInLineRepository @@ -56,10 +57,20 @@ import java.time.Duration import java.time.Instant import java.time.LocalDate import java.time.ZoneId +import java.time.format.DateTimeFormatter // Data class to store bitmap bytes + width (for XML) data class BitmapResult(val bytes: ByteArray, val width: Int) +private data class OnPackBmpExportItem( + val codeLower: String, + val itemId: Long, + val stockInLineId: Long, + val itemCode: String, + val productName: String, + val planDate: LocalDate?, +) + /** One Bag2-style laser TCP attempt (internal to [PlasticBagPrinterService]). */ private data class LaserBag2TcpResult( val success: Boolean, @@ -75,10 +86,12 @@ class PlasticBagPrinterService( private val jdbcDao: JdbcDao, private val stockInLineRepository: StockInLineRepository, private val itemUomService: ItemUomService, + private val itemDefaultShelfLifeService: ItemDefaultShelfLifeService, private val settingsService: SettingsService, private val pyJobOrderPrintSubmitService: PyJobOrderPrintSubmitService, private val environment: Environment, private val objectMapper: ObjectMapper, + private val onPackTemplateFileService: OnPackTemplateFileService, ) { private val logger = LoggerFactory.getLogger(javaClass) private val hongKongZoneId = ZoneId.of("Asia/Hong_Kong") @@ -88,6 +101,67 @@ class PlasticBagPrinterService( private const val DEFAULT_LASER_BAG2_PORT = 45678 private const val DEFAULT_LASER_ITEM_CODES = "PP1175" private const val PACKAGING_PROCESS_NAME = "包裝" + /** Designer product BMP canvas (LOGO), 1-bit. */ + private const val ONPACK_PRODUCT_BMP_WIDTH = 593 + private const val ONPACK_PRODUCT_BMP_HEIGHT = 90 + /** Designer item-code BMP canvas (LOGO_2), 1-bit. */ + private const val ONPACK_CODE_BMP_WIDTH = 385 + private const val ONPACK_CODE_BMP_HEIGHT = 141 + /** Designer production-date BMP canvas (LOGO_3), 1-bit. */ + private const val ONPACK_DATE_BMP_WIDTH = 505 + private const val ONPACK_DATE_BMP_HEIGHT = 141 + /** Designer expiry BMP canvas (LOGO_5), 1-bit. */ + private const val ONPACK_EXPIRY_BMP_WIDTH = 623 + 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;;`. + */ + fun buildLaserBag2Payload( + itemId: Long?, + stockInLineId: Long?, + itemCode: String?, + itemName: String?, + expiryDate: String? = null, + ): String { + val codeStr = (itemCode ?: "").trim().replace(";", ",") + val nameStr = (itemName ?: "").trim().replace(";", ",") + val expStr = formatLaserExpiryParam(expiryDate) + val head = if (itemId != null && stockInLineId != null) { + "{\"itemId\":$itemId,\"stockInLineId\":$stockInLineId}" + } else { + "0" + } + return if (expStr.isNotEmpty()) { + "$head;$codeStr;$nameStr;$expStr;;" + } else { + "$head;$codeStr;$nameStr;;" + } + } + + /** ISO `yyyy-MM-dd`, compact `yyyyMMdd`, or already `Expiry Date yyyyMMdd`. */ + fun formatLaserExpiryParam(expiryDate: String?): String { + val raw = expiryDate?.trim().orEmpty() + if (raw.isEmpty()) return "" + if (raw.startsWith("Expiry Date ", ignoreCase = true)) { + return raw.replace(";", ",") + } + val iso = raw.take(10) + try { + return ItemDefaultShelfLifeService.formatPrintLabel(LocalDate.parse(iso)) + } catch (_: Exception) { + } + if (raw.length == 8 && raw.all { it.isDigit() }) { + try { + val d = LocalDate.parse(raw, DateTimeFormatter.BASIC_ISO_DATE) + return ItemDefaultShelfLifeService.formatPrintLabel(d) + } catch (_: Exception) { + } + } + return raw.replace(";", ",") + } } fun getLaserBag2Settings(): LaserBag2SettingsResponse { @@ -176,8 +250,22 @@ class PlasticBagPrinterService( } val ids = filtered.mapNotNull { it.id } val printed = pyJobOrderPrintSubmitService.sumPrintedQtyByJobOrderIds(ids) + val printDate = ItemDefaultShelfLifeService.today() + val shelfLifeByCode = itemDefaultShelfLifeService.printShelfLifeByItemCodes( + filtered.map { it.bom?.item?.code ?: it.bom?.code }, + ) return filtered.map { jo -> - PyJobOrderListMapper.toLaserListItem(jo, printed[jo.id!!], stockInLineRepository, itemUomService) + val itemCode = jo.bom?.item?.code ?: jo.bom?.code + val (days, useMinus18, expiry) = PyJobOrderListMapper.shelfLifeForItem(itemCode, shelfLifeByCode, printDate) + PyJobOrderListMapper.toLaserListItem( + jo, + printed[jo.id!!], + stockInLineRepository, + itemUomService, + defaultShelfLifeDays = days, + useMinus18 = useMinus18, + expiryDate = expiry, + ) } } @@ -203,6 +291,7 @@ class PlasticBagPrinterService( stockInLineId = request.stockInLineId, itemCode = request.itemCode, itemName = request.itemName, + expiryDate = request.expiryDate, ) val response = if (first.success) { LaserBag2SendResponse( @@ -220,6 +309,7 @@ class PlasticBagPrinterService( stockInLineId = request.stockInLineId, itemCode = request.itemCode, itemName = request.itemName, + expiryDate = request.expiryDate, ) LaserBag2SendResponse( success = second.success, @@ -292,14 +382,9 @@ class PlasticBagPrinterService( stockInLineId: Long?, itemCode: String?, itemName: String?, + expiryDate: String? = null, ): LaserBag2TcpResult { - val codeStr = (itemCode ?: "").trim().replace(";", ",") - val nameStr = (itemName ?: "").trim().replace(";", ",") - val payload = if (itemId != null && stockInLineId != null) { - "{\"itemId\":$itemId,\"stockInLineId\":$stockInLineId};$codeStr;$nameStr;;" - } else { - "0;$codeStr;$nameStr;;" - } + val payload = buildLaserBag2Payload(itemId, stockInLineId, itemCode, itemName, expiryDate) val bytes = payload.toByteArray(StandardCharsets.UTF_8) var socket: Socket? = null try { @@ -564,7 +649,21 @@ class PlasticBagPrinterService( return baos.toByteArray() } - fun generateOnPackQrZip(jobOrders: List): ByteArray { + /** + * 汁水機 OnPack: templates under classpath `onpack2030/{code}.image`. + * Always swaps LOGO_4 to the generated QR BMP. + * When [includeExpiry] is true, also generates product/code/date/expiry BMPs (`{code}Product.bmp`, + * `{code}Code.bmp`, `{code}Date.bmp` from [printDate] / bagPrint filter, `{code}exp.bmp`). + * When [includeExpiry] is true, writes `{code}exp.bmp` into the template expiry slot + * (`LOGO_5` / `LOGO_EXP` if present, otherwise injects `LOGO_EXP`). Prefers + * `onpack2030_exp/{code}.image` over the main `onpack2030` template. + * Old ZIP callers must pass false; they keep `onpack2030` unchanged. + */ + fun generateOnPackQrZip( + jobOrders: List, + includeExpiry: Boolean = false, + printDate: LocalDate? = null, + ): ByteArray { val normalizedJobOrders = jobOrders .map { OnPackQrJobOrderRequest( @@ -608,31 +707,98 @@ class PlasticBagPrinterService( ?: return@mapNotNull null val itemId = stockInLine.item?.id ?: return@mapNotNull null val stockInLineId = stockInLine.id ?: return@mapNotNull null - Triple(codeLower, itemId, stockInLineId) + val jo = jobOrderRepository.findById(order.jobOrderId).orElse(null) + val baseName = jo?.bom?.name ?: stockInLine.item?.name + val stockDesc = itemUomService.findStockUnitByItemId(itemId)?.uom?.udfudesc + val productName = PyJobOrderListMapper.buildDisplayItemName(baseName, stockDesc) + ?: codeLower.uppercase() + val itemCode = (stockInLine.item?.code ?: stockInLine.itemNo ?: codeLower).trim().uppercase() + OnPackBmpExportItem( + codeLower, + itemId, + stockInLineId, + itemCode, + productName, + jo?.planStart?.toLocalDate(), + ) } require(exportItemsRaw.isNotEmpty()) { "No OnPack QR files could be generated for the selected date" } - val codesUpper = exportItemsRaw.map { it.first.uppercase() }.toSet() + val codesUpper = exportItemsRaw.map { it.itemCode }.toSet() val allowedBmpCodes = codesOnPackMatchingTemplateType(codesUpper, "bmp") - val exportItems = exportItemsRaw.filter { allowedBmpCodes.contains(it.first.uppercase()) } + val exportItems = exportItemsRaw.filter { allowedBmpCodes.contains(it.itemCode) } require(exportItems.isNotEmpty()) { "No OnPack QR (bmp) rows in onpack_qr for the selected job orders, or no matching templates" } + val effectivePrintDate = printDate + ?: exportItems.firstNotNullOfOrNull { it.planDate } + ?: ItemDefaultShelfLifeService.today() + val baos = ByteArrayOutputStream() ZipOutputStream(baos).use { zos -> val addedEntries = linkedSetOf() - exportItems.forEach { (codeLower, itemId, stockInLineId) -> - val imageTemplate = loadOnPackImageTemplateOrNull(codeLower) ?: return@forEach + exportItems.forEach { item -> + val codeLower = item.codeLower + val imageTemplate = loadOnPackImageTemplateOrNull(codeLower, includeExpiry) ?: return@forEach - val qrContent = """{"itemId": $itemId, "stockInLineId": $stockInLineId}""" + val qrContent = """{"itemId": ${item.itemId}, "stockInLineId": ${item.stockInLineId}}""" // Target approximately 470x389 BMP, but with larger visible QR and very little vertical whitespace. // Width = 386 + (42 * 2) = 470 // Height = 386 + (1 * 2) = 388 (~389) val bmp = createQrCodeBitmap(qrContent, contentSize = 386, horizontalPadding = 42, verticalPadding = 1) val qrBmpFileName = "${codeLower}qr.bmp" val imageFileName = "$codeLower.image" - val imageContent = withOnPackLogo4Bmp(imageTemplate, qrBmpFileName) + var imageContent = withOnPackLogo4Bmp(imageTemplate, qrBmpFileName) + if (includeExpiry) { + val productFile = "${codeLower}Product.bmp" + val codeFile = "${codeLower}Code.bmp" + val dateFile = "${codeLower}Date.bmp" + val productBmp = createMonochromeBitmapFixed( + item.productName, + ONPACK_PRODUCT_BMP_WIDTH, + ONPACK_PRODUCT_BMP_HEIGHT, + ) + val codeBmp = createMonochromeBitmapFixed( + item.itemCode, + ONPACK_CODE_BMP_WIDTH, + ONPACK_CODE_BMP_HEIGHT, + ) + val dateBmp = createMonochromeBitmapFixed( + ItemDefaultShelfLifeService.formatProductionDatePrintLabel(effectivePrintDate), + ONPACK_DATE_BMP_WIDTH, + ONPACK_DATE_BMP_HEIGHT, + ) + imageContent = withOnPackLogoFile(imageContent, "LOGO", productFile) + imageContent = withOnPackLogoFile(imageContent, "LOGO_2", codeFile) + imageContent = withOnPackLogoFile(imageContent, "LOGO_3", dateFile) + if (addedEntries.add(productFile)) { + addToZip(zos, productFile, productBmp.bytes) + } + if (addedEntries.add(codeFile)) { + addToZip(zos, codeFile, codeBmp.bytes) + } + if (addedEntries.add(dateFile)) { + addToZip(zos, dateFile, dateBmp.bytes) + } + } + val expiryLabel = if (includeExpiry) { + itemDefaultShelfLifeService.expiryDatePrintLabel(codeLower, effectivePrintDate) + } else { + null + } + if (!expiryLabel.isNullOrBlank()) { + val expBmp = createMonochromeBitmapFixed( + expiryLabel, + ONPACK_EXPIRY_BMP_WIDTH, + ONPACK_EXPIRY_BMP_HEIGHT, + ) + val expBmpFileName = "${codeLower}exp.bmp" + imageContent = withOnPackExpiryLogo(imageContent, expBmpFileName, expBmp.width) + if (addedEntries.add(expBmpFileName)) { + addToZip(zos, expBmpFileName, expBmp.bytes) + } + } if (addedEntries.add(qrBmpFileName)) { addToZip(zos, qrBmpFileName, bmp.bytes) @@ -640,6 +806,23 @@ class PlasticBagPrinterService( if (addedEntries.add(imageFileName)) { addToZip(zos, imageFileName, imageContent) } + val decodedXmlForAssets = decodeOnPackImageTemplateForTextEdit(imageContent).first + extractLogoBmpFileNamesFromOnPackImageXml(decodedXmlForAssets).forEach { bmpName -> + if (bmpName.equals(qrBmpFileName, ignoreCase = true)) return@forEach + if (bmpName.endsWith("exp.bmp", ignoreCase = true)) return@forEach + if (bmpName.endsWith("Product.bmp", ignoreCase = true)) return@forEach + if (bmpName.endsWith("Code.bmp", ignoreCase = true)) return@forEach + if (bmpName.endsWith("Date.bmp", ignoreCase = true)) return@forEach + if (!addedEntries.add(bmpName)) return@forEach + val bmpBytes = loadJuiceAssetOrNull(bmpName) ?: return@forEach + addToZip(zos, bmpName, bmpBytes) + } + val jobFileName = "${codeLower}.job" + loadJuiceAssetOrNull(jobFileName)?.let { jobBytes -> + if (addedEntries.add(jobFileName)) { + addToZip(zos, jobFileName, jobBytes) + } + } } require(addedEntries.isNotEmpty()) { "No OnPack QR files could be generated for the selected date" } @@ -651,8 +834,13 @@ class PlasticBagPrinterService( /** * OnPack2023 檸檬機: templates under classpath `onpack2030_2/{code}.image` with embedded QR (Static text). * Only replaces `...` under `` with JSON payload. No separate .bmp in zip. + * When [includeExpiry] is true, also injects TEXT_EXP (does not change TEXT_3 production date). Old ZIP callers must pass false. */ - fun generateOnPackQrTextZip(jobOrders: List): ByteArray { + fun generateOnPackQrTextZip( + jobOrders: List, + includeExpiry: Boolean = false, + printDate: LocalDate? = null, + ): ByteArray { val normalizedJobOrders = jobOrders .map { OnPackQrJobOrderRequest( @@ -711,11 +899,25 @@ class PlasticBagPrinterService( val addedEntries = linkedSetOf() exportItems.forEach { (codeLower, itemId, stockInLineId) -> val imageTemplate = loadOnPack2030_2ImageTemplateOrNull(codeLower) ?: run { - logger.warn("OnPack text ZIP: missing classpath template onpack2030_2/{}.image", codeLower.uppercase()) + logger.warn("OnPack text ZIP: missing template for {}", codeLower.uppercase()) return@forEach } val imageFileName = "${codeLower.uppercase()}.image" - val imageContent = withOnPackStaticQrText(codeLower, imageTemplate, itemId, stockInLineId) + val expiryLabel = if (includeExpiry) { + itemDefaultShelfLifeService.expiryDatePrintLabel( + codeLower, + printDate ?: ItemDefaultShelfLifeService.today(), + ) + } else { + null + } + val imageContent = withOnPackStaticQrText( + codeLower, + imageTemplate, + itemId, + stockInLineId, + expiryCompact = expiryLabel, + ) if (addedEntries.add(imageFileName)) { addToZip(zos, imageFileName, imageContent) } @@ -723,7 +925,7 @@ class PlasticBagPrinterService( extractLogoBmpFileNamesFromOnPackImageXml(decodedXmlForAssets).forEach { bmpName -> if (!addedEntries.add(bmpName)) return@forEach val bmpBytes = loadOnPack2030_2AssetOrNull(bmpName) ?: run { - logger.warn("OnPack text ZIP: missing classpath asset onpack2030_2/{}", bmpName) + logger.warn("OnPack text ZIP: missing asset {}", bmpName) return@forEach } addToZip(zos, bmpName, bmpBytes) @@ -816,10 +1018,26 @@ class PlasticBagPrinterService( else -> return emptySet() } val rows = jdbcDao.queryForList(sql, mapOf("codes" to codesUpper.toList())) - return rows.mapNotNull { it["code"]?.toString()?.trim()?.uppercase() }.toSet() + val fromTable = rows.mapNotNull { it["code"]?.toString()?.trim()?.uppercase() }.toSet() + val machine = if (normalizedType == "text") { + OnPackTemplateFileService.MACHINE_LEMON + } else { + OnPackTemplateFileService.MACHINE_JUICE + } + val fromDbFiles = onPackTemplateFileService.itemCodesWithImage(machine) + .filter { it in codesUpper } + .toSet() + return fromTable + fromDbFiles } - private fun loadOnPackImageTemplateOrNull(codeLower: String): ByteArray? { + private fun loadOnPackImageTemplateOrNull(codeLower: String, forExpiry: Boolean = false): ByteArray? { + onPackTemplateFileService.loadImage(OnPackTemplateFileService.MACHINE_JUICE, codeLower)?.let { return it } + if (forExpiry) { + val expiryResource = ClassPathResource("onpack2030_exp/${codeLower}.image") + if (expiryResource.exists()) { + return expiryResource.inputStream.use { it.readBytes() } + } + } val resourcePath = "onpack2030/${codeLower}.image" val resource = ClassPathResource(resourcePath) if (!resource.exists()) return null @@ -828,6 +1046,7 @@ class PlasticBagPrinterService( /** Template files on classpath use uppercase code, e.g. `onpack2030_2/PP1175.image`. */ private fun loadOnPack2030_2ImageTemplateOrNull(codeLower: String): ByteArray? { + onPackTemplateFileService.loadImage(OnPackTemplateFileService.MACHINE_LEMON, codeLower)?.let { return it } val resourcePath = "onpack2030_2/${codeLower.uppercase()}.image" val resource = ClassPathResource(resourcePath) if (!resource.exists()) return null @@ -836,11 +1055,24 @@ class PlasticBagPrinterService( private fun loadOnPack2030_2AssetOrNull(fileName: String): ByteArray? { val safe = fileName.trim().replace(Regex("""[\\/]+"""), "").ifBlank { return null } + onPackTemplateFileService.loadAsset(OnPackTemplateFileService.MACHINE_LEMON, safe)?.let { return it } val resource = ClassPathResource("onpack2030_2/$safe") if (!resource.exists()) return null return resource.inputStream.use { it.readBytes() } } + private fun loadJuiceAssetOrNull(fileName: String): ByteArray? { + val safe = fileName.trim().replace(Regex("""[\\/]+"""), "").ifBlank { return null } + onPackTemplateFileService.loadAsset(OnPackTemplateFileService.MACHINE_JUICE, safe)?.let { return it } + listOf("onpack2030_exp/$safe", "onpack2030/$safe").forEach { path -> + val resource = ClassPathResource(path) + if (resource.exists()) { + return resource.inputStream.use { it.readBytes() } + } + } + return null + } + /** Collect `xxx.bmp` inside each `...` block (decoded template XML). */ private fun extractLogoBmpFileNamesFromOnPackImageXml(xml: String): Set { val out = linkedSetOf() @@ -853,14 +1085,17 @@ class PlasticBagPrinterService( } private fun withOnPackLogo4Bmp(imageBytes: ByteArray, qrBmpFileName: String): ByteArray { - // Use ISO-8859-1 one-byte mapping so all original bytes are preserved, - // while replacing only ASCII XML fragment for LOGO_4 filename. - val oneByteText = String(imageBytes, StandardCharsets.ISO_8859_1) - val replaced = oneByteText.replace( - Regex("""(\s*LOGO_4\s*[\s\S]*?)([^<]+)()"""), - "$1$qrBmpFileName$3", - ) - return replaced.toByteArray(StandardCharsets.ISO_8859_1) + return withOnPackLogoFile(imageBytes, "LOGO_4", qrBmpFileName) + } + + private fun withOnPackLogoFile(imageBytes: ByteArray, fieldName: String, bmpFileName: String): ByteArray { + val (xml, encodeBack) = decodeOnPackImageTemplateForTextEdit(imageBytes) + return encodeBack(OnPackJuiceExpiryXml.rewriteLogoFileName(xml, fieldName, bmpFileName)) + } + + private fun withOnPackExpiryLogo(imageBytes: ByteArray, bmpFileName: String, bmpWidth: Int): ByteArray { + val (xml, encodeBack) = decodeOnPackImageTemplateForTextEdit(imageBytes) + return encodeBack(OnPackJuiceExpiryXml.applyExpiry(xml, bmpFileName, bmpWidth)) } /** @@ -868,39 +1103,16 @@ class PlasticBagPrinterService( * substring/regex matching (`hasNameQr=false` while XML is valid). */ private fun decodeOnPackImageTemplateForTextEdit(bytes: ByteArray): Pair ByteArray> { - val bomUtf16Le = byteArrayOf(0xFF.toByte(), 0xFE.toByte()) - val bomUtf16Be = byteArrayOf(0xFE.toByte(), 0xFF.toByte()) - val bomUtf8 = byteArrayOf(0xEF.toByte(), 0xBB.toByte(), 0xBF.toByte()) - - when { - bytes.size >= 2 && bytes[0] == bomUtf16Le[0] && bytes[1] == bomUtf16Le[1] -> { - val body = bytes.copyOfRange(2, bytes.size) - val text = String(body, StandardCharsets.UTF_16LE) - return text to { s -> bomUtf16Le + s.toByteArray(StandardCharsets.UTF_16LE) } - } - bytes.size >= 2 && bytes[0] == bomUtf16Be[0] && bytes[1] == bomUtf16Be[1] -> { - val body = bytes.copyOfRange(2, bytes.size) - val text = String(body, StandardCharsets.UTF_16BE) - return text to { s -> bomUtf16Be + s.toByteArray(StandardCharsets.UTF_16BE) } - } - bytes.size >= 3 && bytes[0] == bomUtf8[0] && bytes[1] == bomUtf8[1] && bytes[2] == bomUtf8[2] -> { - val body = bytes.copyOfRange(3, bytes.size) - val text = String(body, StandardCharsets.UTF_8) - return text to { s -> bomUtf8 + s.toByteArray(StandardCharsets.UTF_8) } - } - // UTF-16 LE without BOM: "<" == 0x3C 0x00 - bytes.size >= 2 && bytes[0] == 0x3C.toByte() && bytes[1] == 0x00.toByte() -> { - val text = String(bytes, StandardCharsets.UTF_16LE) - return text to { s -> s.toByteArray(StandardCharsets.UTF_16LE) } - } - else -> { - val utf8 = String(bytes, StandardCharsets.UTF_8) - return utf8 to { s -> s.toByteArray(StandardCharsets.UTF_8) } - } - } + return OnPackImageTemplateCodec.decode(bytes) } - private fun withOnPackStaticQrText(forCode: String, imageBytes: ByteArray, itemId: Long, stockInLineId: Long): ByteArray { + private fun withOnPackStaticQrText( + forCode: String, + imageBytes: ByteArray, + itemId: Long, + stockInLineId: Long, + expiryCompact: String? = null, + ): ByteArray { val payload = """{"itemId": $itemId, "stockInLineId": $stockInLineId}""" val (oneByteText, encodeBack) = decodeOnPackImageTemplateForTextEdit(imageBytes) // Must NOT use Static[^>]*StaticSrc — [^>]* already eats type='StaticSrc.V1', so StaticSrc can never match. @@ -934,7 +1146,12 @@ class PlasticBagPrinterService( ) } val withQrCellWidth = replaceQrV1CellWidth67To50(replaced) - return encodeBack(withQrCellWidth) + val withExpiry = if (expiryCompact.isNullOrBlank()) { + withQrCellWidth + } else { + OnPackLemonExpiryXml.applyExpiry(withQrCellWidth, expiryCompact) + } + return encodeBack(withExpiry) } /** First `67` inside `` → 50 (export tuning). */ @@ -952,6 +1169,33 @@ class PlasticBagPrinterService( return if (collapsed.length <= maxLen) collapsed else collapsed.take(maxLen) + "…" } + /** 1-bit BMP at an exact canvas (no crop). Used for OnPack expiry so it matches the designer slot. */ + private fun createMonochromeBitmapFixed(text: String, width: Int, height: Int): BitmapResult { + val img = BufferedImage(width, height, BufferedImage.TYPE_BYTE_BINARY) + img.createGraphics().apply { + color = Color.WHITE + fillRect(0, 0, width, height) + color = Color.BLACK + setRenderingHint(RenderingHints.KEY_TEXT_ANTIALIASING, RenderingHints.VALUE_TEXT_ANTIALIAS_OFF) + var fontSize = (height * 0.92).toInt().coerceAtLeast(1) + var font = Font("SimSun", Font.BOLD, fontSize) + var metrics = getFontMetrics(font) + while (fontSize > 8 && metrics.stringWidth(text) > width) { + fontSize-- + font = Font("SimSun", Font.BOLD, fontSize) + metrics = getFontMetrics(font) + } + this.font = font + val x = 0 + val y = metrics.ascent + ((height - metrics.height) / 2).coerceAtLeast(0) + drawString(text, x, y) + dispose() + } + val baos = ByteArrayOutputStream() + ImageIO.write(img, "bmp", baos) + return BitmapResult(baos.toByteArray(), img.width) + } + private fun createMonochromeBitmap(text: String, targetHeight: Int): BitmapResult { // Step 1: Measure text width with temporary image val tempImg = BufferedImage(1, 1, BufferedImage.TYPE_BYTE_BINARY) diff --git a/src/main/java/com/ffii/fpsms/modules/jobOrder/web/OnPackTemplateController.kt b/src/main/java/com/ffii/fpsms/modules/jobOrder/web/OnPackTemplateController.kt new file mode 100644 index 0000000..c33197e --- /dev/null +++ b/src/main/java/com/ffii/fpsms/modules/jobOrder/web/OnPackTemplateController.kt @@ -0,0 +1,55 @@ +package com.ffii.fpsms.modules.jobOrder.web + +import com.ffii.fpsms.modules.jobOrder.service.OnPackTemplateFileService +import com.ffii.fpsms.modules.jobOrder.web.model.OnPackSupportedCatalogDto +import com.ffii.fpsms.modules.jobOrder.web.model.OnPackTemplateFileDto +import com.ffii.fpsms.modules.jobOrder.web.model.OnPackTemplateUploadResponse +import org.springframework.http.HttpStatus +import org.springframework.http.ResponseEntity +import org.springframework.web.bind.annotation.DeleteMapping +import org.springframework.web.bind.annotation.ExceptionHandler +import org.springframework.web.bind.annotation.GetMapping +import org.springframework.web.bind.annotation.PathVariable +import org.springframework.web.bind.annotation.PostMapping +import org.springframework.web.bind.annotation.RequestMapping +import org.springframework.web.bind.annotation.RequestParam +import org.springframework.web.bind.annotation.RestController +import org.springframework.web.multipart.MultipartFile + +@RestController +@RequestMapping("/plastic/onpack-templates") +class OnPackTemplateController( + private val onPackTemplateFileService: OnPackTemplateFileService, +) { + @GetMapping + fun list( + @RequestParam(required = false) machine: String?, + ): List { + return onPackTemplateFileService.list(machine) + } + + @GetMapping("/supported") + fun supported(): OnPackSupportedCatalogDto { + return onPackTemplateFileService.supportedCatalog() + } + + @PostMapping + fun upload( + @RequestParam machine: String, + @RequestParam itemCode: String, + @RequestParam("files") files: List?, + ): ResponseEntity { + return ResponseEntity.ok(onPackTemplateFileService.upload(machine, itemCode, files ?: emptyList())) + } + + @DeleteMapping("/{id}") + fun delete(@PathVariable id: Long): ResponseEntity { + onPackTemplateFileService.softDelete(id) + return ResponseEntity.noContent().build() + } + + @ExceptionHandler(IllegalArgumentException::class) + fun badRequest(e: IllegalArgumentException): ResponseEntity> { + return ResponseEntity.status(HttpStatus.BAD_REQUEST).body(mapOf("message" to (e.message ?: "Invalid request"))) + } +} 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 f752803..fe880c8 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 @@ -60,7 +60,8 @@ class PlasticBagPrinterController( } /** - * Bag2.py laser TCP protocol: `{"itemId":n,"stockInLineId":m};code;name;;` or `0;code;name;;` + * Bag2/Bag4 laser TCP protocol: `{"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 { @@ -148,6 +149,51 @@ class PlasticBagPrinterController( } } + /** + * Same 汁水機 ZIP as [downloadOnPackQr], plus expiry BMP from item_default_shelf_life. + * Uses designer `.image` layout (`LOGO_5` / `LOGO_EXP`) when present; otherwise injects LOGO_EXP. + * Old [downloadOnPackQr] is unchanged. + */ + @PostMapping("/download-onpack-qr-with-expiry") + fun downloadOnPackQrWithExpiry( + @RequestBody request: OnPackQrDownloadRequest, + response: HttpServletResponse, + ) { + try { + val zipBytes = plasticBagPrinterService.generateOnPackQrZip( + request.jobOrders, + includeExpiry = true, + printDate = request.planDate, + ) + response.contentType = "application/zip" + response.setHeader( + HttpHeaders.CONTENT_DISPOSITION, + "attachment; filename=\"onpack_qr_exp.zip\"" + ) + response.setContentLength(zipBytes.size) + response.outputStream.write(zipBytes) + response.outputStream.flush() + } catch (e: IllegalArgumentException) { + response.status = HttpServletResponse.SC_BAD_REQUEST + response.contentType = "text/plain;charset=UTF-8" + response.writer.write(e.message ?: "Invalid request") + response.writer.flush() + } catch (e: Exception) { + logger.error("POST /plastic/download-onpack-qr-with-expiry failed", e) + try { + if (!response.isCommitted) { + response.reset() + } + } catch (_: Exception) { + /* ignore */ + } + response.status = HttpServletResponse.SC_INTERNAL_SERVER_ERROR + response.contentType = "text/plain;charset=UTF-8" + response.writer.write(e.message ?: "Download failed") + response.writer.flush() + } + } + /** OnPack2023 檸檬機: `onpack2030_2` templates with embedded QR (Static text only; no separate .bmp). */ @PostMapping("/download-onpack-qr-text") fun downloadOnPackQrText( @@ -194,6 +240,50 @@ class PlasticBagPrinterController( return ResponseEntity.ok(plasticBagPrinterService.pushOnPackQrTextZipToNgpcl(request.jobOrders)) } + /** + * Same lemon OnPack ZIP as [downloadOnPackQrText], plus TEXT_EXP from item_default_shelf_life. + * Does not replace TEXT_3 (production / print date). Old [downloadOnPackQrText] is unchanged. + */ + @PostMapping("/download-onpack-qr-text-with-expiry") + fun downloadOnPackQrTextWithExpiry( + @RequestBody request: OnPackQrDownloadRequest, + response: HttpServletResponse, + ) { + try { + val zipBytes = plasticBagPrinterService.generateOnPackQrTextZip( + request.jobOrders, + includeExpiry = true, + printDate = request.planDate, + ) + response.contentType = "application/zip" + response.setHeader( + HttpHeaders.CONTENT_DISPOSITION, + "attachment; filename=\"onpack2023_lemon_qr_exp.zip\"" + ) + response.setContentLength(zipBytes.size) + response.outputStream.write(zipBytes) + response.outputStream.flush() + } catch (e: IllegalArgumentException) { + response.status = HttpServletResponse.SC_BAD_REQUEST + response.contentType = "text/plain;charset=UTF-8" + response.writer.write(e.message ?: "Invalid request") + response.writer.flush() + } catch (e: Exception) { + logger.error("POST /plastic/download-onpack-qr-text-with-expiry failed", e) + try { + if (!response.isCommitted) { + response.reset() + } + } catch (_: Exception) { + /* ignore */ + } + response.status = HttpServletResponse.SC_INTERNAL_SERVER_ERROR + response.contentType = "text/plain;charset=UTF-8" + response.writer.write(e.message ?: "Download failed") + response.writer.flush() + } + } + /** * Test API to generate and download the printer job files as a ZIP. * ONPACK2030 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 5c0c81c..370b957 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,7 +1,8 @@ package com.ffii.fpsms.modules.jobOrder.web.model /** - * Body for Bag2.py-style laser TCP send: `json;itemCode;itemName;;` (UTF-8). + * Body for Bag2/Bag4-style laser TCP send: `json;itemCode;itemName;;` or + * `json;itemCode;itemName;Expiry Date yyyyMMdd;;` when [expiryDate] is set (UTF-8). * 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] @@ -17,6 +18,11 @@ data class LaserBag2SendRequest( val jobOrderId: Long? = null, val jobOrderNo: String? = null, 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. + */ + val expiryDate: String? = null, /** AUTO (auto-send) or MANUAL (/laserPrint); optional. */ val source: String? = null, ) diff --git a/src/main/java/com/ffii/fpsms/modules/jobOrder/web/model/OnPackTemplateModels.kt b/src/main/java/com/ffii/fpsms/modules/jobOrder/web/model/OnPackTemplateModels.kt new file mode 100644 index 0000000..9bbeef9 --- /dev/null +++ b/src/main/java/com/ffii/fpsms/modules/jobOrder/web/model/OnPackTemplateModels.kt @@ -0,0 +1,29 @@ +package com.ffii.fpsms.modules.jobOrder.web.model + +data class OnPackTemplateFileDto( + val id: Long, + val machine: String, + val itemCode: String, + val fileName: String, + val byteSize: Int, + val modified: String?, +) + +data class OnPackTemplateUploadResponse( + val machine: String, + val itemCode: String, + val saved: List, +) + +data class OnPackSupportedItemDto( + val itemCode: String, + val printable: Boolean, + val inDatabase: Boolean, + val builtin: Boolean, + val registered: Boolean, +) + +data class OnPackSupportedCatalogDto( + val juice: List, + val lemon: List, +) diff --git a/src/main/java/com/ffii/fpsms/modules/jobOrder/web/model/PlasticPrintRequest.kt b/src/main/java/com/ffii/fpsms/modules/jobOrder/web/model/PlasticPrintRequest.kt index eae5fd2..e41ad71 100644 --- a/src/main/java/com/ffii/fpsms/modules/jobOrder/web/model/PlasticPrintRequest.kt +++ b/src/main/java/com/ffii/fpsms/modules/jobOrder/web/model/PlasticPrintRequest.kt @@ -52,6 +52,8 @@ data class PrinterStatusResponse( data class OnPackQrDownloadRequest( val jobOrders: List, + /** /bagPrint filter date (job plan date). Used by expiry ZIP for LOGO_3 production date. */ + val planDate: java.time.LocalDate? = null, ) data class OnPackQrJobOrderRequest( diff --git a/src/main/java/com/ffii/fpsms/modules/master/entity/ItemDefaultShelfLife.kt b/src/main/java/com/ffii/fpsms/modules/master/entity/ItemDefaultShelfLife.kt new file mode 100644 index 0000000..f4e4d50 --- /dev/null +++ b/src/main/java/com/ffii/fpsms/modules/master/entity/ItemDefaultShelfLife.kt @@ -0,0 +1,43 @@ +package com.ffii.fpsms.modules.master.entity + +import com.ffii.core.entity.BaseEntity +import jakarta.persistence.Column +import jakarta.persistence.Entity +import jakarta.persistence.Table +import jakarta.validation.constraints.NotNull +import jakarta.validation.constraints.Size + +/** + * Shelf life by item code for bag / OnPack expiry print. + * [useMinus18]: 0 = print uses [defaultDays] (chilled); 1 = print uses [minus18Days]. + */ +@Entity +@Table(name = "item_default_shelf_life") +open class ItemDefaultShelfLife : BaseEntity() { + + @NotNull + @Size(max = 50) + @Column(name = "itemCode", length = 50, nullable = false, unique = true) + open var itemCode: String? = null + + @Column(name = "defaultDays") + open var defaultDays: Int? = null + + @Column(name = "minus18Days") + open var minus18Days: Int? = null + + @NotNull + @Column(name = "useMinus18", nullable = false) + open var useMinus18: Boolean? = false + + @Column(name = "openedDays") + open var openedDays: Int? = null + + @Size(max = 20) + @Column(name = "storageC", length = 20) + open var storageC: String? = null + + @Size(max = 255) + @Column(name = "remarks", length = 255) + open var remarks: String? = null +} diff --git a/src/main/java/com/ffii/fpsms/modules/master/entity/ItemDefaultShelfLifeRepository.kt b/src/main/java/com/ffii/fpsms/modules/master/entity/ItemDefaultShelfLifeRepository.kt new file mode 100644 index 0000000..5093a11 --- /dev/null +++ b/src/main/java/com/ffii/fpsms/modules/master/entity/ItemDefaultShelfLifeRepository.kt @@ -0,0 +1,12 @@ +package com.ffii.fpsms.modules.master.entity + +import com.ffii.core.support.AbstractRepository +import org.springframework.stereotype.Repository + +@Repository +interface ItemDefaultShelfLifeRepository : AbstractRepository { + + fun findByDeletedFalseAndItemCodeIgnoreCase(itemCode: String): ItemDefaultShelfLife? + + fun findByDeletedFalseAndItemCodeIn(itemCodes: Collection): List +} diff --git a/src/main/java/com/ffii/fpsms/modules/master/service/ItemDefaultShelfLifeService.kt b/src/main/java/com/ffii/fpsms/modules/master/service/ItemDefaultShelfLifeService.kt new file mode 100644 index 0000000..fc0b183 --- /dev/null +++ b/src/main/java/com/ffii/fpsms/modules/master/service/ItemDefaultShelfLifeService.kt @@ -0,0 +1,83 @@ +package com.ffii.fpsms.modules.master.service + +import com.ffii.fpsms.modules.master.entity.ItemDefaultShelfLife +import com.ffii.fpsms.modules.master.entity.ItemDefaultShelfLifeRepository +import org.springframework.stereotype.Service +import java.time.LocalDate +import java.time.ZoneId +import java.time.format.DateTimeFormatter + +data class ItemPrintShelfLife( + val effectiveDays: Int, + val useMinus18: Boolean, +) + +/** + * Lookup shelf life days and compute print-date expiry. + * Uses [ItemDefaultShelfLife.useMinus18] to pick chilled [defaultDays] vs [minus18Days]. + */ +@Service +class ItemDefaultShelfLifeService( + private val repository: ItemDefaultShelfLifeRepository, +) { + fun printShelfLifeByItemCodes(codes: Collection): Map { + val normalized = codes.mapNotNull { it?.trim()?.uppercase()?.takeIf { code -> code.isNotEmpty() } } + .distinct() + if (normalized.isEmpty()) return emptyMap() + return repository.findByDeletedFalseAndItemCodeIn(normalized) + .mapNotNull { row -> + val code = row.itemCode?.trim()?.uppercase().orEmpty() + val days = effectiveDays(row) ?: return@mapNotNull null + if (code.isEmpty()) null else code to ItemPrintShelfLife(days, row.useMinus18 == true) + } + .toMap() + } + + fun defaultDaysByItemCodes(codes: Collection): Map = + printShelfLifeByItemCodes(codes).mapValues { it.value.effectiveDays } + + fun defaultDays(itemCode: String?): Int? { + val code = itemCode?.trim()?.uppercase().orEmpty() + if (code.isEmpty()) return null + val row = repository.findByDeletedFalseAndItemCodeIgnoreCase(code) ?: return null + return effectiveDays(row) + } + + fun expiryDate(itemCode: String?, printDate: LocalDate = today()): LocalDate? { + val days = defaultDays(itemCode) ?: return null + return expiryOn(printDate, days) + } + + fun expiryDateIso(itemCode: String?, printDate: LocalDate = today()): String? = + expiryDate(itemCode, printDate)?.toString() + + fun expiryDateCompact(itemCode: String?, printDate: LocalDate = today()): String? = + expiryDate(itemCode, printDate)?.format(COMPACT) + + /** Printed bag wording, e.g. `Expiry Date 20260821`. */ + fun expiryDatePrintLabel(itemCode: String?, printDate: LocalDate = today()): String? = + expiryDate(itemCode, printDate)?.let { formatPrintLabel(it) } + + companion object { + val PRINT_ZONE: ZoneId = ZoneId.of("Asia/Hong_Kong") + private val COMPACT: DateTimeFormatter = DateTimeFormatter.BASIC_ISO_DATE + + fun today(): LocalDate = LocalDate.now(PRINT_ZONE) + + fun formatPrintLabel(d: LocalDate): String = "Expiry Date ${d.format(COMPACT)}" + + /** Production / print date on 汁水機 LOGO_3, e.g. `20260821`. */ + fun formatProductionDatePrintLabel(d: LocalDate): String = d.format(COMPACT) + + fun expiryOn(printDate: LocalDate, defaultDays: Int): LocalDate = + printDate.plusDays(defaultDays.toLong()) + + fun effectiveDays(row: ItemDefaultShelfLife): Int? = + effectiveDays(row.defaultDays, row.minus18Days, row.useMinus18 == true) + + fun effectiveDays(defaultDays: Int?, minus18Days: Int?, useMinus18: Boolean): Int? { + val chosen = if (useMinus18) minus18Days else defaultDays + return chosen?.takeIf { it > 0 } + } + } +} diff --git a/src/main/java/com/ffii/fpsms/modules/master/service/ItemM18IdRemapSupport.kt b/src/main/java/com/ffii/fpsms/modules/master/service/ItemM18IdRemapSupport.kt new file mode 100644 index 0000000..88c93e8 --- /dev/null +++ b/src/main/java/com/ffii/fpsms/modules/master/service/ItemM18IdRemapSupport.kt @@ -0,0 +1,33 @@ +package com.ffii.fpsms.modules.master.service + +/** + * Production-safe rules for linking a new M18 product id onto an existing local item + * (same code+type after M18 recoded the product). + * + * Remap must only update [com.ffii.fpsms.modules.master.entity.Items.m18Id] — never a full + * product overwrite or item_uom rebuild (those delete purchase/stock UOMs). + */ +internal object ItemM18IdRemapSupport { + const val LINKED_MESSAGE = "Linked m18Id to existing item with same code" + + /** + * True when saveItem may attach [requestM18Id] to [duplicatedItemId] instead of + * returning "code already existed". + */ + fun canLinkM18IdToDuplicateCode( + requestId: Long?, + requestM18Id: Long?, + duplicatedItemId: Long?, + ownerOfNewM18IdItemId: Long?, + ): Boolean { + if (requestM18Id == null || duplicatedItemId == null) return false + if (requestId != null && requestId != duplicatedItemId) return false + if (ownerOfNewM18IdItemId != null && ownerOfNewM18IdItemId != duplicatedItemId) return false + return true + } + + /** After saveItem: M18 pull missed by m18Id, then linked by code+type only. */ + fun isM18IdLinkOnly(existingByM18IdWasMissing: Boolean, message: String?): Boolean { + return existingByM18IdWasMissing && message == LINKED_MESSAGE + } +} diff --git a/src/main/java/com/ffii/fpsms/modules/master/service/ItemsService.kt b/src/main/java/com/ffii/fpsms/modules/master/service/ItemsService.kt index 8e5a578..452bd0a 100644 --- a/src/main/java/com/ffii/fpsms/modules/master/service/ItemsService.kt +++ b/src/main/java/com/ffii/fpsms/modules/master/service/ItemsService.kt @@ -581,6 +581,10 @@ open class ItemsService( return itemsRepository.findByCodeAndDeletedFalse(code); } + open fun findByCodeAndType(code: String, type: String): Items? { + return itemsRepository.findByCodeAndTypeAndDeletedFalse(code, type) + } + open fun findByM18Id(m18Id: Long): Items? { return itemsRepository.findByM18IdAndDeletedIsFalse(m18Id) } @@ -650,34 +654,67 @@ open class ItemsService( @Transactional open fun saveItem(request: NewItemRequest): MessageResponse { val duplicatedItem = itemsRepository.findByCodeAndTypeAndDeletedFalse(request.code, request.type) + val ownerOfNewM18Id = request.m18Id?.let { findByM18Id(it) } if (duplicatedItem != null && duplicatedItem.id != request.id) { - if (request.m18Id != null && request.id == null && duplicatedItem.m18Id == null) { - duplicatedItem.m18Id = request.m18Id - duplicatedItem.m18LastModifyDate = request.m18LastModifyDate - val linked = itemsRepository.saveAndFlush(duplicatedItem) + val canLink = ItemM18IdRemapSupport.canLinkM18IdToDuplicateCode( + requestId = request.id, + requestM18Id = request.m18Id, + duplicatedItemId = duplicatedItem.id, + ownerOfNewM18IdItemId = ownerOfNewM18Id?.id, + ) + if (!canLink) { return MessageResponse( - id = linked.id, - code = linked.code, - name = linked.name, - type = linked.type.toString(), - message = "Linked m18Id to existing item with same code", - errorPosition = null, + id = request.id ?: duplicatedItem.id, + code = request.code, + name = request.name, + type = request.type.toString(), + message = "The item code has already existed", + errorPosition = "code" ) } + // M18 recode: attach new proId only. Do not overwrite name/QC/UOM via this path. + if (duplicatedItem.m18Id != request.m18Id) { + logger.warn( + "Remapping item m18Id: localId=${duplicatedItem.id} code=${duplicatedItem.code} oldM18Id=${duplicatedItem.m18Id} newM18Id=${request.m18Id}" + ) + } + duplicatedItem.m18Id = request.m18Id + duplicatedItem.m18LastModifyDate = request.m18LastModifyDate ?: duplicatedItem.m18LastModifyDate + val linked = itemsRepository.saveAndFlush(duplicatedItem) + return MessageResponse( + id = linked.id, + code = linked.code, + name = linked.name, + type = linked.type.toString(), + message = ItemM18IdRemapSupport.LINKED_MESSAGE, + errorPosition = null, + ) + } + // Prefer local id so an existing item can have its m18Id updated from the UI. + val item = when { + request.id != null && request.id > 0 -> + itemsRepository.findByIdAndDeletedFalse(request.id) ?: Items() + ownerOfNewM18Id != null -> ownerOfNewM18Id + else -> Items() + } + logger.info("item: $item") + if (ownerOfNewM18Id != null && ownerOfNewM18Id.id != item.id) { return MessageResponse( - id = request.id ?: duplicatedItem.id, + id = request.id ?: item.id, code = request.code, name = request.name, type = request.type.toString(), - message = "The item code has already existed", - errorPosition = "code" + message = "M18 ID already used by another item", + errorPosition = "m18Id" ) } - val item = if (request.m18Id != null) findByM18Id(request.m18Id) ?: Items() - else if (request.id != null && request.id > 0) itemsRepository.findByIdAndDeletedFalse(request.id) ?: Items() - else Items() - logger.info("item: $item") - if (item.m18LastModifyDate == request.m18LastModifyDate) { + val m18IdUnchanged = request.m18Id == null || item.m18Id == request.m18Id + // Skip only for unchanged M18 pulls (request carries lastModifyDate). UI saves do not. + if (request.m18LastModifyDate != null && + item.id != null && + item.m18LastModifyDate == request.m18LastModifyDate && + m18IdUnchanged + ) { return MessageResponse( id = item.id, code = item.code, diff --git a/src/main/java/com/ffii/fpsms/py/PyController.kt b/src/main/java/com/ffii/fpsms/py/PyController.kt index 9e63c9a..3f51450 100644 --- a/src/main/java/com/ffii/fpsms/py/PyController.kt +++ b/src/main/java/com/ffii/fpsms/py/PyController.kt @@ -2,6 +2,7 @@ package com.ffii.fpsms.py import com.ffii.fpsms.modules.jobOrder.entity.JobOrderRepository import com.ffii.fpsms.modules.jobOrder.service.PlasticBagPrinterService +import com.ffii.fpsms.modules.master.service.ItemDefaultShelfLifeService import com.ffii.fpsms.modules.master.service.ItemUomService import com.ffii.fpsms.modules.stock.entity.StockInLineRepository import org.springframework.format.annotation.DateTimeFormat @@ -28,6 +29,7 @@ open class PyController( private val itemUomService: ItemUomService, private val plasticBagPrinterService: PlasticBagPrinterService, private val pyJobOrderPrintSubmitService: PyJobOrderPrintSubmitService, + private val itemDefaultShelfLifeService: ItemDefaultShelfLifeService, ) { companion object { private const val PACKAGING_PROCESS_NAME = "包裝" @@ -53,8 +55,22 @@ open class PyController( ) val ids = orders.mapNotNull { it.id } val printed = pyJobOrderPrintSubmitService.sumPrintedQtyByJobOrderIds(ids) + val printDate = ItemDefaultShelfLifeService.today() + val shelfLifeByCode = itemDefaultShelfLifeService.printShelfLifeByItemCodes( + orders.map { it.bom?.item?.code ?: it.bom?.code }, + ) val list = orders.map { jo -> - PyJobOrderListMapper.toListItem(jo, printed[jo.id!!], stockInLineRepository, itemUomService) + val itemCode = jo.bom?.item?.code ?: jo.bom?.code + val (days, useMinus18, expiry) = PyJobOrderListMapper.shelfLifeForItem(itemCode, shelfLifeByCode, printDate) + PyJobOrderListMapper.toListItem( + jo, + printed[jo.id!!], + stockInLineRepository, + itemUomService, + defaultShelfLifeDays = days, + useMinus18 = useMinus18, + expiryDate = expiry, + ) } return ResponseEntity.ok(list) } diff --git a/src/main/java/com/ffii/fpsms/py/PyJobOrderListItem.kt b/src/main/java/com/ffii/fpsms/py/PyJobOrderListItem.kt index 0ac36f2..95b737c 100644 --- a/src/main/java/com/ffii/fpsms/py/PyJobOrderListItem.kt +++ b/src/main/java/com/ffii/fpsms/py/PyJobOrderListItem.kt @@ -1,6 +1,7 @@ package com.ffii.fpsms.py import java.math.BigDecimal +import java.time.LocalDate import java.time.LocalDateTime /** @@ -27,4 +28,10 @@ data class PyJobOrderListItem( val labelPrintedQty: Long = 0, /** Cumulative qty from 激光機 submits (LASER). */ val laserPrintedQty: Long = 0, + /** Effective shelf life days used for print (chilled or -18, according to useMinus18). */ + val defaultShelfLifeDays: Int? = null, + /** True when expiry is computed from minus18Days instead of chilled defaultDays. */ + val useMinus18: Boolean? = null, + /** Print date (today, Asia/Hong_Kong) + effective shelf life days. */ + val expiryDate: LocalDate? = null, ) diff --git a/src/main/java/com/ffii/fpsms/py/PyJobOrderListMapper.kt b/src/main/java/com/ffii/fpsms/py/PyJobOrderListMapper.kt index 66e8a6a..9ca929d 100644 --- a/src/main/java/com/ffii/fpsms/py/PyJobOrderListMapper.kt +++ b/src/main/java/com/ffii/fpsms/py/PyJobOrderListMapper.kt @@ -1,8 +1,11 @@ package com.ffii.fpsms.py import com.ffii.fpsms.modules.jobOrder.entity.JobOrder +import com.ffii.fpsms.modules.master.service.ItemDefaultShelfLifeService +import com.ffii.fpsms.modules.master.service.ItemPrintShelfLife import com.ffii.fpsms.modules.master.service.ItemUomService import com.ffii.fpsms.modules.stock.entity.StockInLineRepository +import java.time.LocalDate object PyJobOrderListMapper { @@ -80,6 +83,9 @@ object PyJobOrderListMapper { printed: PrintedQtyByChannel?, stockInLineRepository: StockInLineRepository, itemUomService: ItemUomService, + defaultShelfLifeDays: Int? = null, + useMinus18: Boolean? = null, + expiryDate: LocalDate? = null, ): PyJobOrderListItem { val itemCode = jo.bom?.item?.code ?: jo.bom?.code val baseName = jo.bom?.name ?: jo.bom?.item?.name @@ -107,6 +113,9 @@ object PyJobOrderListMapper { bagPrintedQty = p.bagPrintedQty, labelPrintedQty = p.labelPrintedQty, laserPrintedQty = p.laserPrintedQty, + defaultShelfLifeDays = defaultShelfLifeDays, + useMinus18 = useMinus18, + expiryDate = expiryDate, ) } @@ -116,6 +125,9 @@ object PyJobOrderListMapper { printed: PrintedQtyByChannel?, stockInLineRepository: StockInLineRepository, itemUomService: ItemUomService, + defaultShelfLifeDays: Int? = null, + useMinus18: Boolean? = null, + expiryDate: LocalDate? = null, ): PyJobOrderListItem { val itemCode = jo.bom?.item?.code ?: jo.bom?.code val baseName = jo.bom?.name ?: jo.bom?.item?.name @@ -143,6 +155,20 @@ object PyJobOrderListMapper { bagPrintedQty = p.bagPrintedQty, labelPrintedQty = p.labelPrintedQty, laserPrintedQty = p.laserPrintedQty, + defaultShelfLifeDays = defaultShelfLifeDays, + useMinus18 = useMinus18, + expiryDate = expiryDate, ) } + + fun shelfLifeForItem( + itemCode: String?, + byCode: Map, + printDate: LocalDate = ItemDefaultShelfLifeService.today(), + ): Triple { + val info = byCode[itemCode?.trim()?.uppercase().orEmpty()] + val days = info?.effectiveDays + val expiry = days?.let { ItemDefaultShelfLifeService.expiryOn(printDate, it) } + return Triple(days, info?.useMinus18, expiry) + } } diff --git a/src/main/resources/db/changelog/changes/20260820_item_default_shelf_life/01_create_item_default_shelf_life.sql b/src/main/resources/db/changelog/changes/20260820_item_default_shelf_life/01_create_item_default_shelf_life.sql new file mode 100644 index 0000000..0425737 --- /dev/null +++ b/src/main/resources/db/changelog/changes/20260820_item_default_shelf_life/01_create_item_default_shelf_life.sql @@ -0,0 +1,21 @@ +--liquibase formatted sql + +--changeset fpsms:create_item_default_shelf_life +--comment: Default (non -18 warehouse) shelf life days by item code for bag / OnPack expiry print +CREATE TABLE `item_default_shelf_life` +( + `id` INT NOT NULL AUTO_INCREMENT, + `created` DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP, + `createdBy` VARCHAR(30) NULL DEFAULT NULL, + `version` INT NOT NULL DEFAULT '0', + `modified` DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP, + `modifiedBy` VARCHAR(30) NULL DEFAULT NULL, + `deleted` TINYINT(1) NOT NULL DEFAULT '0', + `itemCode` VARCHAR(50) NOT NULL, + `defaultDays` INT NOT NULL COMMENT 'Main shelf life days; not -18 warehouse', + `openedDays` INT NULL COMMENT '0-4C secondary; unused for print for now', + `storageC` VARCHAR(20) NULL, + `remarks` VARCHAR(255) NULL, + CONSTRAINT pk_item_default_shelf_life PRIMARY KEY (`id`), + UNIQUE KEY uk_item_default_shelf_life_itemCode (`itemCode`) +); diff --git a/src/main/resources/db/changelog/changes/20260820_item_default_shelf_life/02_seed_item_default_shelf_life.sql b/src/main/resources/db/changelog/changes/20260820_item_default_shelf_life/02_seed_item_default_shelf_life.sql new file mode 100644 index 0000000..f453c4b --- /dev/null +++ b/src/main/resources/db/changelog/changes/20260820_item_default_shelf_life/02_seed_item_default_shelf_life.sql @@ -0,0 +1,17 @@ +--liquibase formatted sql + +--changeset fpsms:seed_item_default_shelf_life_sample +--comment: Sample F-codes from the bag print shelf-life sheet (default days only; not -18). Import remaining codes later. +INSERT INTO `item_default_shelf_life` +(`created`, `createdBy`, `version`, `modified`, `modifiedBy`, `deleted`, `itemCode`, `defaultDays`, `openedDays`, `storageC`, `remarks`) +VALUES +(NOW(), 'system', 0, NOW(), 'system', 0, 'F0013', 365, NULL, '18', 'default; not -18 warehouse'), +(NOW(), 'system', 0, NOW(), 'system', 0, 'F0015', 730, NULL, '18', 'default; not -18 warehouse'), +(NOW(), 'system', 0, NOW(), 'system', 0, 'F0025', 365, 10, '0-5', 'default; not -18 warehouse'), +(NOW(), 'system', 0, NOW(), 'system', 0, 'F0028', 730, 12, '0-5', 'default; not -18 warehouse'), +(NOW(), 'system', 0, NOW(), 'system', 0, 'F0032', 365, 12, '0-5', 'default; not -18 warehouse'), +(NOW(), 'system', 0, NOW(), 'system', 0, 'F0039', 365, 12, '0-5', 'default; not -18 warehouse'), +(NOW(), 'system', 0, NOW(), 'system', 0, 'F0055', 365, 7, '0-5', 'default; not -18 warehouse'), +(NOW(), 'system', 0, NOW(), 'system', 0, 'F0075', 180, 14, '0-5', 'default; not -18 warehouse'), +(NOW(), 'system', 0, NOW(), 'system', 0, 'F0110', 90, 21, '0-5', 'default; not -18 warehouse'), +(NOW(), 'system', 0, NOW(), 'system', 0, 'F0250', 365, 12, '0-5', 'default; not -18 warehouse'); diff --git a/src/main/resources/db/changelog/changes/20260820_item_default_shelf_life/03_seed_item_default_shelf_life_from_joexpiry.sql b/src/main/resources/db/changelog/changes/20260820_item_default_shelf_life/03_seed_item_default_shelf_life_from_joexpiry.sql new file mode 100644 index 0000000..f2fb4fb --- /dev/null +++ b/src/main/resources/db/changelog/changes/20260820_item_default_shelf_life/03_seed_item_default_shelf_life_from_joexpiry.sql @@ -0,0 +1,181 @@ +--liquibase formatted sql + +--changeset fpsms:seed_item_default_shelf_life_from_joexpiry +--comment: Seed item_default_shelf_life from joExpiry.xlsx sheet XXXXXXXX; defaultDays = first non -18 保質期(P+) +-- generated 2026-08-20; skipped -18-only: 55 +INSERT INTO `item_default_shelf_life` +(`created`, `createdBy`, `version`, `modified`, `modifiedBy`, `deleted`, `itemCode`, `defaultDays`, `openedDays`, `storageC`, `remarks`) +VALUES +(NOW(), 'system', 0, NOW(), 'system', 0, 'PP2240', 60, NULL, '0-4', '小菜類 TOA韓式泡菜'), +(NOW(), 'system', 0, NOW(), 'system', 0, 'PP2241', 60, NULL, '0-4', '小菜類 TOA酸甜蘿蔔'), +(NOW(), 'system', 0, NOW(), 'system', 0, 'PP2327', 30, NULL, '0-4', '小菜類 OEM香辣炒菜莆(1KG/包)(加工)'), +(NOW(), 'system', 0, NOW(), 'system', 0, 'PP2253', 14, NULL, '0-4', '小菜類 涼拌雙絲(1KG/包)'), +(NOW(), 'system', 0, NOW(), 'system', 0, 'PP2244', 14, NULL, '0-4', '小菜類 酸甜蘿蔔粒(2磅/包)'), +(NOW(), 'system', 0, NOW(), 'system', 0, 'PP2298', 14, NULL, '0-4', '小菜類 炒筍絲(200G)'), +(NOW(), 'system', 0, NOW(), 'system', 0, 'PP2018', 14, NULL, '0-4', '小菜類 炸菜肉絲(2磅/包)'), +(NOW(), 'system', 0, NOW(), 'system', 0, 'PP2008', 14, NULL, '0-4', '小菜類 雪菜肉絲(2磅/包)'), +(NOW(), 'system', 0, NOW(), 'system', 0, 'PP2058', 14, NULL, '0-4', '小菜類 (熟)大冬菇'), +(NOW(), 'system', 0, NOW(), 'system', 0, 'PP2364', 14, NULL, '0-4', '小菜類 炒雪菜(2磅/包)'), +(NOW(), 'system', 0, NOW(), 'system', 0, 'PP2376', 14, NULL, '0-4', '小菜類 切冬菇粒(1KG)'), +(NOW(), 'system', 0, NOW(), 'system', 0, 'PP2383', 14, NULL, '0-4', '小菜類 辣椒菜脯(1KG)'), +(NOW(), 'system', 0, NOW(), 'system', 0, 'PP1152', 30, NULL, '0-4', '汁水類 魚露味水(2磅/包)'), +(NOW(), 'system', 0, NOW(), 'system', 0, 'PP2274', 14, NULL, '0-4', '汁水類 西檸汁(1KG/包)'), +(NOW(), 'system', 0, NOW(), 'system', 0, 'PP2284', 14, NULL, '0-4', '汁水類 油醋汁(1KG/包)'), +(NOW(), 'system', 0, NOW(), 'system', 0, 'PP1225', 14, NULL, '0-4', '汁水類 香辣薑汁'), +(NOW(), 'system', 0, NOW(), 'system', 0, 'PP1178', 30, NULL, '0-4', '汁水類 撈麵豉油(1磅/包)'), +(NOW(), 'system', 0, NOW(), 'system', 0, 'PP1043', 14, NULL, '0-4', '汁水類 豆豉汁(2磅/包)'), +(NOW(), 'system', 0, NOW(), 'system', 0, 'PP2206', 14, NULL, '0-4', '汁水類 鮮沙薑汁(雞廠)'), +(NOW(), 'system', 0, NOW(), 'system', 0, 'PP1126', 14, NULL, '0-4', '汁水類 石頭鍋汁'), +(NOW(), 'system', 0, NOW(), 'system', 0, 'PP1213', 30, NULL, '0-4', '汁水類 石頭飯辣汁(2磅/包)'), +(NOW(), 'system', 0, NOW(), 'system', 0, 'PP1137', 14, NULL, '0-4', '汁水類 咖喱魚蛋汁(2磅/包)'), +(NOW(), 'system', 0, NOW(), 'system', 0, 'PP1117', 14, NULL, '0-4', '汁水類 漁樂紅咖哩汁(2磅/包)'), +(NOW(), 'system', 0, NOW(), 'system', 0, 'PP2262', 7, NULL, '0-4', '汁水類 菇奶(1KG/包)'), +(NOW(), 'system', 0, NOW(), 'system', 0, 'PP2250', 30, NULL, '0-4', '汁水類 欖角汁(2磅/包)'), +(NOW(), 'system', 0, NOW(), 'system', 0, 'PP1214', 30, NULL, '0-4', '汁水類 炒粉絲汁(2磅/包)'), +(NOW(), 'system', 0, NOW(), 'system', 0, 'PP1209', 30, NULL, '0-4', '汁水類 扒飯豉油'), +(NOW(), 'system', 0, NOW(), 'system', 0, 'PP2243', 7, NULL, '0-4', '汁水類 粟米汁(2磅/包)'), +(NOW(), 'system', 0, NOW(), 'system', 0, 'PP2210', 7, NULL, '0-4', '汁水類 牛奶薑汁酒'), +(NOW(), 'system', 0, NOW(), 'system', 0, 'PP2214', 30, NULL, '0-4', '汁水類 丼飯汁(2磅/包)'), +(NOW(), 'system', 0, NOW(), 'system', 0, 'PP1224', 14, NULL, '0-4', '汁水類 柚子蒜蓉汁'), +(NOW(), 'system', 0, NOW(), 'system', 0, 'PP1216', 14, NULL, '0-4', '汁水類 叉燒蜜汁'), +(NOW(), 'system', 0, NOW(), 'system', 0, 'PP1144', 7, NULL, '0-4', '汁水類 越式汁'), +(NOW(), 'system', 0, NOW(), 'system', 0, 'PP1080', 14, NULL, '0-4', '汁水類 咖哩汁'), +(NOW(), 'system', 0, NOW(), 'system', 0, 'PP2211', 14, NULL, '0-4', '汁水類 瑤柱鮑汁 (蠔油汁)'), +(NOW(), 'system', 0, NOW(), 'system', 0, 'PP1234', 14, NULL, '0-4', '汁水類 日式咖哩汁'), +(NOW(), 'system', 0, NOW(), 'system', 0, 'PP2317', 14, NULL, '0-4', '汁水類 漁樂黃咖哩汁(1KG/包)'), +(NOW(), 'system', 0, NOW(), 'system', 0, 'PP2330', 60, NULL, '0-4', '汁水類 浸雞水(1KG)'), +(NOW(), 'system', 0, NOW(), 'system', 0, 'PP2360', 14, NULL, '0-4', '汁水類 瑞士汁(1KG/包)'), +(NOW(), 'system', 0, NOW(), 'system', 0, 'PP2333', 14, NULL, '0-4', '汁水類 豬鞍汁(1KG/包)'), +(NOW(), 'system', 0, NOW(), 'system', 0, 'PP2331', 14, NULL, '0-4', '汁水類 青咖哩(1KG/包)'), +(NOW(), 'system', 0, NOW(), 'system', 0, 'PP2381', 14, NULL, '0-4', '汁水類 咖哩豬皮(1KG)'), +(NOW(), 'system', 0, NOW(), 'system', 0, 'PP2088', 14, NULL, '0-4', '肉食類 淨牛腩粒(2磅/包)'), +(NOW(), 'system', 0, NOW(), 'system', 0, 'PP2271', 90, NULL, '0-4', '肉食類 豬腳薑(350g/包) - 台嵐款'), +(NOW(), 'system', 0, NOW(), 'system', 0, 'PP2290', 14, NULL, '0-4', '肉食類 豬軟骨(400G/包)'), +(NOW(), 'system', 0, NOW(), 'system', 0, 'PP2017', 14, NULL, '0-4', '肉食類 咸瘦肉絲'), +(NOW(), 'system', 0, NOW(), 'system', 0, 'PP2098', 60, NULL, '0-4', '肉食類 臘腸粒(1磅/包)'), +(NOW(), 'system', 0, NOW(), 'system', 0, 'PP2127', 14, NULL, '0-4', '肉食類 五香肉丁'), +(NOW(), 'system', 0, NOW(), 'system', 0, 'PP2105', 14, NULL, '0-4', '肉食類 咖喱牛腩(1人份量/包)'), +(NOW(), 'system', 0, NOW(), 'system', 0, 'PP2037', 14, NULL, '0-4', '肉食類 柱侯牛筋腩(2磅/包)'), +(NOW(), 'system', 0, NOW(), 'system', 0, 'PP2115', 14, NULL, '0-4', '肉食類 瑞士雞翼(1人份量/包)'), +(NOW(), 'system', 0, NOW(), 'system', 0, 'PP2193', 14, NULL, '0-4', '肉食類 原條牛奶叉燒'), +(NOW(), 'system', 0, NOW(), 'system', 0, 'PP2063', 14, NULL, '0-4', '肉食類 咖哩牛腩(2磅/包)'), +(NOW(), 'system', 0, NOW(), 'system', 0, 'PP2167', 14, NULL, '0-4', '肉食類 牛丼'), +(NOW(), 'system', 0, NOW(), 'system', 0, 'PP2334', 14, NULL, '0-4', '肉食類 原條牛奶叉燒(1KG)'), +(NOW(), 'system', 0, NOW(), 'system', 0, 'PP2366', 14, NULL, '0-4', '肉食類 清湯牛腩'), +(NOW(), 'system', 0, NOW(), 'system', 0, 'PP2378', 14, NULL, '0-4', '肉食類 醬香鳳爪(1KG)'), +(NOW(), 'system', 0, NOW(), 'system', 0, 'PP2325', 7, NULL, '0-4', '沙律 薯仔蛋沙律(2.2KG)'), +(NOW(), 'system', 0, NOW(), 'system', 0, 'PP2300', 60, NULL, '0-4', '油類 凱撒牛油(1KG/包)'), +(NOW(), 'system', 0, NOW(), 'system', 0, 'PP1088', 14, NULL, '0-4', '油類 海南雞油(2磅/包)'), +(NOW(), 'system', 0, NOW(), 'system', 0, 'PP1118', 14, NULL, '0-4', '油類 漁樂煉豬油'), +(NOW(), 'system', 0, NOW(), 'system', 0, 'PP1185', 14, NULL, '0-4', '油類 蒜香牛油'), +(NOW(), 'system', 0, NOW(), 'system', 0, 'PP1120', 14, NULL, '0-4', '油類 漁樂蝦頭油(1磅/包)'), +(NOW(), 'system', 0, NOW(), 'system', 0, 'PP2277', 3, NULL, '0-4', '粉麵﹑飯類 烚意粉(1KG/包)'), +(NOW(), 'system', 0, NOW(), 'system', 0, 'PP2276', 3, NULL, '0-4', '粉麵﹑飯類 烚通粉(1KG/包)'), +(NOW(), 'system', 0, NOW(), 'system', 0, 'PP1136', 14, NULL, '0-4', '粉麵﹑飯類 白粥'), +(NOW(), 'system', 0, NOW(), 'system', 0, 'PP1133', 14, NULL, '0-4', '粉麵﹑飯類 豬骨粥(1位份量)(1磅/包)'), +(NOW(), 'system', 0, NOW(), 'system', 0, 'PP2363', 14, NULL, '0-4', '粉麵﹑飯類 炒麵底(170G/包)'), +(NOW(), 'system', 0, NOW(), 'system', 0, 'PP2228', 60, NULL, '0-4', '湯類 香濃沙嗲湯膽(1磅/包)'), +(NOW(), 'system', 0, NOW(), 'system', 0, 'PP1180', 7, NULL, '0-4', '湯類 菇湯(2磅/包)'), +(NOW(), 'system', 0, NOW(), 'system', 0, 'PP2246', 14, NULL, '0-4', '湯類 酸辣湯(2磅/包)'), +(NOW(), 'system', 0, NOW(), 'system', 0, 'PP2282', 14, NULL, '0-4', '湯類 冷麵湯(1KG/包)'), +(NOW(), 'system', 0, NOW(), 'system', 0, 'PP2248', 7, NULL, '0-4', '湯類 無添加魚湯(400ml/包)'), +(NOW(), 'system', 0, NOW(), 'system', 0, 'PP2328', 7, NULL, '0-4', '湯類 鹿茸菇湯(1KG/包)'), +(NOW(), 'system', 0, NOW(), 'system', 0, 'PP2332', 14, NULL, '0-4', '湯類 雞絲碗仔翅(1KG/包)'), +(NOW(), 'system', 0, NOW(), 'system', 0, 'PP2329', 14, NULL, '0-4', '湯類 茶樹菇排骨湯(1KG/包)'), +(NOW(), 'system', 0, NOW(), 'system', 0, 'PP2288', 3, NULL, '0-4', '飲品類 香水檸檬汁P+3(0.8L/包)'), +(NOW(), 'system', 0, NOW(), 'system', 0, 'PP1175', 4, NULL, '0-4', '飲品類 鮮檸檬汁(P+4)'), +(NOW(), 'system', 0, NOW(), 'system', 0, 'PP2237', 10, NULL, '0-4', '飲品類 (樽裝用)凍咖啡底P+10(0.9L/包)'), +(NOW(), 'system', 0, NOW(), 'system', 0, 'PP2238', 10, NULL, '0-4', '飲品類 (餐廳用)凍咖啡底P+10(0.9L/包)'), +(NOW(), 'system', 0, NOW(), 'system', 0, 'PP2236', 10, NULL, '0-4', '飲品類 (樽裝用)凍奶茶底P+10(1L/包)'), +(NOW(), 'system', 0, NOW(), 'system', 0, 'PP2239', 10, NULL, '0-4', '飲品類 (餐廳用)凍奶茶底P+10(1L/包)'), +(NOW(), 'system', 0, NOW(), 'system', 0, 'PP2336', 10, NULL, '0-4', '飲品類 (無糖)凍咖啡底P+10(0.9L/包)'), +(NOW(), 'system', 0, NOW(), 'system', 0, 'PP2338', 10, NULL, '0-4', '飲品類 (無糖)凍奶茶底P+10(1L/包)'), +(NOW(), 'system', 0, NOW(), 'system', 0, 'PP1167', 3, NULL, '0-4', '飲品類 原個檸檬(10個/包)'), +(NOW(), 'system', 0, NOW(), 'system', 0, 'PP2382', 10, NULL, '0-4', '飲品類 凍檸茶膽P+10(1000ML)'), +(NOW(), 'system', 0, NOW(), 'system', 0, 'PP2346', 14, NULL, '0-4', '蔬菜類 南乳蓮藕(225G/包)'), +(NOW(), 'system', 0, NOW(), 'system', 0, 'PP2226', 14, NULL, '0-4', '餡料類 野菌沙嗲牛肉餡 (2磅/包)'), +(NOW(), 'system', 0, NOW(), 'system', 0, 'PP2080', 14, NULL, '0-4', '餡料類 黑椒牛肉餡'), +(NOW(), 'system', 0, NOW(), 'system', 0, 'PP2219', 14, NULL, '0-4', '餡料類 葡汁雞餡'), +(NOW(), 'system', 0, NOW(), 'system', 0, 'PP2126', 14, NULL, '0-4', '餡料類 白汁蘑菇雞批餡'), +(NOW(), 'system', 0, NOW(), 'system', 0, 'PP2220', 14, NULL, '0-4', '餡料類 糯米糍餡'), +(NOW(), 'system', 0, NOW(), 'system', 0, 'PP2074', 14, NULL, '0-4', '餡料類 叉燒飽餡'), +(NOW(), 'system', 0, NOW(), 'system', 0, 'PP2218', 14, NULL, '0-4', '餡料類 青醬白汁雞餡'), +(NOW(), 'system', 0, NOW(), 'system', 0, 'PP2204', 90, NULL, '0-4', '醬料類 八寶醬'), +(NOW(), 'system', 0, NOW(), 'system', 0, 'PP1223', 30, NULL, '0-4', '醬料類 韓式泡菜醬'), +(NOW(), 'system', 0, NOW(), 'system', 0, 'PP2205', 30, NULL, '0-4', '醬料類 沙薑醬'), +(NOW(), 'system', 0, NOW(), 'system', 0, 'PP1121', 30, NULL, '0-4', '醬料類 漁樂馬拉盞(2磅/包)'), +(NOW(), 'system', 0, NOW(), 'system', 0, 'PP2234', 14, NULL, '0-4', '醬料類 韓樂黑炸醬(1磅/包)'), +(NOW(), 'system', 0, NOW(), 'system', 0, 'PP2212', 14, NULL, '0-4', '醬料類 青醬'), +(NOW(), 'system', 0, NOW(), 'system', 0, 'PP1062', 14, NULL, '0-4', '醬料類 XO醬(1磅/包)'), +(NOW(), 'system', 0, NOW(), 'system', 0, 'PP2227', 60, NULL, '0-4', '醬料類 香辣腐乳醬(2磅/包)'), +(NOW(), 'system', 0, NOW(), 'system', 0, 'PP1042', 14, NULL, '0-4', '醬料類 口水雞汁(2磅/包)'), +(NOW(), 'system', 0, NOW(), 'system', 0, 'PP1181', 14, NULL, '0-4', '醬料類 韓式撈雞醬(2磅/包)'), +(NOW(), 'system', 0, NOW(), 'system', 0, 'PP1018', 60, NULL, '0-4', '醬料類 沙嗲醬(2磅/包)'), +(NOW(), 'system', 0, NOW(), 'system', 0, 'PP2215', 14, NULL, '0-4', '醬料類 海南雞醬(1磅/包)'), +(NOW(), 'system', 0, NOW(), 'system', 0, 'PP1232', 14, NULL, '0-4', '醬料類 剁椒醬(1磅/包)'), +(NOW(), 'system', 0, NOW(), 'system', 0, 'PP1222', 30, NULL, '0-4', '醬料類 拌飯醬汁(1磅/包)'), +(NOW(), 'system', 0, NOW(), 'system', 0, 'PP2235', 7, NULL, '0-4', '醬料類 芝士醬(菠菜用)(1LB)'), +(NOW(), 'system', 0, NOW(), 'system', 0, 'PP1215', 14, NULL, '0-4', '醬料類 黑松露醬'), +(NOW(), 'system', 0, NOW(), 'system', 0, 'PP2254', 60, NULL, '0-4', '醬料類 蒜蓉豆豉粒(1KG/包)'), +(NOW(), 'system', 0, NOW(), 'system', 0, 'PP1093', 7, NULL, '0-4', '醬料類 肉燥拉麵醬'), +(NOW(), 'system', 0, NOW(), 'system', 0, 'PP1211', 14, NULL, '0-4', '醬料類 船麵醬(2磅/包)'), +(NOW(), 'system', 0, NOW(), 'system', 0, 'PP1231', 7, NULL, '0-4', '醬料類 金銀蒜(1磅/包)'), +(NOW(), 'system', 0, NOW(), 'system', 0, 'PP1237', 14, NULL, '0-4', '醬料類 蒜泥(1磅/包)'), +(NOW(), 'system', 0, NOW(), 'system', 0, 'PP1202', 14, NULL, '0-4', '醬料類 薑蓉'), +(NOW(), 'system', 0, NOW(), 'system', 0, 'PP1044', 60, NULL, '0-4', '醬料類 漁樂沙茶醬(1磅/包)'), +(NOW(), 'system', 0, NOW(), 'system', 0, 'PP2326', 60, NULL, '0-4', '醬料類 南乳醬(1KG)'), +(NOW(), 'system', 0, NOW(), 'system', 0, 'PP2322', 120, NULL, '0-4', '醬料類 麥芽糖(1KG/包)'), +(NOW(), 'system', 0, NOW(), 'system', 0, 'PP1188', 30, NULL, '0-4', '醬料類 咖喱膽(2磅/包)'), +(NOW(), 'system', 0, NOW(), 'system', 0, 'PP2373', 14, NULL, '0-4', '醬料類 燒味飯汁(2KG)'), +(NOW(), 'system', 0, NOW(), 'system', 0, 'PP2375', 14, NULL, '0-4', '醬料類 車仔麵醬(1KG)'), +(NOW(), 'system', 0, NOW(), 'system', 0, 'PP2182', 14, NULL, '0-4', '雜項 熟薏米 (2磅/包)'), +(NOW(), 'system', 0, NOW(), 'system', 0, 'PP2231', 7, NULL, '0-4', '雜項 花膠(1磅/包)'), +(NOW(), 'system', 0, NOW(), 'system', 0, 'PP2384', 14, NULL, '0-4', '柚皮 (300G)'), +(NOW(), 'system', 0, NOW(), 'system', 0, 'PP2386', 7, NULL, '0-4', '飲品類 楊枝甘露(250G)'), +(NOW(), 'system', 0, NOW(), 'system', 0, 'PP2387', 14, NULL, '0-4', '肉食類 滷水雞翼尖(1KG)'), +(NOW(), 'system', 0, NOW(), 'system', 0, 'PP2388', 14, NULL, '0-4', '小菜類 日式蘿蔔(1KG)'), +(NOW(), 'system', 0, NOW(), 'system', 0, 'PP2393', 7, NULL, '0-4', '醬料類 芝士醬(菠菜用)(2LB)'), +(NOW(), 'system', 0, NOW(), 'system', 0, 'PP2395', 14, NULL, '0-4', '肉食類 法式田螺(500G)'), +(NOW(), 'system', 0, NOW(), 'system', 0, 'PP2396', 14, NULL, '0-4', '肉食類 沙嗲牛肉(1KG/包)'), +(NOW(), 'system', 0, NOW(), 'system', 0, 'PP2398', 14, NULL, '0-4', '汁水類 PP葡汁(1KG/包)'), +(NOW(), 'system', 0, NOW(), 'system', 0, 'PP2399', 14, NULL, '0-4', '汁水類 PP蒜茸汁(1KG/包)'), +(NOW(), 'system', 0, NOW(), 'system', 0, 'PP2400', 14, NULL, '0-4', '汁水類 PP甜酸汁(1KG/包)'), +(NOW(), 'system', 0, NOW(), 'system', 0, 'PP2401', 14, NULL, '0-4', '汁水類 PP照燒汁(1KG)'), +(NOW(), 'system', 0, NOW(), 'system', 0, 'PP2402', 14, NULL, '0-4', '汁水類 PP沙嗲牛肉汁(1KG)'), +(NOW(), 'system', 0, NOW(), 'system', 0, 'PP2403', 14, NULL, '0-4', '汁水類 PP牛肝菌汁(1KG)'), +(NOW(), 'system', 0, NOW(), 'system', 0, 'PP2404', 14, NULL, '0-4', '汁水類 PP黑椒汁(1KG)'), +(NOW(), 'system', 0, NOW(), 'system', 0, 'PP2405', 14, NULL, '0-4', '汁水類 PP肉醬汁(1KG)'), +(NOW(), 'system', 0, NOW(), 'system', 0, 'PP2406', 7, NULL, '0-4', '汁水類 PP白汁(1KG/包)'), +(NOW(), 'system', 0, NOW(), 'system', 0, 'PP2407', 14, NULL, '0-4', '汁水類 PP鮮茄膽(1KG)'), +(NOW(), 'system', 0, NOW(), 'system', 0, 'PP2408', 14, NULL, '0-4', '汁水類 PP龍蝦湯膽(1KG)'), +(NOW(), 'system', 0, NOW(), 'system', 0, 'PP2409', 14, NULL, '0-4', '汁水類 PP焗飯汁(1KG)'), +(NOW(), 'system', 0, NOW(), 'system', 0, 'PP2410', 14, NULL, '0-4', '汁水類 PP雜菜湯(1KG)'), +(NOW(), 'system', 0, NOW(), 'system', 0, 'PP2411', 14, NULL, '0-4', '汁水類 PP碗仔翅(1KG)'), +(NOW(), 'system', 0, NOW(), 'system', 0, 'PP2412', 14, NULL, '0-4', '小菜類 PP酸甜蘿蔔(1KG/包)'), +(NOW(), 'system', 0, NOW(), 'system', 0, 'PP2415', 14, NULL, '0-4', '油類 PP龍蝦油(1KG/包)'), +(NOW(), 'system', 0, NOW(), 'system', 0, 'PP0261', 4, NULL, '0-4', '飲品類 青檸汁(1KG/包)'), +(NOW(), 'system', 0, NOW(), 'system', 0, 'PP2416', 7, NULL, '0-4', '飲品類 芒果汁底(1KG/包)'), +(NOW(), 'system', 0, NOW(), 'system', 0, 'PP2422', 14, NULL, '0-4', '肉食類 泡椒雞翼尖(1KG)'), +(NOW(), 'system', 0, NOW(), 'system', 0, 'PP2229', 7, NULL, '0-4', '小菜類 特級泡菜(2磅/包)(P+7)'), +(NOW(), 'system', 0, NOW(), 'system', 0, 'PP1131', 7, NULL, '0-4', '汁水類 牛肝菌汁'), +(NOW(), 'system', 0, NOW(), 'system', 0, 'PP2249', 3, NULL, '0-4', '肉食類 醃西冷牛扒(10oz/包)'), +(NOW(), 'system', 0, NOW(), 'system', 0, 'PP1148', 7, NULL, '0-4', '粉麵﹑飯類 皮蛋瘦肉粥(500g/包)'), +(NOW(), 'system', 0, NOW(), 'system', 0, 'PP1091', 7, NULL, '0-4', '湯類 龍蝦湯膽'), +(NOW(), 'system', 0, NOW(), 'system', 0, 'PP2068', 7, NULL, '0-4', '湯類 碗仔翅'), +(NOW(), 'system', 0, NOW(), 'system', 0, 'PP2113', 7, NULL, '0-4', '醬料類 魚香肉醬(1人份量/包)'), +(NOW(), 'system', 0, NOW(), 'system', 0, 'PP1217', 7, NULL, '0-4', '醬料類 奶油芝士醬') +ON DUPLICATE KEY UPDATE + `defaultDays` = VALUES(`defaultDays`), + `openedDays` = VALUES(`openedDays`), + `storageC` = VALUES(`storageC`), + `remarks` = VALUES(`remarks`), + `modified` = NOW(), + `modifiedBy` = 'system', + `deleted` = 0; + +-- skipped -18-only item codes (not used for print yet): +-- PP1076, PP1069, PP1078, PP2252, PP1041, PP1066, PP1082, PP1157, PP1067, PP1074, PP1149, PP2306 +-- PP1210, PP2342, PP2349, PP2169, PP2242, PP2301, PP2269, PP2106, PP2292, PP2267, PP2144, PP2024 +-- PP2345, PP1065, MF0419, MB0857, MF0514, PP2251, PP2265, PP2260, PP1220, PP2335, PP2279, PP2278 +-- PP2341, PP2289, PP2368, PP2286, PP1071, PP2001, PP2061, PP2273, PP2272, PP2145, PP3002, MF0524 +-- MF0530, MF0539, MF0551, MG1665, PP0221, PP2347, PP2348 diff --git a/src/main/resources/db/changelog/changes/20260820_item_default_shelf_life/04_item_default_shelf_life_minus18_flag.sql b/src/main/resources/db/changelog/changes/20260820_item_default_shelf_life/04_item_default_shelf_life_minus18_flag.sql new file mode 100644 index 0000000..e37a5c4 --- /dev/null +++ b/src/main/resources/db/changelog/changes/20260820_item_default_shelf_life/04_item_default_shelf_life_minus18_flag.sql @@ -0,0 +1,8 @@ +--liquibase formatted sql + +--changeset fpsms:item_default_shelf_life_minus18_flag +--comment: Per-item flag useMinus18: 0 = print uses defaultDays (chilled), 1 = print uses minus18Days +ALTER TABLE `item_default_shelf_life` + MODIFY COLUMN `defaultDays` INT NULL COMMENT 'Non -18 warehouse shelf life days', + ADD COLUMN `minus18Days` INT NULL COMMENT '-18 warehouse shelf life days' AFTER `defaultDays`, + ADD COLUMN `useMinus18` TINYINT(1) NOT NULL DEFAULT 0 COMMENT '1 = expiry uses minus18Days; 0 = uses defaultDays' AFTER `minus18Days`; diff --git a/src/main/resources/db/changelog/changes/20260820_item_default_shelf_life/05_seed_item_default_shelf_life_minus18_flag.sql b/src/main/resources/db/changelog/changes/20260820_item_default_shelf_life/05_seed_item_default_shelf_life_minus18_flag.sql new file mode 100644 index 0000000..802e5a0 --- /dev/null +++ b/src/main/resources/db/changelog/changes/20260820_item_default_shelf_life/05_seed_item_default_shelf_life_minus18_flag.sql @@ -0,0 +1,220 @@ +--liquibase formatted sql + +--changeset fpsms:seed_item_default_shelf_life_minus18_flag +--comment: Upsert joExpiry.xlsx sheet XXXXXXXX: defaultDays + minus18Days + useMinus18 flag +-- generated 2026-08-20; rows=201 useMinus18=1 (frozen-only default)=43 +INSERT INTO `item_default_shelf_life` +(`created`, `createdBy`, `version`, `modified`, `modifiedBy`, `deleted`, + `itemCode`, `defaultDays`, `minus18Days`, `useMinus18`, `openedDays`, `storageC`, `remarks`) +VALUES +(NOW(), 'system', 0, NOW(), 'system', 0, 'PP2240', 60, NULL, 0, NULL, '0-4', '小菜類 TOA韓式泡菜'), +(NOW(), 'system', 0, NOW(), 'system', 0, 'PP2241', 60, NULL, 0, NULL, '0-4', '小菜類 TOA酸甜蘿蔔'), +(NOW(), 'system', 0, NOW(), 'system', 0, 'PP2327', 30, NULL, 0, NULL, '0-4', '小菜類 OEM香辣炒菜莆(1KG/包)(加工)'), +(NOW(), 'system', 0, NOW(), 'system', 0, 'PP2253', 14, 60, 0, NULL, '0-4', '小菜類 涼拌雙絲(1KG/包)'), +(NOW(), 'system', 0, NOW(), 'system', 0, 'PP2244', 14, 60, 0, NULL, '0-4', '小菜類 酸甜蘿蔔粒(2磅/包)'), +(NOW(), 'system', 0, NOW(), 'system', 0, 'PP2298', 14, 60, 0, NULL, '0-4', '小菜類 炒筍絲(200G)'), +(NOW(), 'system', 0, NOW(), 'system', 0, 'PP2018', 14, 60, 0, NULL, '0-4', '小菜類 炸菜肉絲(2磅/包)'), +(NOW(), 'system', 0, NOW(), 'system', 0, 'PP2008', 14, 60, 0, NULL, '0-4', '小菜類 雪菜肉絲(2磅/包)'), +(NOW(), 'system', 0, NOW(), 'system', 0, 'PP2058', 14, 60, 0, NULL, '0-4', '小菜類 (熟)大冬菇'), +(NOW(), 'system', 0, NOW(), 'system', 0, 'PP2364', 14, 60, 0, NULL, '0-4', '小菜類 炒雪菜(2磅/包)'), +(NOW(), 'system', 0, NOW(), 'system', 0, 'PP2376', 14, 60, 0, NULL, '0-4', '小菜類 切冬菇粒(1KG)'), +(NOW(), 'system', 0, NOW(), 'system', 0, 'PP2383', 14, 60, 0, NULL, '0-4', '小菜類 辣椒菜脯(1KG)'), +(NOW(), 'system', 0, NOW(), 'system', 0, 'PP1076', NULL, 150, 1, NULL, '-18', '汁水類 OEM照燒汁(10包/箱)'), +(NOW(), 'system', 0, NOW(), 'system', 0, 'PP1069', NULL, 150, 1, NULL, '-18', '汁水類 OEM甜酸汁(糖醋汁)(10包/箱)'), +(NOW(), 'system', 0, NOW(), 'system', 0, 'PP1078', NULL, 150, 1, NULL, '-18', '汁水類 OEM沙嗲牛肉汁'), +(NOW(), 'system', 0, NOW(), 'system', 0, 'PP2252', NULL, 150, 1, NULL, '-18', '汁水類 OEM牛肝菌汁(1KG)'), +(NOW(), 'system', 0, NOW(), 'system', 0, 'PP1041', NULL, 150, 1, NULL, '-18', '汁水類 OEM葡汁(10包/箱)'), +(NOW(), 'system', 0, NOW(), 'system', 0, 'PP1066', NULL, 150, 1, NULL, '-18', '汁水類 OEM蒜茸汁(10包/箱)'), +(NOW(), 'system', 0, NOW(), 'system', 0, 'PP1082', NULL, 150, 1, NULL, '-18', '汁水類 OEM鮮茄膽(12包/箱)'), +(NOW(), 'system', 0, NOW(), 'system', 0, 'PP1157', NULL, 150, 1, NULL, '-18', '汁水類 OEM焗飯汁'), +(NOW(), 'system', 0, NOW(), 'system', 0, 'PP1067', NULL, 150, 1, NULL, '-18', '汁水類 OEM黑椒汁(12包/箱)'), +(NOW(), 'system', 0, NOW(), 'system', 0, 'PP1074', NULL, 150, 1, NULL, '-18', '汁水類 OEM白汁(12包/箱)'), +(NOW(), 'system', 0, NOW(), 'system', 0, 'PP1149', NULL, 30, 1, NULL, '-18', '汁水類 泰式鳳爪汁'), +(NOW(), 'system', 0, NOW(), 'system', 0, 'PP1152', 30, 60, 0, NULL, '0-4', '汁水類 魚露味水(2磅/包)'), +(NOW(), 'system', 0, NOW(), 'system', 0, 'PP2274', 14, 60, 0, NULL, '0-4', '汁水類 西檸汁(1KG/包)'), +(NOW(), 'system', 0, NOW(), 'system', 0, 'PP2284', 14, 60, 0, NULL, '0-4', '汁水類 油醋汁(1KG/包)'), +(NOW(), 'system', 0, NOW(), 'system', 0, 'PP2306', NULL, 150, 1, NULL, '-18', '汁水類 蟹黃汁(1KG/包)'), +(NOW(), 'system', 0, NOW(), 'system', 0, 'PP1225', 14, 60, 0, NULL, '0-4', '汁水類 香辣薑汁'), +(NOW(), 'system', 0, NOW(), 'system', 0, 'PP1178', 30, 60, 0, NULL, '0-4', '汁水類 撈麵豉油(1磅/包)'), +(NOW(), 'system', 0, NOW(), 'system', 0, 'PP1043', 14, 60, 0, NULL, '0-4', '汁水類 豆豉汁(2磅/包)'), +(NOW(), 'system', 0, NOW(), 'system', 0, 'PP2206', 14, 60, 0, NULL, '0-4', '汁水類 鮮沙薑汁(雞廠)'), +(NOW(), 'system', 0, NOW(), 'system', 0, 'PP1126', 14, 60, 0, NULL, '0-4', '汁水類 石頭鍋汁'), +(NOW(), 'system', 0, NOW(), 'system', 0, 'PP1213', 30, 60, 0, NULL, '0-4', '汁水類 石頭飯辣汁(2磅/包)'), +(NOW(), 'system', 0, NOW(), 'system', 0, 'PP1137', 14, 60, 0, NULL, '0-4', '汁水類 咖喱魚蛋汁(2磅/包)'), +(NOW(), 'system', 0, NOW(), 'system', 0, 'PP1117', 14, 60, 0, NULL, '0-4', '汁水類 漁樂紅咖哩汁(2磅/包)'), +(NOW(), 'system', 0, NOW(), 'system', 0, 'PP2262', 7, 60, 0, NULL, '0-4', '汁水類 菇奶(1KG/包)'), +(NOW(), 'system', 0, NOW(), 'system', 0, 'PP2250', 30, 60, 0, NULL, '0-4', '汁水類 欖角汁(2磅/包)'), +(NOW(), 'system', 0, NOW(), 'system', 0, 'PP1214', 30, 60, 0, NULL, '0-4', '汁水類 炒粉絲汁(2磅/包)'), +(NOW(), 'system', 0, NOW(), 'system', 0, 'PP1209', 30, 60, 0, NULL, '0-4', '汁水類 扒飯豉油'), +(NOW(), 'system', 0, NOW(), 'system', 0, 'PP2243', 7, 60, 0, NULL, '0-4', '汁水類 粟米汁(2磅/包)'), +(NOW(), 'system', 0, NOW(), 'system', 0, 'PP2210', 7, 60, 0, NULL, '0-4', '汁水類 牛奶薑汁酒'), +(NOW(), 'system', 0, NOW(), 'system', 0, 'PP2214', 30, 60, 0, NULL, '0-4', '汁水類 丼飯汁(2磅/包)'), +(NOW(), 'system', 0, NOW(), 'system', 0, 'PP1224', 14, 60, 0, NULL, '0-4', '汁水類 柚子蒜蓉汁'), +(NOW(), 'system', 0, NOW(), 'system', 0, 'PP1216', 14, 60, 0, NULL, '0-4', '汁水類 叉燒蜜汁'), +(NOW(), 'system', 0, NOW(), 'system', 0, 'PP1210', NULL, 60, 1, NULL, '-18', '汁水類 泰式剌身汁(2磅/包)'), +(NOW(), 'system', 0, NOW(), 'system', 0, 'PP1144', 7, 60, 0, NULL, '0-4', '汁水類 越式汁'), +(NOW(), 'system', 0, NOW(), 'system', 0, 'PP1080', 14, 60, 0, NULL, '0-4', '汁水類 咖哩汁'), +(NOW(), 'system', 0, NOW(), 'system', 0, 'PP2211', 14, 60, 0, NULL, '0-4', '汁水類 瑤柱鮑汁 (蠔油汁)'), +(NOW(), 'system', 0, NOW(), 'system', 0, 'PP1234', 14, 60, 0, NULL, '0-4', '汁水類 日式咖哩汁'), +(NOW(), 'system', 0, NOW(), 'system', 0, 'PP2317', 14, 60, 0, NULL, '0-4', '汁水類 漁樂黃咖哩汁(1KG/包)'), +(NOW(), 'system', 0, NOW(), 'system', 0, 'PP2342', NULL, 60, 1, NULL, '-18', '汁水類 UURO龍蝦膽(1KG/包)'), +(NOW(), 'system', 0, NOW(), 'system', 0, 'PP2330', 60, 60, 0, NULL, '0-4', '汁水類 浸雞水(1KG)'), +(NOW(), 'system', 0, NOW(), 'system', 0, 'PP2360', 14, 60, 0, NULL, '0-4', '汁水類 瑞士汁(1KG/包)'), +(NOW(), 'system', 0, NOW(), 'system', 0, 'PP2333', 14, 60, 0, NULL, '0-4', '汁水類 豬鞍汁(1KG/包)'), +(NOW(), 'system', 0, NOW(), 'system', 0, 'PP2349', NULL, 150, 1, NULL, '-18', '汁水類 芋絲汁(200G/包)'), +(NOW(), 'system', 0, NOW(), 'system', 0, 'PP2331', 14, 60, 0, NULL, '0-4', '汁水類 青咖哩(1KG/包)'), +(NOW(), 'system', 0, NOW(), 'system', 0, 'PP2381', 14, 60, 0, NULL, '0-4', '汁水類 咖哩豬皮(1KG)'), +(NOW(), 'system', 0, NOW(), 'system', 0, 'PP2169', NULL, 150, 1, NULL, '-18', '肉食類 OEM滷肉(2磅/包)'), +(NOW(), 'system', 0, NOW(), 'system', 0, 'PP2242', NULL, 150, 1, NULL, '-18', '肉食類 TOA梅菜扣肉'), +(NOW(), 'system', 0, NOW(), 'system', 0, 'PP2301', NULL, 150, 1, NULL, '-18', '肉食類 OEM法式羊架(300G小包)(4小包/袋)(8袋/箱)'), +(NOW(), 'system', 0, NOW(), 'system', 0, 'PP2088', 14, 60, 0, NULL, '0-4', '肉食類 淨牛腩粒(2磅/包)'), +(NOW(), 'system', 0, NOW(), 'system', 0, 'PP2271', 90, 60, 0, NULL, '0-4', '肉食類 豬腳薑(350g/包) - 台嵐款'), +(NOW(), 'system', 0, NOW(), 'system', 0, 'PP2290', 14, 60, 0, NULL, '0-4', '肉食類 豬軟骨(400G/包)'), +(NOW(), 'system', 0, NOW(), 'system', 0, 'PP2269', NULL, 30, 1, NULL, '-18', '肉食類 南乳豬手(1人份量/包)'), +(NOW(), 'system', 0, NOW(), 'system', 0, 'PP2106', NULL, 30, 1, NULL, '-18', '肉食類 東坡肉(1人份/包)'), +(NOW(), 'system', 0, NOW(), 'system', 0, 'PP2292', NULL, 30, 1, NULL, '-18', '肉食類 五香牛雜(180G/包)'), +(NOW(), 'system', 0, NOW(), 'system', 0, 'PP2017', 14, 60, 0, NULL, '0-4', '肉食類 咸瘦肉絲'), +(NOW(), 'system', 0, NOW(), 'system', 0, 'PP2098', 60, NULL, 0, NULL, '0-4', '肉食類 臘腸粒(1磅/包)'), +(NOW(), 'system', 0, NOW(), 'system', 0, 'PP2127', 14, 60, 0, NULL, '0-4', '肉食類 五香肉丁'), +(NOW(), 'system', 0, NOW(), 'system', 0, 'PP2105', 14, 60, 0, NULL, '0-4', '肉食類 咖喱牛腩(1人份量/包)'), +(NOW(), 'system', 0, NOW(), 'system', 0, 'PP2267', NULL, 30, 1, NULL, '-18', '肉食類 法式牛肋條 (320g/包)'), +(NOW(), 'system', 0, NOW(), 'system', 0, 'PP2037', 14, 60, 0, NULL, '0-4', '肉食類 柱侯牛筋腩(2磅/包)'), +(NOW(), 'system', 0, NOW(), 'system', 0, 'PP2115', 14, 60, 0, NULL, '0-4', '肉食類 瑞士雞翼(1人份量/包)'), +(NOW(), 'system', 0, NOW(), 'system', 0, 'PP2193', 14, 60, 0, NULL, '0-4', '肉食類 原條牛奶叉燒'), +(NOW(), 'system', 0, NOW(), 'system', 0, 'PP2063', 14, 60, 0, NULL, '0-4', '肉食類 咖哩牛腩(2磅/包)'), +(NOW(), 'system', 0, NOW(), 'system', 0, 'PP2167', 14, 60, 0, NULL, '0-4', '肉食類 牛丼'), +(NOW(), 'system', 0, NOW(), 'system', 0, 'PP2144', NULL, 30, 1, NULL, '-18', '肉食類 泰式鳳爪(1.5磅/包)'), +(NOW(), 'system', 0, NOW(), 'system', 0, 'PP2024', NULL, 60, 1, NULL, '-18', '肉食類 豬肚'), +(NOW(), 'system', 0, NOW(), 'system', 0, 'PP2334', 14, 60, 0, NULL, '0-4', '肉食類 原條牛奶叉燒(1KG)'), +(NOW(), 'system', 0, NOW(), 'system', 0, 'PP2345', NULL, 60, 1, NULL, '-18', '肉食類 一品煲(380G/包)'), +(NOW(), 'system', 0, NOW(), 'system', 0, 'PP2366', 14, 60, 0, NULL, '0-4', '肉食類 清湯牛腩'), +(NOW(), 'system', 0, NOW(), 'system', 0, 'PP2378', 14, 60, 0, NULL, '0-4', '肉食類 醬香鳳爪(1KG)'), +(NOW(), 'system', 0, NOW(), 'system', 0, 'PP2325', 7, 60, 0, NULL, '0-4', '沙律 薯仔蛋沙律(2.2KG)'), +(NOW(), 'system', 0, NOW(), 'system', 0, 'PP1065', NULL, 150, 1, NULL, '-18', '油類 OEM龍蝦油'), +(NOW(), 'system', 0, NOW(), 'system', 0, 'PP2300', 60, 60, 0, NULL, '0-4', '油類 凱撒牛油(1KG/包)'), +(NOW(), 'system', 0, NOW(), 'system', 0, 'PP1088', 14, 60, 0, NULL, '0-4', '油類 海南雞油(2磅/包)'), +(NOW(), 'system', 0, NOW(), 'system', 0, 'PP1118', 14, 60, 0, NULL, '0-4', '油類 漁樂煉豬油'), +(NOW(), 'system', 0, NOW(), 'system', 0, 'PP1185', 14, 60, 0, NULL, '0-4', '油類 蒜香牛油'), +(NOW(), 'system', 0, NOW(), 'system', 0, 'PP1120', 14, 60, 0, NULL, '0-4', '油類 漁樂蝦頭油(1磅/包)'), +(NOW(), 'system', 0, NOW(), 'system', 0, 'MF0419', NULL, 60, 1, NULL, '-18', '粉麵﹑飯類 白飯(24個/箱)'), +(NOW(), 'system', 0, NOW(), 'system', 0, 'MB0857', NULL, 60, 1, NULL, '-18', '粉麵﹑飯類 TOA蛋炒飯 (36個/箱)'), +(NOW(), 'system', 0, NOW(), 'system', 0, 'MF0514', NULL, 150, 1, NULL, '-18', '粉麵﹑飯類 TOA 海南雞油飯(280g/個 24個/箱)'), +(NOW(), 'system', 0, NOW(), 'system', 0, 'PP2277', 3, 60, 0, NULL, '0-4', '粉麵﹑飯類 烚意粉(1KG/包)'), +(NOW(), 'system', 0, NOW(), 'system', 0, 'PP2276', 3, 60, 0, NULL, '0-4', '粉麵﹑飯類 烚通粉(1KG/包)'), +(NOW(), 'system', 0, NOW(), 'system', 0, 'PP1136', 14, 60, 0, NULL, '0-4', '粉麵﹑飯類 白粥'), +(NOW(), 'system', 0, NOW(), 'system', 0, 'PP1133', 14, 60, 0, NULL, '0-4', '粉麵﹑飯類 豬骨粥(1位份量)(1磅/包)'), +(NOW(), 'system', 0, NOW(), 'system', 0, 'PP2363', 14, 60, 0, NULL, '0-4', '粉麵﹑飯類 炒麵底(170G/包)'), +(NOW(), 'system', 0, NOW(), 'system', 0, 'PP2251', NULL, 150, 1, NULL, '-18', '湯類 OEM龍蝦湯膽(1KG)'), +(NOW(), 'system', 0, NOW(), 'system', 0, 'PP2265', NULL, 150, 1, NULL, '-18', '湯類 OEM中式例湯(350g/包)'), +(NOW(), 'system', 0, NOW(), 'system', 0, 'PP2260', NULL, 150, 1, NULL, '-18', '湯類 OEM碗仔翅(1KG)'), +(NOW(), 'system', 0, NOW(), 'system', 0, 'PP1220', NULL, 150, 1, NULL, '-18', '湯類 OEM意大利菜湯'), +(NOW(), 'system', 0, NOW(), 'system', 0, 'PP2335', NULL, 150, 1, NULL, '-18', '湯類 OEM雜菜湯(12包/箱)'), +(NOW(), 'system', 0, NOW(), 'system', 0, 'PP2228', 60, 60, 0, NULL, '0-4', '湯類 香濃沙嗲湯膽(1磅/包)'), +(NOW(), 'system', 0, NOW(), 'system', 0, 'PP1180', 7, 60, 0, NULL, '0-4', '湯類 菇湯(2磅/包)'), +(NOW(), 'system', 0, NOW(), 'system', 0, 'PP2279', NULL, 30, 1, NULL, '-18', '湯類 茶樹菇排骨湯(170G/包)'), +(NOW(), 'system', 0, NOW(), 'system', 0, 'PP2278', NULL, 30, 1, NULL, '-18', '湯類 胡椒豬肚雞湯(655G/包)'), +(NOW(), 'system', 0, NOW(), 'system', 0, 'PP2246', 14, 60, 0, NULL, '0-4', '湯類 酸辣湯(2磅/包)'), +(NOW(), 'system', 0, NOW(), 'system', 0, 'PP2282', 14, 60, 0, NULL, '0-4', '湯類 冷麵湯(1KG/包)'), +(NOW(), 'system', 0, NOW(), 'system', 0, 'PP2248', 7, 60, 0, NULL, '0-4', '湯類 無添加魚湯(400ml/包)'), +(NOW(), 'system', 0, NOW(), 'system', 0, 'PP2328', 7, 60, 0, NULL, '0-4', '湯類 鹿茸菇湯(1KG/包)'), +(NOW(), 'system', 0, NOW(), 'system', 0, 'PP2341', NULL, 60, 1, NULL, '-18', '湯類 日式雞湯(2KG/包)'), +(NOW(), 'system', 0, NOW(), 'system', 0, 'PP2332', 14, 60, 0, NULL, '0-4', '湯類 雞絲碗仔翅(1KG/包)'), +(NOW(), 'system', 0, NOW(), 'system', 0, 'PP2329', 14, 60, 0, NULL, '0-4', '湯類 茶樹菇排骨湯(1KG/包)'), +(NOW(), 'system', 0, NOW(), 'system', 0, 'PP2288', 3, NULL, 0, NULL, '0-4', '飲品類 香水檸檬汁P+3(0.8L/包)'), +(NOW(), 'system', 0, NOW(), 'system', 0, 'PP1175', 4, NULL, 0, NULL, '0-4', '飲品類 鮮檸檬汁(P+4)'), +(NOW(), 'system', 0, NOW(), 'system', 0, 'PP2237', 10, NULL, 0, NULL, '0-4', '飲品類 (樽裝用)凍咖啡底P+10(0.9L/包)'), +(NOW(), 'system', 0, NOW(), 'system', 0, 'PP2238', 10, NULL, 0, NULL, '0-4', '飲品類 (餐廳用)凍咖啡底P+10(0.9L/包)'), +(NOW(), 'system', 0, NOW(), 'system', 0, 'PP2236', 10, NULL, 0, NULL, '0-4', '飲品類 (樽裝用)凍奶茶底P+10(1L/包)'), +(NOW(), 'system', 0, NOW(), 'system', 0, 'PP2239', 10, NULL, 0, NULL, '0-4', '飲品類 (餐廳用)凍奶茶底P+10(1L/包)'), +(NOW(), 'system', 0, NOW(), 'system', 0, 'PP2336', 10, NULL, 0, NULL, '0-4', '飲品類 (無糖)凍咖啡底P+10(0.9L/包)'), +(NOW(), 'system', 0, NOW(), 'system', 0, 'PP2338', 10, NULL, 0, NULL, '0-4', '飲品類 (無糖)凍奶茶底P+10(1L/包)'), +(NOW(), 'system', 0, NOW(), 'system', 0, 'PP1167', 3, NULL, 0, NULL, '0-4', '飲品類 原個檸檬(10個/包)'), +(NOW(), 'system', 0, NOW(), 'system', 0, 'PP2368', NULL, 150, 1, NULL, '-18', '飲品類 冷壓菠蘿汁'), +(NOW(), 'system', 0, NOW(), 'system', 0, 'PP2382', 10, NULL, 0, NULL, '0-4', '飲品類 凍檸茶膽P+10(1000ML)'), +(NOW(), 'system', 0, NOW(), 'system', 0, 'PP2346', 14, 60, 0, NULL, '0-4', '蔬菜類 南乳蓮藕(225G/包)'), +(NOW(), 'system', 0, NOW(), 'system', 0, 'PP2226', 14, 60, 0, NULL, '0-4', '餡料類 野菌沙嗲牛肉餡 (2磅/包)'), +(NOW(), 'system', 0, NOW(), 'system', 0, 'PP2080', 14, 60, 0, NULL, '0-4', '餡料類 黑椒牛肉餡'), +(NOW(), 'system', 0, NOW(), 'system', 0, 'PP2219', 14, 60, 0, NULL, '0-4', '餡料類 葡汁雞餡'), +(NOW(), 'system', 0, NOW(), 'system', 0, 'PP2126', 14, 60, 0, NULL, '0-4', '餡料類 白汁蘑菇雞批餡'), +(NOW(), 'system', 0, NOW(), 'system', 0, 'PP2220', 14, 60, 0, NULL, '0-4', '餡料類 糯米糍餡'), +(NOW(), 'system', 0, NOW(), 'system', 0, 'PP2074', 14, 60, 0, NULL, '0-4', '餡料類 叉燒飽餡'), +(NOW(), 'system', 0, NOW(), 'system', 0, 'PP2218', 14, 60, 0, NULL, '0-4', '餡料類 青醬白汁雞餡'), +(NOW(), 'system', 0, NOW(), 'system', 0, 'PP2286', NULL, 150, 1, NULL, '-18', '醬料類 OEM黑松露醬(500G)'), +(NOW(), 'system', 0, NOW(), 'system', 0, 'PP1071', NULL, 150, 1, NULL, '-18', '醬料類 OEM肉醬汁(12包/箱)'), +(NOW(), 'system', 0, NOW(), 'system', 0, 'PP2001', NULL, 150, 1, NULL, '-18', '醬料類 金沙咸蛋黃(2磅/包)'), +(NOW(), 'system', 0, NOW(), 'system', 0, 'PP2204', 90, 60, 0, NULL, '0-4', '醬料類 八寶醬'), +(NOW(), 'system', 0, NOW(), 'system', 0, 'PP1223', 30, 60, 0, NULL, '0-4', '醬料類 韓式泡菜醬'), +(NOW(), 'system', 0, NOW(), 'system', 0, 'PP2205', 30, 60, 0, NULL, '0-4', '醬料類 沙薑醬'), +(NOW(), 'system', 0, NOW(), 'system', 0, 'PP1121', 30, 60, 0, NULL, '0-4', '醬料類 漁樂馬拉盞(2磅/包)'), +(NOW(), 'system', 0, NOW(), 'system', 0, 'PP2234', 14, 60, 0, NULL, '0-4', '醬料類 韓樂黑炸醬(1磅/包)'), +(NOW(), 'system', 0, NOW(), 'system', 0, 'PP2212', 14, 60, 0, NULL, '0-4', '醬料類 青醬'), +(NOW(), 'system', 0, NOW(), 'system', 0, 'PP1062', 14, 60, 0, NULL, '0-4', '醬料類 XO醬(1磅/包)'), +(NOW(), 'system', 0, NOW(), 'system', 0, 'PP2227', 60, 60, 0, NULL, '0-4', '醬料類 香辣腐乳醬(2磅/包)'), +(NOW(), 'system', 0, NOW(), 'system', 0, 'PP1042', 14, 60, 0, NULL, '0-4', '醬料類 口水雞汁(2磅/包)'), +(NOW(), 'system', 0, NOW(), 'system', 0, 'PP1181', 14, 60, 0, NULL, '0-4', '醬料類 韓式撈雞醬(2磅/包)'), +(NOW(), 'system', 0, NOW(), 'system', 0, 'PP1018', 60, 60, 0, NULL, '0-4', '醬料類 沙嗲醬(2磅/包)'), +(NOW(), 'system', 0, NOW(), 'system', 0, 'PP2215', 14, 60, 0, NULL, '0-4', '醬料類 海南雞醬(1磅/包)'), +(NOW(), 'system', 0, NOW(), 'system', 0, 'PP1232', 14, 60, 0, NULL, '0-4', '醬料類 剁椒醬(1磅/包)'), +(NOW(), 'system', 0, NOW(), 'system', 0, 'PP1222', 30, 60, 0, NULL, '0-4', '醬料類 拌飯醬汁(1磅/包)'), +(NOW(), 'system', 0, NOW(), 'system', 0, 'PP2235', 7, 60, 0, NULL, '0-4', '醬料類 芝士醬(菠菜用)(1LB)'), +(NOW(), 'system', 0, NOW(), 'system', 0, 'PP1215', 14, 60, 0, NULL, '0-4', '醬料類 黑松露醬'), +(NOW(), 'system', 0, NOW(), 'system', 0, 'PP2254', 60, 60, 0, NULL, '0-4', '醬料類 蒜蓉豆豉粒(1KG/包)'), +(NOW(), 'system', 0, NOW(), 'system', 0, 'PP1093', 7, 60, 0, NULL, '0-4', '醬料類 肉燥拉麵醬'), +(NOW(), 'system', 0, NOW(), 'system', 0, 'PP1211', 14, 60, 0, NULL, '0-4', '醬料類 船麵醬(2磅/包)'), +(NOW(), 'system', 0, NOW(), 'system', 0, 'PP1231', 7, 60, 0, NULL, '0-4', '醬料類 金銀蒜(1磅/包)'), +(NOW(), 'system', 0, NOW(), 'system', 0, 'PP1237', 14, 60, 0, NULL, '0-4', '醬料類 蒜泥(1磅/包)'), +(NOW(), 'system', 0, NOW(), 'system', 0, 'PP1202', 14, 60, 0, NULL, '0-4', '醬料類 薑蓉'), +(NOW(), 'system', 0, NOW(), 'system', 0, 'PP1044', 60, 60, 0, NULL, '0-4', '醬料類 漁樂沙茶醬(1磅/包)'), +(NOW(), 'system', 0, NOW(), 'system', 0, 'PP2326', 60, 60, 0, NULL, '0-4', '醬料類 南乳醬(1KG)'), +(NOW(), 'system', 0, NOW(), 'system', 0, 'PP2322', 120, NULL, 0, NULL, '0-4', '醬料類 麥芽糖(1KG/包)'), +(NOW(), 'system', 0, NOW(), 'system', 0, 'PP1188', 30, NULL, 0, NULL, '0-4', '醬料類 咖喱膽(2磅/包)'), +(NOW(), 'system', 0, NOW(), 'system', 0, 'PP2373', 14, 60, 0, NULL, '0-4', '醬料類 燒味飯汁(2KG)'), +(NOW(), 'system', 0, NOW(), 'system', 0, 'PP2375', 14, 60, 0, NULL, '0-4', '醬料類 車仔麵醬(1KG)'), +(NOW(), 'system', 0, NOW(), 'system', 0, 'PP2182', 14, NULL, 0, NULL, '0-4', '雜項 熟薏米 (2磅/包)'), +(NOW(), 'system', 0, NOW(), 'system', 0, 'PP2231', 7, NULL, 0, NULL, '0-4', '雜項 花膠(1磅/包)'), +(NOW(), 'system', 0, NOW(), 'system', 0, 'PP2384', 14, NULL, 0, NULL, '0-4', '柚皮 (300G)'), +(NOW(), 'system', 0, NOW(), 'system', 0, 'PP2386', 7, NULL, 0, NULL, '0-4', '飲品類 楊枝甘露(250G)'), +(NOW(), 'system', 0, NOW(), 'system', 0, 'PP2387', 14, 60, 0, NULL, '0-4', '肉食類 滷水雞翼尖(1KG)'), +(NOW(), 'system', 0, NOW(), 'system', 0, 'PP2388', 14, 60, 0, NULL, '0-4', '小菜類 日式蘿蔔(1KG)'), +(NOW(), 'system', 0, NOW(), 'system', 0, 'PP2393', 7, 60, 0, NULL, '0-4', '醬料類 芝士醬(菠菜用)(2LB)'), +(NOW(), 'system', 0, NOW(), 'system', 0, 'PP2395', 14, 60, 0, NULL, '0-4', '肉食類 法式田螺(500G)'), +(NOW(), 'system', 0, NOW(), 'system', 0, 'PP2396', 14, 60, 0, NULL, '0-4', '肉食類 沙嗲牛肉(1KG/包)'), +(NOW(), 'system', 0, NOW(), 'system', 0, 'PP2398', 14, 60, 0, NULL, '0-4', '汁水類 PP葡汁(1KG/包)'), +(NOW(), 'system', 0, NOW(), 'system', 0, 'PP2399', 14, 60, 0, NULL, '0-4', '汁水類 PP蒜茸汁(1KG/包)'), +(NOW(), 'system', 0, NOW(), 'system', 0, 'PP2400', 14, 60, 0, NULL, '0-4', '汁水類 PP甜酸汁(1KG/包)'), +(NOW(), 'system', 0, NOW(), 'system', 0, 'PP2401', 14, 60, 0, NULL, '0-4', '汁水類 PP照燒汁(1KG)'), +(NOW(), 'system', 0, NOW(), 'system', 0, 'PP2402', 14, 60, 0, NULL, '0-4', '汁水類 PP沙嗲牛肉汁(1KG)'), +(NOW(), 'system', 0, NOW(), 'system', 0, 'PP2403', 14, 60, 0, NULL, '0-4', '汁水類 PP牛肝菌汁(1KG)'), +(NOW(), 'system', 0, NOW(), 'system', 0, 'PP2404', 14, 60, 0, NULL, '0-4', '汁水類 PP黑椒汁(1KG)'), +(NOW(), 'system', 0, NOW(), 'system', 0, 'PP2405', 14, 60, 0, NULL, '0-4', '汁水類 PP肉醬汁(1KG)'), +(NOW(), 'system', 0, NOW(), 'system', 0, 'PP2406', 7, 60, 0, NULL, '0-4', '汁水類 PP白汁(1KG/包)'), +(NOW(), 'system', 0, NOW(), 'system', 0, 'PP2407', 14, 60, 0, NULL, '0-4', '汁水類 PP鮮茄膽(1KG)'), +(NOW(), 'system', 0, NOW(), 'system', 0, 'PP2408', 14, 60, 0, NULL, '0-4', '汁水類 PP龍蝦湯膽(1KG)'), +(NOW(), 'system', 0, NOW(), 'system', 0, 'PP2409', 14, 60, 0, NULL, '0-4', '汁水類 PP焗飯汁(1KG)'), +(NOW(), 'system', 0, NOW(), 'system', 0, 'PP2410', 14, 60, 0, NULL, '0-4', '汁水類 PP雜菜湯(1KG)'), +(NOW(), 'system', 0, NOW(), 'system', 0, 'PP2411', 14, 60, 0, NULL, '0-4', '汁水類 PP碗仔翅(1KG)'), +(NOW(), 'system', 0, NOW(), 'system', 0, 'PP2412', 14, 60, 0, NULL, '0-4', '小菜類 PP酸甜蘿蔔(1KG/包)'), +(NOW(), 'system', 0, NOW(), 'system', 0, 'PP2415', 14, 60, 0, NULL, '0-4', '油類 PP龍蝦油(1KG/包)'), +(NOW(), 'system', 0, NOW(), 'system', 0, 'PP0261', 4, NULL, 0, NULL, '0-4', '飲品類 青檸汁(1KG/包)'), +(NOW(), 'system', 0, NOW(), 'system', 0, 'PP2416', 7, NULL, 0, NULL, '0-4', '飲品類 芒果汁底(1KG/包)'), +(NOW(), 'system', 0, NOW(), 'system', 0, 'PP2422', 14, NULL, 0, NULL, '0-4', '肉食類 泡椒雞翼尖(1KG)'), +(NOW(), 'system', 0, NOW(), 'system', 0, 'PP2229', 7, NULL, 0, NULL, '0-4', '小菜類 特級泡菜(2磅/包)(P+7)'), +(NOW(), 'system', 0, NOW(), 'system', 0, 'PP1131', 7, NULL, 0, NULL, '0-4', '汁水類 牛肝菌汁'), +(NOW(), 'system', 0, NOW(), 'system', 0, 'PP2145', NULL, 30, 1, NULL, '-18', '肉食類 炆羊腩(1人份量/包)'), +(NOW(), 'system', 0, NOW(), 'system', 0, 'PP2249', 3, NULL, 0, NULL, '0-4', '肉食類 醃西冷牛扒(10oz/包)'), +(NOW(), 'system', 0, NOW(), 'system', 0, 'PP1148', 7, NULL, 0, NULL, '0-4', '粉麵﹑飯類 皮蛋瘦肉粥(500g/包)'), +(NOW(), 'system', 0, NOW(), 'system', 0, 'PP3002', NULL, 60, 1, NULL, '-18', '湯類 蓮藕湯(1人份/包)'), +(NOW(), 'system', 0, NOW(), 'system', 0, 'PP1091', 7, NULL, 0, NULL, '0-4', '湯類 龍蝦湯膽'), +(NOW(), 'system', 0, NOW(), 'system', 0, 'PP2068', 7, NULL, 0, NULL, '0-4', '湯類 碗仔翅'), +(NOW(), 'system', 0, NOW(), 'system', 0, 'PP2113', 7, NULL, 0, NULL, '0-4', '醬料類 魚香肉醬(1人份量/包)'), +(NOW(), 'system', 0, NOW(), 'system', 0, 'PP1217', 7, NULL, 0, NULL, '0-4', '醬料類 奶油芝士醬') +ON DUPLICATE KEY UPDATE + `defaultDays` = VALUES(`defaultDays`), + `minus18Days` = VALUES(`minus18Days`), + `useMinus18` = VALUES(`useMinus18`), + `openedDays` = VALUES(`openedDays`), + `storageC` = VALUES(`storageC`), + `remarks` = VALUES(`remarks`), + `modified` = NOW(), + `modifiedBy` = 'system', + `deleted` = 0; diff --git a/src/main/resources/db/changelog/changes/20260820_onpack_template_file/01_create_onpack_template_file.sql b/src/main/resources/db/changelog/changes/20260820_onpack_template_file/01_create_onpack_template_file.sql new file mode 100644 index 0000000..bc2a7cb --- /dev/null +++ b/src/main/resources/db/changelog/changes/20260820_onpack_template_file/01_create_onpack_template_file.sql @@ -0,0 +1,21 @@ +--liquibase formatted sql + +--changeset fpsms:create_onpack_template_file +--comment: User-maintained OnPack template files (汁水機 / 檸檬機). ZIP load prefers these over classpath. +CREATE TABLE `onpack_template_file` +( + `id` INT NOT NULL AUTO_INCREMENT, + `created` DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP, + `createdBy` VARCHAR(30) NULL DEFAULT NULL, + `version` INT NOT NULL DEFAULT '0', + `modified` DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP, + `modifiedBy` VARCHAR(30) NULL DEFAULT NULL, + `deleted` TINYINT(1) NOT NULL DEFAULT '0', + `machine` VARCHAR(20) NOT NULL COMMENT 'juice=onpack2030 汁水機; lemon=onpack2030_2 檸檬機', + `itemCode` VARCHAR(50) NOT NULL, + `fileName` VARCHAR(200) NOT NULL, + `byteSize` INT NOT NULL, + `fileBytes` MEDIUMBLOB NOT NULL, + CONSTRAINT pk_onpack_template_file PRIMARY KEY (`id`), + UNIQUE KEY uk_onpack_template_file (`machine`, `itemCode`, `fileName`) +); diff --git a/src/main/resources/onpack2030_exp/pp1181.image b/src/main/resources/onpack2030_exp/pp1181.image new file mode 100644 index 0000000..b4dcf4d Binary files /dev/null and b/src/main/resources/onpack2030_exp/pp1181.image differ 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 new file mode 100644 index 0000000..376fb3d --- /dev/null +++ b/src/test/kotlin/com/ffii/fpsms/modules/jobOrder/service/LaserBag2PayloadTest.kt @@ -0,0 +1,38 @@ +package com.ffii.fpsms.modules.jobOrder.service + +import org.junit.jupiter.api.Assertions.assertEquals +import org.junit.jupiter.api.Test + +class LaserBag2PayloadTest { + + @Test + fun payload_without_expiry_keeps_original_three_fields() { + assertEquals( + "{\"itemId\":1,\"stockInLineId\":2};PP1175;鮮檸檬汁;;", + PlasticBagPrinterService.buildLaserBag2Payload(1, 2, "PP1175", "鮮檸檬汁", null), + ) + assertEquals( + "0;PP1175;鮮檸檬汁;;", + PlasticBagPrinterService.buildLaserBag2Payload(null, null, "PP1175", "鮮檸檬汁"), + ) + } + + @Test + fun payload_with_iso_expiry_adds_fourth_print_label_field() { + assertEquals( + "{\"itemId\":1,\"stockInLineId\":2};PP1175;鮮檸檬汁;Expiry Date 20260821;;", + PlasticBagPrinterService.buildLaserBag2Payload(1, 2, "PP1175", "鮮檸檬汁", "2026-08-21"), + ) + } + + @Test + fun formatLaserExpiryParam_accepts_iso_compact_and_print_label() { + assertEquals("Expiry Date 20260821", PlasticBagPrinterService.formatLaserExpiryParam("2026-08-21")) + assertEquals("Expiry Date 20260821", PlasticBagPrinterService.formatLaserExpiryParam("20260821")) + assertEquals( + "Expiry Date 20260821", + PlasticBagPrinterService.formatLaserExpiryParam("Expiry Date 20260821"), + ) + assertEquals("", PlasticBagPrinterService.formatLaserExpiryParam(" ")) + } +} diff --git a/src/test/kotlin/com/ffii/fpsms/modules/jobOrder/service/OnPackJuiceExpiryXmlTest.kt b/src/test/kotlin/com/ffii/fpsms/modules/jobOrder/service/OnPackJuiceExpiryXmlTest.kt new file mode 100644 index 0000000..a9ace27 --- /dev/null +++ b/src/test/kotlin/com/ffii/fpsms/modules/jobOrder/service/OnPackJuiceExpiryXmlTest.kt @@ -0,0 +1,109 @@ +package com.ffii.fpsms.modules.jobOrder.service + +import org.junit.jupiter.api.Assertions.assertEquals +import org.junit.jupiter.api.Assertions.assertFalse +import org.junit.jupiter.api.Assertions.assertTrue +import org.junit.jupiter.api.Test + +class OnPackJuiceExpiryXmlTest { + + private val sample = """ + DEG_053007100 + LOGO_3650025000BLACKdate.bmp42031173 + LOGO_4775037500BLACKpp1080qr.bmp39133000 + + """.trimIndent() + + @Test + fun rewriteLogoFileName_updates_logo_not_logo2() { + val xml = """ + + LOGOold-product.bmp + LOGO_2old-code.bmp + + """.trimIndent() + val out = OnPackJuiceExpiryXml.rewriteLogoFileName(xml, "LOGO", "pp1181Product.bmp") + val out2 = OnPackJuiceExpiryXml.rewriteLogoFileName(out, "LOGO_2", "pp1181Code.bmp") + assertTrue(out2.contains("LOGOpp1181Product.bmp")) + assertTrue(out2.contains("LOGO_2pp1181Code.bmp")) + assertFalse(out2.contains("old-product.bmp")) + assertFalse(out2.contains("old-code.bmp")) + } + + @Test + fun applyExpiry_places_expiry_above_moved_qr() { + val out = OnPackJuiceExpiryXml.applyExpiry(sample, "pp1080exp.bmp", 1800) + val layout = OnPackJuiceExpiryXml.layoutFor(sample, 1800) + assertTrue(out.contains("LOGO_EXP")) + assertTrue(out.contains("pp1080exp.bmp")) + assertTrue(out.contains("date.bmp")) + assertTrue(out.contains("pp1080qr.bmp")) + assertTrue(out.contains("${layout.dateHeight}")) + assertTrue(out.contains("${layout.expY}")) + assertTrue(out.contains("${layout.qrY}")) + assertTrue(layout.expY + layout.expHeight <= layout.qrY) + assertEquals(1, Regex("LOGO_3").findAll(out).count()) + val expIdx = out.indexOf("LOGO_EXP") + val qrIdx = out.indexOf("LOGO_4") + assertTrue(expIdx in 1 until qrIdx) + } + + @Test + fun applyExpiry_replaces_existing_logo_exp_filename_and_geometry() { + val once = OnPackJuiceExpiryXml.applyExpiry(sample, "pp1080exp.bmp", 1800) + val twice = OnPackJuiceExpiryXml.applyExpiry(once, "otherexp.bmp", 1800) + assertEquals(1, Regex("LOGO_EXP").findAll(twice).count()) + assertTrue(twice.contains("otherexp.bmp")) + assertFalse(twice.contains("pp1080exp.bmp")) + assertTrue(twice.contains("date.bmp")) + } + + @Test + fun applyExpiry_rewrites_logo5_filename_without_injecting_logo_exp() { + val designed = """ + DEG_053007100 + LOGO_3850020000BLACKdate.bmp42031173 + LOGO_510030000BLACKold-exp.bmp5187657 + LOGO_4925040000BLACKpp1181qr.bmp48362500 + + """.trimIndent() + val out = OnPackJuiceExpiryXml.applyExpiry(designed, "pp1181exp.bmp", 1800) + assertEquals("LOGO_5", OnPackJuiceExpiryXml.existingExpirySlotName(designed)) + assertFalse(out.contains("LOGO_EXP")) + assertEquals(1, Regex("LOGO_5").findAll(out).count()) + assertTrue(out.contains("pp1181exp.bmp")) + assertFalse(out.contains("old-exp.bmp")) + assertTrue(out.contains("3000")) + assertTrue(out.contains("4000")) + assertTrue(out.contains("1173")) + assertTrue(out.contains("date.bmp")) + } + + @Test + fun applyExpiry_inserts_pp2211exp_above_qr_on_real_template() { + val bytes = javaClass.classLoader.getResourceAsStream("onpack2030/pp2211.image")!!.use { it.readBytes() } + val xml = String(bytes.copyOfRange(2, bytes.size), Charsets.UTF_16LE) + val out = OnPackJuiceExpiryXml.applyExpiry(xml, "pp2211exp.bmp", 1800) + val layout = OnPackJuiceExpiryXml.layoutFor(xml, 1800) + assertTrue(out.contains("LOGO_EXP")) + assertTrue(out.contains("pp2211exp.bmp")) + assertTrue(out.contains("pp2211qr.bmp")) + assertTrue(layout.expY + layout.expHeight + 200 <= layout.qrY) + assertTrue(layout.qrY > 3750) + } + + @Test + fun applyExpiry_uses_real_pp1181_exp_template_slot() { + val bytes = javaClass.classLoader.getResourceAsStream("onpack2030_exp/pp1181.image")!!.use { it.readBytes() } + val xml = String(bytes.copyOfRange(2, bytes.size), Charsets.UTF_16LE) + val out = OnPackJuiceExpiryXml.applyExpiry(xml, "pp1181exp.bmp", 1800) + assertEquals("LOGO_5", OnPackJuiceExpiryXml.existingExpirySlotName(xml)) + assertFalse(out.contains("LOGO_EXP")) + assertTrue(out.contains("LOGO_5")) + assertTrue(out.contains("pp1181exp.bmp")) + val y4 = Regex("""(?s)\s*LOGO_4\s*[\s\S]*?\s*(\d+)\s*""").find(out)?.groupValues?.get(1) + assertEquals("4000", y4) + val y5 = Regex("""(?s)\s*LOGO_5\s*[\s\S]*?\s*(\d+)\s*""").find(out)?.groupValues?.get(1) + assertEquals("3000", y5) + } +} diff --git a/src/test/kotlin/com/ffii/fpsms/modules/jobOrder/service/OnPackLemonExpiryXmlTest.kt b/src/test/kotlin/com/ffii/fpsms/modules/jobOrder/service/OnPackLemonExpiryXmlTest.kt new file mode 100644 index 0000000..2054c58 --- /dev/null +++ b/src/test/kotlin/com/ffii/fpsms/modules/jobOrder/service/OnPackLemonExpiryXmlTest.kt @@ -0,0 +1,40 @@ +package com.ffii.fpsms.modules.jobOrder.service + +import org.junit.jupiter.api.Assertions.assertEquals +import org.junit.jupiter.api.Assertions.assertFalse +import org.junit.jupiter.api.Assertions.assertTrue +import org.junit.jupiter.api.Test + +class OnPackLemonExpiryXmlTest { + + private val sample = """ + + TEXT_232900225090 + 783 + PP1175-21 + TEXT_342000225090 + 0 + + """.trimIndent() + + @Test + fun applyExpiry_inserts_text_exp_between_lot_and_production_date() { + val out = OnPackLemonExpiryXml.applyExpiry(sample, "Expiry Date : 20/8/2027") + assertTrue(out.contains("TEXT_EXP")) + assertTrue(out.contains("Expiry Date : 20/8/2027")) + assertTrue(out.contains("TEXT_3")) + assertTrue(out.contains("0")) + assertTrue(out.contains("2450")) + assertTrue(out.contains("520")) + assertFalse(out.contains("783")) + } + + @Test + fun applyExpiry_replaces_existing_text_exp() { + val once = OnPackLemonExpiryXml.applyExpiry(sample, "Expiry Date : 20/8/2027") + val twice = OnPackLemonExpiryXml.applyExpiry(once, "Expiry Date : 1/1/2028") + assertEquals(1, Regex("TEXT_EXP").findAll(twice).count()) + assertTrue(twice.contains("Expiry Date : 1/1/2028")) + assertFalse(twice.contains("Expiry Date : 20/8/2027")) + } +} diff --git a/src/test/kotlin/com/ffii/fpsms/modules/jobOrder/service/OnPackTemplateFileServiceTest.kt b/src/test/kotlin/com/ffii/fpsms/modules/jobOrder/service/OnPackTemplateFileServiceTest.kt new file mode 100644 index 0000000..a4f7291 --- /dev/null +++ b/src/test/kotlin/com/ffii/fpsms/modules/jobOrder/service/OnPackTemplateFileServiceTest.kt @@ -0,0 +1,87 @@ +package com.ffii.fpsms.modules.jobOrder.service + +import org.junit.jupiter.api.Assertions.assertEquals +import org.junit.jupiter.api.Assertions.assertNull +import org.junit.jupiter.api.Assertions.assertTrue +import org.junit.jupiter.api.Test +import org.junit.jupiter.api.assertThrows + +class OnPackTemplateFileServiceTest { + + @Test + fun storedFileName_normalizes_image_by_machine() { + assertEquals( + "pp2211.image", + OnPackTemplateFileService.storedFileName("juice", "PP2211", "PP2211.image"), + ) + assertEquals( + "PP1175.image", + OnPackTemplateFileService.storedFileName("lemon", "pp1175", "foo.image"), + ) + assertEquals( + "product.bmp", + OnPackTemplateFileService.storedFileName("juice", "PP2211", "sub/product.bmp"), + ) + } + + @Test + fun sanitizeFileName_rejects_path_and_bad_ext() { + assertNull(OnPackTemplateFileService.sanitizeFileName("../secret.txt")) + assertNull(OnPackTemplateFileService.sanitizeFileName("x.exe")) + assertEquals("a.job", OnPackTemplateFileService.sanitizeFileName("a.job")) + } + + @Test + fun normalizeMachine_accepts_aliases() { + assertEquals("juice", OnPackTemplateFileService.normalizeMachine("汁水機")) + assertEquals("lemon", OnPackTemplateFileService.normalizeMachine("onpack2030_2")) + assertThrows { OnPackTemplateFileService.normalizeMachine("laser") } + } + + @Test + fun imageFileNameCandidates_cover_case() { + val juice = OnPackTemplateFileService.imageFileNameCandidates("juice", "PP2211") + assertTrue(juice.contains("pp2211.image")) + val lemon = OnPackTemplateFileService.imageFileNameCandidates("lemon", "pp1175") + assertTrue(lemon.contains("PP1175.image")) + } + + @Test + fun mergeSupported_marks_juice_vs_sources() { + val rows = OnPackTemplateFileService.mergeSupported( + registered = setOf("PP2211", "PP9999"), + inDatabase = setOf("PP9999"), + builtin = setOf("PP2211", "PP1080"), + ) + val byCode = rows.associateBy { it.itemCode } + assertEquals(setOf("PP1080", "PP2211", "PP9999"), byCode.keys) + assertTrue(byCode.getValue("PP2211").printable && byCode.getValue("PP2211").builtin) + assertTrue(byCode.getValue("PP9999").printable && byCode.getValue("PP9999").inDatabase) + assertTrue(byCode.getValue("PP1080").printable && !byCode.getValue("PP1080").registered) + } + + @Test + fun scanBuiltinImageCodes_finds_juice_and_lemon_templates() { + val juice = OnPackTemplateFileService.scanBuiltinImageCodes("juice") + val lemon = OnPackTemplateFileService.scanBuiltinImageCodes("lemon") + assertTrue(juice.contains("PP2211"), juice.toString()) + assertTrue(juice.size >= 20, "juice builtin count=${juice.size}") + assertTrue(!juice.contains("TESTPP1234"), juice.toString()) + assertTrue(!juice.contains("TESTPP1126"), juice.toString()) + assertTrue(!juice.contains("TT_PP1167"), juice.toString()) + assertTrue(!juice.contains("LO342987"), juice.toString()) + assertTrue(!juice.contains("LPP2290A"), juice.toString()) + assertTrue(lemon.contains("PP1175"), lemon.toString()) + assertTrue(lemon.size >= 5, "lemon builtin count=${lemon.size}") + } + + @Test + fun itemCodeFromImageFileName_skips_non_item_templates() { + assertEquals("PP2211", OnPackTemplateFileService.itemCodeFromImageFileName("pp2211.image")) + assertNull(OnPackTemplateFileService.itemCodeFromImageFileName("default.image")) + assertNull(OnPackTemplateFileService.itemCodeFromImageFileName("日期.image")) + assertTrue(OnPackTemplateFileService.isListedBuiltinCode("PP2211")) + assertTrue(!OnPackTemplateFileService.isListedBuiltinCode("TESTPP1234")) + assertTrue(!OnPackTemplateFileService.isListedBuiltinCode("TT_PP1167")) + } +} diff --git a/src/test/kotlin/com/ffii/fpsms/modules/jobOrder/service/OnPackTemplateLoadQaTest.kt b/src/test/kotlin/com/ffii/fpsms/modules/jobOrder/service/OnPackTemplateLoadQaTest.kt new file mode 100644 index 0000000..17f83dd --- /dev/null +++ b/src/test/kotlin/com/ffii/fpsms/modules/jobOrder/service/OnPackTemplateLoadQaTest.kt @@ -0,0 +1,206 @@ +package com.ffii.fpsms.modules.jobOrder.service + +import org.junit.jupiter.api.Assertions.assertTrue +import org.junit.jupiter.api.Test +import org.xml.sax.InputSource +import java.io.File +import java.io.StringReader +import javax.xml.parsers.DocumentBuilderFactory + +/** + * Load-safety QA for SmartDate X40: rewritten `.image` bytes must stay well-formed, + * keep the original encoding/BOM, unique field names/IDs, and stay on the canvas. + */ +class OnPackTemplateLoadQaTest { + + @Test + fun juice_templates_stay_loadable_after_expiry_rewrite() { + val failures = mutableListOf() + val expiryBmpWidth = 1800 + for (folder in listOf("onpack2030", "onpack2030_exp")) { + for (file in imageFiles(folder)) { + val original = file.readBytes() + val (xml, encodeBack) = OnPackImageTemplateCodec.decode(original) + val stem = file.nameWithoutExtension.lowercase() + val qrName = "${stem}qr.bmp" + val expName = "${stem}exp.bmp" + val withQr = xml.replace( + Regex("""(\s*LOGO_4\s*[\s\S]*?)([^<]+)()"""), + "$1$qrName$3", + ) + val rewritten = OnPackJuiceExpiryXml.applyExpiry(withQr, expName, expiryBmpWidth) + val outBytes = encodeBack(rewritten) + val (roundTrip, _) = OnPackImageTemplateCodec.decode(outBytes) + val label = "$folder/${file.name}" + checkCommon(failures, label, original, outBytes, xml, roundTrip) + val hadLogo4 = Regex("""\s*LOGO_4\s*""").containsMatchIn(xml) + val slot = OnPackJuiceExpiryXml.existingExpirySlotName(xml) + if (slot != null) { + if (slot == "LOGO_5" && Regex("""\s*LOGO_EXP\s*""").containsMatchIn(roundTrip)) { + failures.add("$label: must not inject LOGO_EXP when LOGO_5 exists") + } + val slotFile = Regex( + """(?s)\s*${Regex.escape(slot)}\s*[\s\S]*?([^<]+)""", + ).find(roundTrip)?.groupValues?.get(1) + if (slotFile != expName) { + failures.add("$label: $slot FileName=$slotFile expected $expName") + } + val yOrig = Regex("""(?s)\s*LOGO_4\s*[\s\S]*?\s*(\d+)\s*""").find(xml)?.groupValues?.get(1) + val yNew = Regex("""(?s)\s*LOGO_4\s*[\s\S]*?\s*(\d+)\s*""").find(roundTrip)?.groupValues?.get(1) + if (yOrig != yNew) { + failures.add("$label: designer template QR Y changed $yOrig -> $yNew") + } + continue + } + if (!hadLogo4) { + if (roundTrip.contains("LOGO_EXP")) { + failures.add("$label: template without LOGO_4 must not gain LOGO_EXP") + } + continue + } + if (!roundTrip.contains("LOGO_EXP")) { + failures.add("$label: missing LOGO_EXP") + } + if (!roundTrip.contains("$expName")) { + failures.add("$label: .image does not reference $expName") + } + if (!roundTrip.contains("$qrName")) { + failures.add("$label: LOGO_4 not pointing at $qrName") + } + if (Regex("""LOGO_EXP""").findAll(roundTrip).count() != 1) { + failures.add("$label: LOGO_EXP count != 1") + } + val twice = OnPackJuiceExpiryXml.applyExpiry(roundTrip, expName, expiryBmpWidth) + if (Regex("""LOGO_EXP""").findAll(twice).count() != 1) { + failures.add("$label: second apply duplicated LOGO_EXP") + } + val layout = OnPackJuiceExpiryXml.layoutFor(xml, expiryBmpWidth) + if (layout.expY + layout.expHeight > layout.qrY) { + failures.add("$label: expiry overlaps QR (expBottom=${layout.expY + layout.expHeight} qrY=${layout.qrY})") + } + if (layout.qrY + layout.qrHeight > layout.canvasHeight) { + failures.add("$label: QR below canvas (bottom=${layout.qrY + layout.qrHeight} canvas=${layout.canvasHeight})") + } + if (layout.x + layout.expWidth > layout.canvasWidth + 1) { + failures.add("$label: expiry wider than canvas") + } + } + } + if (failures.isNotEmpty()) { + throw AssertionError(failures.joinToString("\n")) + } + } + + @Test + fun lemon_templates_stay_loadable_after_expiry_rewrite() { + val failures = mutableListOf() + val label = "Expiry Date : 31/8/2026" + for (file in imageFiles("onpack2030_2")) { + val original = file.readBytes() + val (xml, encodeBack) = OnPackImageTemplateCodec.decode(original) + val rewritten = OnPackLemonExpiryXml.applyExpiry(xml, label) + val outBytes = encodeBack(rewritten) + val (roundTrip, _) = OnPackImageTemplateCodec.decode(outBytes) + checkCommon(failures, file.name, original, outBytes, xml, roundTrip) + if (!Regex("""\s*TEXT_3\s*""").containsMatchIn(xml)) { + continue + } + if (!roundTrip.contains("TEXT_EXP")) { + failures.add("${file.name}: missing TEXT_EXP") + } + if (!roundTrip.contains("$label")) { + failures.add("${file.name}: TEXT_EXP wording missing") + } + if (!roundTrip.contains("0")) { + failures.add("${file.name}: TEXT_3 production date OffsetDays changed") + } + if (Regex("""TEXT_EXP""").findAll(roundTrip).count() != 1) { + failures.add("${file.name}: TEXT_EXP count != 1") + } + val twice = OnPackLemonExpiryXml.applyExpiry(roundTrip, "Expiry Date : 1/1/2028") + if (Regex("""TEXT_EXP""").findAll(twice).count() != 1) { + failures.add("${file.name}: second apply duplicated TEXT_EXP") + } + } + if (failures.isNotEmpty()) { + throw AssertionError(failures.joinToString("\n")) + } + } + + @Test + fun juice_qa_covers_production_skus() { + val names = imageFiles("onpack2030").map { it.name.lowercase() }.toSet() + assertTrue(names.contains("pp2211.image"), "pp2211.image must be in juice QA set") + assertTrue(names.contains("pp1080.image"), "pp1080.image must be in juice QA set") + val expNames = imageFiles("onpack2030_exp").map { it.name.lowercase() }.toSet() + assertTrue(expNames.contains("pp1181.image"), "pp1181.image must be in expiry layout set") + } + + private fun checkCommon( + failures: MutableList, + name: String, + original: ByteArray, + outBytes: ByteArray, + originalXml: String, + outXml: String, + ) { + if (OnPackImageTemplateCodec.hasUtf16LeBom(original) && !OnPackImageTemplateCodec.hasUtf16LeBom(outBytes)) { + failures.add("$name: lost UTF-16 LE BOM (X40 will not load)") + } + if (OnPackImageTemplateCodec.hasUtf16LeBom(outBytes) && + outBytes.size >= 4 && + outBytes[2] == 0xFF.toByte() && + outBytes[3] == 0xFE.toByte() + ) { + failures.add("$name: double UTF-16 BOM") + } + if (countTag(outXml, "FieldList") != countTag(originalXml, "FieldList")) { + failures.add("$name: FieldList count changed") + } + if (!outXml.contains("") || !outXml.contains("([^<]+)""").findAll(outXml).map { it.groupValues[1].trim() }.toList()) + if (dupNames.isNotEmpty()) { + failures.add("$name: duplicate field names $dupNames") + } + val dupIds = duplicates(Regex("""(\d+)""").findAll(outXml).map { it.groupValues[1] }.toList()) + if (dupIds.isNotEmpty()) { + failures.add("$name: duplicate field IDs $dupIds") + } + } + + private fun imageFiles(folder: String): List { + val probe = javaClass.classLoader.getResource(folder) + ?: throw AssertionError("Missing classpath folder $folder") + val dir = File(probe.toURI()) + assertTrue(dir.isDirectory, "$folder should be a directory") + return dir.listFiles { f -> f.isFile && f.name.endsWith(".image") }!!.sortedBy { it.name } + } + + private fun wellFormedError(xml: String): String? { + return try { + val factory = DocumentBuilderFactory.newInstance() + factory.isNamespaceAware = false + factory.setFeature("http://apache.org/xml/features/disallow-doctype-decl", true) + val doc = factory.newDocumentBuilder().parse(InputSource(StringReader(xml))) + if (doc.documentElement.tagName != "Legend") { + "root is ${doc.documentElement.tagName}" + } else { + null + } + } catch (e: Exception) { + e.message ?: e.javaClass.simpleName + } + } + + private fun countTag(xml: String, tag: String): Int = + Regex(")").findAll(xml).count() + + private fun duplicates(values: List): List = + values.groupingBy { it }.eachCount().filter { it.value > 1 }.keys.sorted() +} diff --git a/src/test/kotlin/com/ffii/fpsms/modules/master/service/ItemDefaultShelfLifeServiceTest.kt b/src/test/kotlin/com/ffii/fpsms/modules/master/service/ItemDefaultShelfLifeServiceTest.kt new file mode 100644 index 0000000..8accc86 --- /dev/null +++ b/src/test/kotlin/com/ffii/fpsms/modules/master/service/ItemDefaultShelfLifeServiceTest.kt @@ -0,0 +1,45 @@ +package com.ffii.fpsms.modules.master.service + +import org.junit.jupiter.api.Assertions.assertEquals +import org.junit.jupiter.api.Test +import java.time.LocalDate + +class ItemDefaultShelfLifeServiceTest { + + @Test + fun expiryOn_adds_default_days_to_print_date() { + assertEquals( + LocalDate.of(2027, 8, 20), + ItemDefaultShelfLifeService.expiryOn(LocalDate.of(2026, 8, 20), 365), + ) + assertEquals( + LocalDate.of(2026, 11, 18), + ItemDefaultShelfLifeService.expiryOn(LocalDate.of(2026, 8, 20), 90), + ) + } + + @Test + fun effectiveDays_follows_useMinus18_flag() { + assertEquals(14, ItemDefaultShelfLifeService.effectiveDays(14, 60, false)) + assertEquals(60, ItemDefaultShelfLifeService.effectiveDays(14, 60, true)) + assertEquals(150, ItemDefaultShelfLifeService.effectiveDays(null, 150, true)) + assertEquals(null, ItemDefaultShelfLifeService.effectiveDays(null, null, true)) + assertEquals(null, ItemDefaultShelfLifeService.effectiveDays(null, 150, false)) + } + + @Test + fun formatPrintLabel_uses_yyyyMMdd() { + assertEquals( + "Expiry Date 20260831", + ItemDefaultShelfLifeService.formatPrintLabel(LocalDate.of(2026, 8, 31)), + ) + assertEquals( + "Expiry Date 20270101", + ItemDefaultShelfLifeService.formatPrintLabel(LocalDate.of(2027, 1, 1)), + ) + assertEquals( + "20260821", + ItemDefaultShelfLifeService.formatProductionDatePrintLabel(LocalDate.of(2026, 8, 21)), + ) + } +} diff --git a/src/test/kotlin/com/ffii/fpsms/modules/master/service/ItemM18IdRemapSupportTest.kt b/src/test/kotlin/com/ffii/fpsms/modules/master/service/ItemM18IdRemapSupportTest.kt new file mode 100644 index 0000000..304d0db --- /dev/null +++ b/src/test/kotlin/com/ffii/fpsms/modules/master/service/ItemM18IdRemapSupportTest.kt @@ -0,0 +1,94 @@ +package com.ffii.fpsms.modules.master.service + +import org.junit.jupiter.api.Assertions.assertFalse +import org.junit.jupiter.api.Assertions.assertTrue +import org.junit.jupiter.api.Test + +class ItemM18IdRemapSupportTest { + + @Test + fun canLink_whenM18SyncHasNewProIdAndSameCodeItem() { + assertTrue( + ItemM18IdRemapSupport.canLinkM18IdToDuplicateCode( + requestId = null, + requestM18Id = 20022L, + duplicatedItemId = 10L, + ownerOfNewM18IdItemId = null, + ) + ) + } + + @Test + fun canLink_whenExistingItemHasNoM18IdYet() { + assertTrue( + ItemM18IdRemapSupport.canLinkM18IdToDuplicateCode( + requestId = null, + requestM18Id = 20022L, + duplicatedItemId = 10L, + ownerOfNewM18IdItemId = null, + ) + ) + } + + @Test + fun cannotLink_whenAnotherItemAlreadyOwnsNewM18Id() { + assertFalse( + ItemM18IdRemapSupport.canLinkM18IdToDuplicateCode( + requestId = null, + requestM18Id = 20022L, + duplicatedItemId = 10L, + ownerOfNewM18IdItemId = 99L, + ) + ) + } + + @Test + fun cannotLink_whenRequestTargetsADifferentLocalItem() { + assertFalse( + ItemM18IdRemapSupport.canLinkM18IdToDuplicateCode( + requestId = 11L, + requestM18Id = 20022L, + duplicatedItemId = 10L, + ownerOfNewM18IdItemId = null, + ) + ) + } + + @Test + fun cannotLink_whenNoM18IdOnRequest() { + assertFalse( + ItemM18IdRemapSupport.canLinkM18IdToDuplicateCode( + requestId = null, + requestM18Id = null, + duplicatedItemId = 10L, + ownerOfNewM18IdItemId = null, + ) + ) + } + + @Test + fun isM18IdLinkOnly_trueAfterMissThenLink() { + assertTrue( + ItemM18IdRemapSupport.isM18IdLinkOnly( + existingByM18IdWasMissing = true, + message = ItemM18IdRemapSupport.LINKED_MESSAGE, + ) + ) + } + + @Test + fun isM18IdLinkOnly_falseForNormalProductUpdate() { + assertFalse( + ItemM18IdRemapSupport.isM18IdLinkOnly( + existingByM18IdWasMissing = false, + message = ItemM18IdRemapSupport.LINKED_MESSAGE, + ) + ) + assertFalse( + ItemM18IdRemapSupport.isM18IdLinkOnly( + existingByM18IdWasMissing = true, + message = "M18 Item does not have any updates", + ) + ) + } +} diff --git a/src/test/kotlin/com/ffii/fpsms/py/PyJobOrderListMapperTest.kt b/src/test/kotlin/com/ffii/fpsms/py/PyJobOrderListMapperTest.kt index e90561f..70b7485 100644 --- a/src/test/kotlin/com/ffii/fpsms/py/PyJobOrderListMapperTest.kt +++ b/src/test/kotlin/com/ffii/fpsms/py/PyJobOrderListMapperTest.kt @@ -4,6 +4,8 @@ import org.junit.jupiter.api.Assertions.assertEquals import org.junit.jupiter.api.Assertions.assertFalse import org.junit.jupiter.api.Assertions.assertTrue import org.junit.jupiter.api.Test +import java.time.LocalDate +import com.ffii.fpsms.modules.master.service.ItemPrintShelfLife class PyJobOrderListMapperTest { @@ -64,14 +66,38 @@ class PyJobOrderListMapperTest { } @Test - fun buildDisplayItemNameForLaser_omits_appended_uom_for_PP1175() { - assertEquals( - "鮮檸檬汁(P+4)", - PyJobOrderListMapper.buildDisplayItemNameForLaser("鮮檸檬汁(P+4)", stockUom, "PP1175"), - ) - assertEquals( - "咖哩汁(1包X2磅)", - PyJobOrderListMapper.buildDisplayItemNameForLaser("咖哩汁(1包X2磅)", stockUom, "PP1080"), - ) - } + fun buildDisplayItemNameForLaser_omits_appended_uom_for_PP1175() { + assertEquals( + "鮮檸檬汁(P+4)", + PyJobOrderListMapper.buildDisplayItemNameForLaser("鮮檸檬汁(P+4)", stockUom, "PP1175"), + ) + assertEquals( + "咖哩汁(1包X2磅)", + PyJobOrderListMapper.buildDisplayItemNameForLaser("咖哩汁(1包X2磅)", stockUom, "PP1080"), + ) + } + + @Test + fun shelfLifeForItem_computes_expiry_from_print_date() { + val (days, useMinus18, expiry) = PyJobOrderListMapper.shelfLifeForItem( + "f0025", + mapOf("F0025" to ItemPrintShelfLife(365, false)), + LocalDate.of(2026, 8, 20), + ) + assertEquals(365, days) + assertEquals(false, useMinus18) + assertEquals(LocalDate.of(2027, 8, 20), expiry) + } + + @Test + fun shelfLifeForItem_is_null_when_item_missing() { + val (days, useMinus18, expiry) = PyJobOrderListMapper.shelfLifeForItem( + "PP1175", + emptyMap(), + LocalDate.of(2026, 8, 20), + ) + assertEquals(null, days) + assertEquals(null, useMinus18) + assertEquals(null, expiry) + } }