From 42ac8cf6f8a0fe20cf427ef7874cd20508b16770 Mon Sep 17 00:00:00 2001 From: Fai Luk Date: Fri, 21 Aug 2026 21:43:58 +0800 Subject: [PATCH] added for expiry date for bag printing and handle the changed id for item m18Id --- build.gradle | 4 + docs/deploy/20260819_po-m18id-remap.md | 85 + python/Bag4.py | 3074 +++++++++++++++++ python/Bag4.spec | 38 + python/__pycache__/Bag4.cpython-314.pyc | Bin 0 -> 164826 bytes python/installAndExe.txt | 28 +- ...erate_item_default_shelf_life_liquibase.py | 186 + .../fpsms/m18/service/M18MasterDataService.kt | 44 +- .../jobOrder/entity/OnPackTemplateFile.kt | 41 + .../entity/OnPackTemplateFileRepository.kt | 29 + .../service/LaserBag2AutoSendService.kt | 1 + .../service/OnPackImageTemplateCodec.kt | 45 + .../jobOrder/service/OnPackJuiceExpiryXml.kt | 152 + .../jobOrder/service/OnPackLemonExpiryXml.kt | 61 + .../service/OnPackTemplateFileService.kt | 364 ++ .../modules/jobOrder/service/OnPackXml.kt | 13 + .../service/PlasticBagPrinterService.kt | 368 +- .../jobOrder/web/OnPackTemplateController.kt | 55 + .../web/PlasticBagPrinterController.kt | 92 +- .../web/model/LaserBag2SendRequest.kt | 8 +- .../web/model/OnPackTemplateModels.kt | 29 + .../jobOrder/web/model/PlasticPrintRequest.kt | 2 + .../master/entity/ItemDefaultShelfLife.kt | 43 + .../entity/ItemDefaultShelfLifeRepository.kt | 12 + .../service/ItemDefaultShelfLifeService.kt | 83 + .../master/service/ItemM18IdRemapSupport.kt | 33 + .../modules/master/service/ItemsService.kt | 73 +- .../java/com/ffii/fpsms/py/PyController.kt | 18 +- .../com/ffii/fpsms/py/PyJobOrderListItem.kt | 7 + .../com/ffii/fpsms/py/PyJobOrderListMapper.kt | 26 + .../01_create_item_default_shelf_life.sql | 21 + .../02_seed_item_default_shelf_life.sql | 17 + ..._item_default_shelf_life_from_joexpiry.sql | 181 + ...4_item_default_shelf_life_minus18_flag.sql | 8 + ...d_item_default_shelf_life_minus18_flag.sql | 220 ++ .../01_create_onpack_template_file.sql | 21 + .../resources/onpack2030_exp/pp1181.image | Bin 0 -> 2996 bytes .../jobOrder/service/LaserBag2PayloadTest.kt | 38 + .../service/OnPackJuiceExpiryXmlTest.kt | 109 + .../service/OnPackLemonExpiryXmlTest.kt | 40 + .../service/OnPackTemplateFileServiceTest.kt | 87 + .../service/OnPackTemplateLoadQaTest.kt | 206 ++ .../ItemDefaultShelfLifeServiceTest.kt | 45 + .../service/ItemM18IdRemapSupportTest.kt | 94 + .../ffii/fpsms/py/PyJobOrderListMapperTest.kt | 46 +- 45 files changed, 6027 insertions(+), 120 deletions(-) create mode 100644 docs/deploy/20260819_po-m18id-remap.md create mode 100644 python/Bag4.py create mode 100644 python/Bag4.spec create mode 100644 python/__pycache__/Bag4.cpython-314.pyc create mode 100644 scripts/generate_item_default_shelf_life_liquibase.py create mode 100644 src/main/java/com/ffii/fpsms/modules/jobOrder/entity/OnPackTemplateFile.kt create mode 100644 src/main/java/com/ffii/fpsms/modules/jobOrder/entity/OnPackTemplateFileRepository.kt create mode 100644 src/main/java/com/ffii/fpsms/modules/jobOrder/service/OnPackImageTemplateCodec.kt create mode 100644 src/main/java/com/ffii/fpsms/modules/jobOrder/service/OnPackJuiceExpiryXml.kt create mode 100644 src/main/java/com/ffii/fpsms/modules/jobOrder/service/OnPackLemonExpiryXml.kt create mode 100644 src/main/java/com/ffii/fpsms/modules/jobOrder/service/OnPackTemplateFileService.kt create mode 100644 src/main/java/com/ffii/fpsms/modules/jobOrder/service/OnPackXml.kt create mode 100644 src/main/java/com/ffii/fpsms/modules/jobOrder/web/OnPackTemplateController.kt create mode 100644 src/main/java/com/ffii/fpsms/modules/jobOrder/web/model/OnPackTemplateModels.kt create mode 100644 src/main/java/com/ffii/fpsms/modules/master/entity/ItemDefaultShelfLife.kt create mode 100644 src/main/java/com/ffii/fpsms/modules/master/entity/ItemDefaultShelfLifeRepository.kt create mode 100644 src/main/java/com/ffii/fpsms/modules/master/service/ItemDefaultShelfLifeService.kt create mode 100644 src/main/java/com/ffii/fpsms/modules/master/service/ItemM18IdRemapSupport.kt create mode 100644 src/main/resources/db/changelog/changes/20260820_item_default_shelf_life/01_create_item_default_shelf_life.sql create mode 100644 src/main/resources/db/changelog/changes/20260820_item_default_shelf_life/02_seed_item_default_shelf_life.sql create mode 100644 src/main/resources/db/changelog/changes/20260820_item_default_shelf_life/03_seed_item_default_shelf_life_from_joexpiry.sql create mode 100644 src/main/resources/db/changelog/changes/20260820_item_default_shelf_life/04_item_default_shelf_life_minus18_flag.sql create mode 100644 src/main/resources/db/changelog/changes/20260820_item_default_shelf_life/05_seed_item_default_shelf_life_minus18_flag.sql create mode 100644 src/main/resources/db/changelog/changes/20260820_onpack_template_file/01_create_onpack_template_file.sql create mode 100644 src/main/resources/onpack2030_exp/pp1181.image create mode 100644 src/test/kotlin/com/ffii/fpsms/modules/jobOrder/service/LaserBag2PayloadTest.kt create mode 100644 src/test/kotlin/com/ffii/fpsms/modules/jobOrder/service/OnPackJuiceExpiryXmlTest.kt create mode 100644 src/test/kotlin/com/ffii/fpsms/modules/jobOrder/service/OnPackLemonExpiryXmlTest.kt create mode 100644 src/test/kotlin/com/ffii/fpsms/modules/jobOrder/service/OnPackTemplateFileServiceTest.kt create mode 100644 src/test/kotlin/com/ffii/fpsms/modules/jobOrder/service/OnPackTemplateLoadQaTest.kt create mode 100644 src/test/kotlin/com/ffii/fpsms/modules/master/service/ItemDefaultShelfLifeServiceTest.kt create mode 100644 src/test/kotlin/com/ffii/fpsms/modules/master/service/ItemM18IdRemapSupportTest.kt 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 0000000000000000000000000000000000000000..267a3928b8d1c330d7048de10a8cd6bf645bf3aa GIT binary patch literal 164826 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

5^^xtbY4iTqZh4xI4r)yVqppwJPR7N>3Ol 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 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 0000000000000000000000000000000000000000..b4dcf4dd0eb374844ba358b8080b4cfb6892ce6c GIT binary patch literal 2996 zcmdUxeQy#$5XR@DVE+e-!1bD(msqK_vDQYT)fm&@akZo%cwn_(Uj040UXQmzF{#09 z4sPdt=9$^OUq2&zU}e^+W!AMZE443HvQKu+?-Lu?k-fCnytm+tkogQhMY?2fEwG9` z79@&;1S|3^QD%h(*DcK1W@iXVYM+}*h zJ+%uLW9p>#kV@~iWGdj&_) z9izKX=Bmw=i|7NjXxTe!kZ(fF36?ulYZpptb@B~thn4WwfS&M_;U*TMQRX*Vo)=vEGMUf~z`K9SV7zdGz9VbP^ZokP3InN;rDG7wj{ZDw*Ol#%G&K zjo8WRJU;?;Wi8Cc$mSMfJnd7JGqRE9ZBh{()y2~o%wlq_BkwgP>5)CdgL>)$$(Xv; zZsevp5xu2ode}VTZRF(DSp&3%;AB_j)V!xup}dx{>*xa_SNsJv0`nFz1di_CBQi~~ zE)2%h;~IVh`Z3rw|Ky`xWbdbF+C_BH0IkW|`3lU_70Dt`Mb1TM$F@fXkC~0jyf zIaYsz;2Ai`Ly=>SqK|JCioV-CDg};{)XhgyozVg3bug?`w{1Fj6^81T9nijS6upOe z6hr5E6^fd~Sqzn5PThPAbyMl)T?fO4!_Zf5*&O~W3>&vh;_jU=Ty&o5j{Scy{K|>C z9)2N{xo96f*Y&VS+Pwk*D$WBU!ybmx2k literal 0 HcmV?d00001 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) + } }