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..6a284d9 --- /dev/null +++ b/python/Bag4.py @@ -0,0 +1,3148 @@ +#!/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, always printed as `Expiry Date 20260826`. + +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 + + +EXPIRY_PRINT_PREFIX = "Expiry Date " +EXPIRY_LABEL_PREFIX = "Expiry " + + +def format_expiry_print_label(d: date) -> str: + """DataFlex / laser wording: `Expiry Date 20260826`.""" + return f"{EXPIRY_PRINT_PREFIX}{d.strftime('%Y%m%d')}" + + +def format_expiry_label_compact(d: date) -> str: + """標簽機 wording (narrow): `Expiry 20260826`.""" + return f"{EXPIRY_LABEL_PREFIX}{d.strftime('%Y%m%d')}" + + +def _parse_api_expiry_date(raw) -> Optional[date]: + """Accept ISO `yyyy-MM-dd`, compact `yyyyMMdd`, `Expiry Date yyyyMMdd`, or `[yyyy,M,d]`.""" + if raw is None or raw == "": + return None + if isinstance(raw, datetime): + return raw.date() + if isinstance(raw, date): + return raw + if isinstance(raw, (list, tuple)) and len(raw) >= 3: + try: + return date(int(raw[0]), int(raw[1]), int(raw[2])) + except (TypeError, ValueError): + return None + s = str(raw).strip().replace(";", ",") + if not s: + return None + if s.lower().startswith(EXPIRY_PRINT_PREFIX.lower()): + s = s[len(EXPIRY_PRINT_PREFIX):].strip() + elif s.lower().startswith(EXPIRY_LABEL_PREFIX.lower()): + s = s[len(EXPIRY_LABEL_PREFIX):].strip() + digits = "".join(ch for ch in s if ch.isdigit()) + if len(digits) >= 8: + try: + return date(int(digits[0:4]), int(digits[4:6]), int(digits[6:8])) + except ValueError: + return None + return None + + +def standardize_expiry_print_label(raw) -> str: + """Normalize any expiry input to `Expiry Date 20260826`, or empty if unknown.""" + parsed = _parse_api_expiry_date(raw) + return format_expiry_print_label(parsed) if parsed else "" + + +def standardize_expiry_label_compact(raw) -> str: + """Normalize any expiry input to `Expiry 20260826` for 標簽機, or empty if unknown.""" + parsed = _parse_api_expiry_date(raw) + return format_expiry_label_compact(parsed) if parsed else "" + + +def job_expiry_zpl_text(jo: dict) -> Optional[str]: + """Expiry as `Expiry Date yyyyMMdd` from 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)) + label = standardize_expiry_print_label(jo.get("expiryDate")) + return label or None + + +def job_expiry_laser_param(jo: dict) -> str: + """Same `Expiry Date yyyyMMdd` as DataFlex, 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: expiry, then Lot at the next X, then item code. + Text is ^A@R (90° CW); the next line under expiry 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 = standardize_expiry_print_label(expiry_text) + exp_esc = _zpl_escape(exp_raw) if exp_raw else "" + # QR payload: prefer JSON {"itemId":..., "stockInLineId":...} when both present; else fall back to lot/batch text + if item_id is not None and stock_in_line_id is not None: + 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{exp_esc}^FS +^FO42,200 +^A@R,40,40,{font_regular}^FD{label_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, + expiry_text: Optional[str] = None, + font: str = "MingLiUHKSCS", +) -> str: + """ + ZPL for 標簽機. Row 1: item name. Row 2: QR left | item code + lot + expiry right. + Expiry is always `Expiry yyyyMMdd` when present (narrow label). + QR contains {"itemId": xxx, "stockInLineId": xxx} when both present; else batch_no. + Unicode (^CI28); font set for Big-5 (e.g. MingLiUHKSCS). + """ + 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) + exp_esc = _zpl_escape(standardize_expiry_label_compact(expiry_text)) + if item_id is not None and stock_in_line_id is not None: + qr_data = _zpl_escape(json.dumps({"itemId": item_id, "stockInLineId": stock_in_line_id})) + else: + qr_data = f"QA,{batch_no}" + exp_zpl = f""" +^FO150,194 +^A@N,25,25,{font}^FD{exp_esc}^FS""" if exp_esc else "" + 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,34,34,{font}^FD{code}^FS +^FO150,156 +^A@N,28,28,{font}^FD{label_line2_esc}^FS{exp_zpl} +^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 +# Item code / lot / expiry ~70% of original size so all three fit under the name. +LABEL_FONT_CODE_SIZE = 34 +LABEL_FONT_BATCH_SIZE = 24 +LABEL_FONT_EXPIRY_SIZE = 24 +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, + expiry_text: Optional[str] = None, +) -> "Image.Image": + """ + Render 標簽機 label as a PIL Image (white bg, black text + QR). + Lot line is followed by `Expiry yyyyMMdd` when [expiry_text] is set. + Use this image for printing so Chinese displays correctly; words are drawn bigger. + Requires Pillow and qrcode. Raises RuntimeError if not available. + """ + 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) + font_expiry = _get_chinese_font(LABEL_FONT_EXPIRY_SIZE) + x_right = LABEL_PADDING + LABEL_QR_SIZE + LABEL_PADDING + y_line = LABEL_PADDING + # Line 1: item name (wrap within remaining width) + 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") + y_line += LABEL_FONT_BATCH_SIZE + 6 + exp_str = standardize_expiry_label_compact(expiry_text) + if exp_str: + if font_expiry: + draw.text((x_right, y_line), exp_str, font=font_expiry, fill="black") + else: + draw.text((x_right, y_line), exp_str, fill="black") + return img + + +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: one payload. Sending 3 queued 2–3 old bags after a job change. +LASER_ROW_SEND_COUNT = 1 +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). The EZCAD job must bind it. + Returns (success, message). + """ + code_str = (item_code or "").strip().replace(";", ",") + name_str = (item_name or "").strip().replace(";", ",") + exp_str = standardize_expiry_print_label(expiry_text).replace(";", ",") + + if item_id is not None and stock_in_line_id is not None: + # Use compact JSON so device-side parser doesn't get spaces. + 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() + # Cancel labels already sitting in the DataFlex buffer so the next job + # does not print 2–3 leftover bags of the previous job. + ip, port = session.printer_ip, session.printer_port + + def _cancel_buffer() -> None: + try: + send_dataflex_preprint_reset(ip, port, force=True) + except Exception: + pass + + threading.Thread(target=_cancel_buffer, daemon=True).start() + 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, + expiry_text=job_expiry_zpl_text(j), + ) + zpl_img = _image_to_zpl_gfa(label_img) + run_label_print_batch_thread( + root=root, + label_lock=label_lock, + label_busy_ref=label_busy_ref, + com=com, + zpl_img=zpl_img, + n=n, + jo_id=j.get("id"), + base_url=base_url_ref[0], + set_status_message=set_status_message, + on_recorded=lambda: load_job_orders( + from_user_date_change=False + ), + ) + except Exception as err: + messagebox.showerror("標簽機", f"列印失敗:{err}") + elif printer_var.get() == "激光機": + ip = (settings.get("laser_ip") or "").strip() + port_str = (settings.get("laser_port") or "45678").strip() + try: + port = int(port_str) + except ValueError: + port = 45678 + if not ip: + set_status_message("請在設定中填寫激光機的 IP。", is_error=True) + else: + item_id = j.get("itemId") + stock_in_line_id = j.get("stockInLineId") + item_code_val = j.get("itemCode") or "" + item_name_val = j.get("itemName") or "" + run_laser_row_send_thread( + root=root, + laser_conn_ref=laser_conn_ref, + laser_busy_ref=laser_send_busy_ref, + ip=ip, + port=port, + item_id=item_id, + stock_in_line_id=stock_in_line_id, + item_code=item_code_val, + item_name=item_name_val, + set_status_message=set_status_message, + base_url=base_url_ref[0], + job_order_id=j.get("id"), + on_recorded=lambda: load_job_orders(from_user_date_change=False), + expiry_text=job_expiry_laser_param(j), + ) + + for w in ( + row, + left, + batch_lbl, + meta_lbl, + code_col, + code_lbl, + name_col, + name_lbl, + ): + w.bind("", _on_click) + w.bind("", _on_mousewheel) + w.bind("", _on_mousewheel) + w.bind("", _on_mousewheel) + if preserve_selection and selected_id is not None and jo.get("id") == selected_id: + found_row = row + if found_row is not None: + set_row_highlight(found_row, True) + selected_row_holder[0] = found_row + + def refresh_visible_list() -> None: + """Re-apply search filter to last fetched rows without hitting the API.""" + raw = last_data_ref[0] + if raw is None: + return + ps = last_plan_start_ref[0] + needle = search_var.get().strip() + shown = _filter_job_orders_by_search(raw, needle) if needle else raw + for w in inner.winfo_children(): + w.destroy() + _build_list_from_data(shown, ps, preserve_selection=True) + + search_entry.bind("", lambda e: refresh_visible_list()) + + def load_job_orders(from_user_date_change: bool = False) -> None: + if after_id_ref[0] is not None: + root.after_cancel(after_id_ref[0]) + after_id_ref[0] = None + date_str = date_var.get().strip() + try: + plan_start = date.fromisoformat(date_str) + except ValueError: + messagebox.showerror("日期錯誤", f"請使用 yyyy-MM-dd 格式。目前:{date_str}") + return + if from_user_date_change: + selected_row_holder[0] = None + selected_jo_id_ref[0] = None + try: + data = fetch_job_orders(base_url_ref[0], plan_start) + except requests.RequestException: + set_status_error() + after_id_ref[0] = root.after(RETRY_MS, lambda: load_job_orders(from_user_date_change=False)) + return + set_status_ok() + old_data = last_data_ref[0] + last_data_ref[0] = data + last_plan_start_ref[0] = plan_start + data_changed = not _data_equal(old_data, data) + if data_changed or from_user_date_change: + printing_busy = ( + dataflex_busy_ref[0] + or label_busy_ref[0] + or laser_send_busy_ref[0] + ) + # Do not destroy/rebuild all rows while printing — that removes click bindings and + # can hide DataFlex「停止列印」. Retry until idle (one deferred pass at a time). + if printing_busy and not from_user_date_change: + after_id_ref[0] = root.after( + JOB_LIST_DEFER_WHILE_PRINTING_MS, + lambda: load_job_orders(from_user_date_change=False), + ) + else: + # Rebuild list: clear and rebuild from current data (last_data_ref already updated) + for w in inner.winfo_children(): + w.destroy() + preserve = not from_user_date_change + needle = search_var.get().strip() + shown = _filter_job_orders_by_search(data, needle) if needle else data + _build_list_from_data(shown, plan_start, preserve_selection=preserve) + if from_user_date_change: + canvas.yview_moveto(0) + if JOB_LIST_AUTO_REFRESH_MS > 0: + after_id_ref[0] = root.after( + JOB_LIST_AUTO_REFRESH_MS, + lambda: load_job_orders(from_user_date_change=False), + ) + + # Load default (today) on start; then start printer connection check + root.after(100, lambda: load_job_orders(from_user_date_change=True)) + root.after(300, check_printer) + + root.mainloop() + + +def _startup_error_log_path() -> str: + if getattr(sys, "frozen", False): + base = os.path.dirname(sys.executable) + else: + base = os.path.dirname(os.path.abspath(__file__)) + return os.path.join(base, "bag4_startup_error.log") + + +if __name__ == "__main__": + try: + main() + except SystemExit: + raise + except Exception: + import traceback + + log_path = _startup_error_log_path() + try: + with open(log_path, "w", encoding="utf-8") as f: + traceback.print_exc(file=f) + except OSError: + log_path = "(could not write log file)" + msg = f"Bag4 啟動失敗,詳情已寫入:\n{log_path}" + print(msg, file=sys.stderr) + traceback.print_exc() + try: + _err_root = tk.Tk() + _err_root.withdraw() + messagebox.showerror("Bag4", msg) + _err_root.destroy() + except Exception: + pass + if getattr(sys, "frozen", False): + try: + input("按 Enter 關閉…") + except (EOFError, KeyboardInterrupt): + pass + sys.exit(1) \ No newline at end of file diff --git a/python/Bag4.spec b/python/Bag4.spec new file mode 100644 index 0000000..b531464 --- /dev/null +++ b/python/Bag4.spec @@ -0,0 +1,38 @@ +# -*- mode: python ; coding: utf-8 -*- + + +a = Analysis( + ['Bag4.py'], + pathex=[], + binaries=[], + datas=[], + hiddenimports=[], + hookspath=[], + hooksconfig={}, + runtime_hooks=[], + excludes=[], + noarchive=False, + optimize=0, +) +pyz = PYZ(a.pure) + +exe = EXE( + pyz, + a.scripts, + a.binaries, + a.datas, + [], + name='Bag4', + debug=False, + bootloader_ignore_signals=False, + strip=False, + upx=False, + upx_exclude=[], + runtime_tmpdir=None, + console=False, + disable_windowed_traceback=False, + argv_emulation=False, + target_arch=None, + codesign_identity=None, + entitlements_file=None, +) diff --git a/python/__pycache__/Bag4.cpython-314.pyc b/python/__pycache__/Bag4.cpython-314.pyc new file mode 100644 index 0000000..267a392 Binary files /dev/null and b/python/__pycache__/Bag4.cpython-314.pyc differ diff --git a/python/installAndExe.txt b/python/installAndExe.txt index fb00b31..b05456d 100644 --- a/python/installAndExe.txt +++ b/python/installAndExe.txt @@ -1,24 +1,30 @@ -# Bag3 Windows exe build (run all commands in this python/ folder) +# Bag3 / Bag4 Windows exe build (run all commands in this python/ folder) py -m pip install --upgrade pyinstaller py -m pip install --upgrade pywin32 -py -m pip install --upgrade Pillow "qrcode[pil]" requests +py -m pip install --upgrade Pillow "qrcode[pil]" requests pyserial -py -m PyInstaller --noconfirm --clean Bag3.spec +# --- Bag4 (DataFlex with expiry) --- +py -m PyInstaller --noconfirm --clean Bag4.spec +# Output: dist\Bag4.exe (one-file). Copy Bag4.exe to the client PC. +# Settings are created next to the exe: bag4_settings.json +# Do not overwrite Bag3.exe; Bag3 and Bag4 are separate. -# Output: dist\Bag3\Bag3.exe plus dist\Bag3\_internal\... -# Copy the ENTIRE dist\Bag3\ folder to the client PC (not only Bag3.exe). +# --- Bag3 (DataFlex without expiry) --- +py -m PyInstaller --noconfirm --clean Bag3.spec +# Output: dist\Bag3.exe # --- If the client exe flashes and closes --- -1) On the client PC, open cmd in the Bag3 folder and run: - Bag3.exe - You should see the error in the console, or open bag3_startup_error.log next to Bag3.exe. +1) On the client PC, open cmd in the folder that contains the exe and run: + Bag4.exe + You should see the error in the console, or open bag4_startup_error.log next to Bag4.exe. + (Bag3: bag3_startup_error.log) 2) Compare BUILD machines (both should match): py --version py -m PyInstaller --version - py -m pip show pyinstaller pywin32 Pillow qrcode requests + py -m pip show pyinstaller pywin32 Pillow qrcode requests pyserial A broken build is often caused by: - Different Python major version (e.g. 3.13 vs 3.11) @@ -28,8 +34,8 @@ py -m PyInstaller --noconfirm --clean Bag3.spec 3) Rebuild on the machine that works, or reinstall Python from python.org (64-bit) and reinstall deps above, then rebuild. -4) Bag3.spec disables UPX (upx=False) for stability; do not re-enable unless you test on the client. +4) Spec files disable UPX (upx=False) for stability; do not re-enable unless you test on the client. 5) Client needs 64-bit Windows and Microsoft VC++ Redistributable (same as your Python installer). -6) Antivirus may quarantine files under _internal\ — whitelist the Bag3 folder if the log mentions missing DLL. +6) Antivirus may quarantine the exe — whitelist the folder if the log mentions missing DLL. diff --git a/scripts/generate_item_default_shelf_life_liquibase.py b/scripts/generate_item_default_shelf_life_liquibase.py new file mode 100644 index 0000000..66ab3fe --- /dev/null +++ b/scripts/generate_item_default_shelf_life_liquibase.py @@ -0,0 +1,186 @@ +#!/usr/bin/env python3 +"""Generate Liquibase seed SQL from 工埸產品保質期 Excel (joExpiry.xlsx). + +Stores both chilled defaultDays and minus18Days. +useMinus18 = 1 only when the row has no non-18 shelf life (frozen-only). +Flip useMinus18 in DB later for items that should print -18 days. + +Example: + py scripts/generate_item_default_shelf_life_liquibase.py + py scripts/generate_item_default_shelf_life_liquibase.py "C:\\Users\\Administrator\\Downloads\\joExpiry.xlsx" +""" +from __future__ import annotations + +import argparse +import datetime as dt +from pathlib import Path + +import openpyxl + +PREFERRED_SHEET = "XXXXXXXX" +OUT_SQL = Path( + "src/main/resources/db/changelog/changes/20260820_item_default_shelf_life/" + "05_seed_item_default_shelf_life_minus18_flag.sql" +) + + +def sql_str(value: str | None) -> str: + if value is None: + return "NULL" + return "'" + value.replace("\\", "\\\\").replace("'", "''") + "'" + + +def sql_int(value: int | None) -> str: + return "NULL" if value is None else str(value) + + +def norm_temp(raw) -> str | None: + if raw is None: + return None + s = str(raw).strip().replace("℃", "").replace("°C", "").replace(" ", "") + if not s or s.lower() in ("n/a", "none", "null", "-", "nan"): + return None + return s + + +def is_minus18(temp: str | None) -> bool: + return temp in ("-18", "-18.0") + + +def to_days(raw) -> int | None: + if raw is None or raw == "": + return None + if isinstance(raw, str) and raw.strip().lower() in ("n/a", "-", "none", "null"): + return None + try: + n = int(round(float(raw))) + except (TypeError, ValueError): + return None + return n if n > 0 else None + + +def pick_days(t1, d1, t2, d2) -> tuple[int | None, str | None, int | None]: + """Return (chilledDays, chilledTemp, minus18Days).""" + chilled_days = None + chilled_temp = None + minus18_days = None + for temp, days in ((norm_temp(t1), to_days(d1)), (norm_temp(t2), to_days(d2))): + if not temp or not days: + continue + if is_minus18(temp): + if minus18_days is None: + minus18_days = days + elif chilled_days is None: + chilled_days = days + chilled_temp = temp + return chilled_days, chilled_temp, minus18_days + + +def load_rows(xlsx: Path) -> tuple[str, list[dict]]: + wb = openpyxl.load_workbook(xlsx, data_only=True) + sheet_name = PREFERRED_SHEET if PREFERRED_SHEET in wb.sheetnames else wb.sheetnames[0] + ws = wb[sheet_name] + kept: list[dict] = [] + seen: set[str] = set() + for row in ws.iter_rows(min_row=4, values_only=True): + code_raw = row[3] + if not code_raw: + continue + code = str(code_raw).strip().upper() + if not code or code in seen: + continue + chilled, storage, minus18 = pick_days(row[5], row[6], row[7], row[8]) + if chilled is None and minus18 is None: + continue + seen.add(code) + use_minus18 = 1 if chilled is None else 0 + name = str(row[4]).strip() if row[4] else "" + cat = str(row[2]).strip() if row[2] else "" + remark = f"{cat} {name}".strip()[:255] + kept.append( + { + "itemCode": code, + "defaultDays": chilled, + "minus18Days": minus18, + "useMinus18": use_minus18, + "storageC": "-18" if use_minus18 else storage, + "remarks": remark, + } + ) + return sheet_name, kept + + +def render_sql(sheet_name: str, kept: list[dict], source_name: str) -> str: + today = dt.date.today().isoformat() + n_flag = sum(1 for r in kept if r["useMinus18"] == 1) + lines = [ + "--liquibase formatted sql", + "", + "--changeset fpsms:seed_item_default_shelf_life_minus18_flag", + f"--comment: Upsert joExpiry.xlsx sheet {sheet_name}: defaultDays + minus18Days + useMinus18 flag", + f"-- generated {today}; rows={len(kept)} useMinus18=1 (frozen-only default)={n_flag}", + "INSERT INTO `item_default_shelf_life`", + "(`created`, `createdBy`, `version`, `modified`, `modifiedBy`, `deleted`,", + " `itemCode`, `defaultDays`, `minus18Days`, `useMinus18`, `openedDays`, `storageC`, `remarks`)", + "VALUES", + ] + value_rows = [] + for r in kept: + value_rows.append( + "(" + + ", ".join( + [ + "NOW()", + "'system'", + "0", + "NOW()", + "'system'", + "0", + sql_str(r["itemCode"]), + sql_int(r["defaultDays"]), + sql_int(r["minus18Days"]), + str(r["useMinus18"]), + "NULL", + sql_str(r["storageC"]), + sql_str(r["remarks"]), + ] + ) + + ")" + ) + lines.append(",\n".join(value_rows)) + lines.append("ON DUPLICATE KEY UPDATE") + lines.append(" `defaultDays` = VALUES(`defaultDays`),") + lines.append(" `minus18Days` = VALUES(`minus18Days`),") + lines.append(" `useMinus18` = VALUES(`useMinus18`),") + lines.append(" `openedDays` = VALUES(`openedDays`),") + lines.append(" `storageC` = VALUES(`storageC`),") + lines.append(" `remarks` = VALUES(`remarks`),") + lines.append(" `modified` = NOW(),") + lines.append(" `modifiedBy` = 'system',") + lines.append(" `deleted` = 0;") + lines.append("") + return "\n".join(lines) + + +def main() -> None: + parser = argparse.ArgumentParser() + parser.add_argument( + "xlsx", + nargs="?", + default=str(Path.home() / "Downloads" / "joExpiry.xlsx"), + ) + args = parser.parse_args() + xlsx = Path(args.xlsx) + if not xlsx.is_file(): + raise SystemExit(f"Excel not found: {xlsx}") + sheet_name, kept = load_rows(xlsx) + sql = render_sql(sheet_name, kept, xlsx.name) + OUT_SQL.parent.mkdir(parents=True, exist_ok=True) + OUT_SQL.write_text(sql, encoding="utf-8") + n_flag = sum(1 for r in kept if r["useMinus18"] == 1) + print(f"sheet={sheet_name} rows={len(kept)} useMinus18=1={n_flag} useMinus18=0={len(kept) - n_flag}") + print(f"wrote {OUT_SQL}") + + +if __name__ == "__main__": + main() diff --git a/src/main/java/com/ffii/fpsms/config/WebConfig.java b/src/main/java/com/ffii/fpsms/config/WebConfig.java index dcdf839..5ef870a 100644 --- a/src/main/java/com/ffii/fpsms/config/WebConfig.java +++ b/src/main/java/com/ffii/fpsms/config/WebConfig.java @@ -16,7 +16,7 @@ public class WebConfig implements WebMvcConfigurer { registry.addMapping("/**") .allowedHeaders("*") .allowedOrigins("*") - .exposedHeaders("filename", "Content-Disposition") + .exposedHeaders("filename", "Content-Disposition", "X-OnPack-Skipped-Expiry") .allowedMethods("GET", "POST", "PUT", "DELETE", "HEAD", "OPTIONS"); } diff --git a/src/main/java/com/ffii/fpsms/config/security/SecurityConfig.java b/src/main/java/com/ffii/fpsms/config/security/SecurityConfig.java index e59628a..7dfc830 100644 --- a/src/main/java/com/ffii/fpsms/config/security/SecurityConfig.java +++ b/src/main/java/com/ffii/fpsms/config/security/SecurityConfig.java @@ -75,6 +75,7 @@ public class SecurityConfig { } /** + * FP-MTMS Version Checklist | Functions Ref. No. 67 | v1.0.0 | 2026-08-13 * FP-MTMS Version Checklist | Functions Ref. No. 51 | v1.0.1 | 2026-08-06 * FP-MTMS Version Checklist | Functions Ref. No. 3 | v1.0.5 | 2026-08-05 * (stockAdjustment/submit → INVENTORY_ADJUST only) @@ -128,6 +129,15 @@ public class SecurityConfig { /* 工序「已完成」(Just Pass):僅 ADMIN */ .requestMatchers(HttpMethod.POST, "/product-process/Demo/ProcessLine/pass/**") .hasAuthority("ADMIN") + /* M18 手動同步頁:ADMIN 或 M18_SYNC。po-by-code 另允許 PURCHASE(採購單搜尋自動同步)。 */ + .requestMatchers(HttpMethod.GET, "/m18/test/po-by-code") + .hasAnyAuthority("ADMIN", "M18_SYNC", "PURCHASE") + .requestMatchers(HttpMethod.GET, "/m18/test/do-by-code") + .hasAnyAuthority("ADMIN", "M18_SYNC") + .requestMatchers(HttpMethod.GET, "/m18/test/do-by-code-extra") + .hasAnyAuthority("ADMIN", "M18_SYNC") + .requestMatchers(HttpMethod.GET, "/m18/test/product-by-code") + .hasAnyAuthority("ADMIN", "M18_SYNC") .anyRequest().authenticated()) .httpBasic(httpBasic -> httpBasic.authenticationEntryPoint( (request, response, authException) -> sendUnauthorizedJson(response, "Unauthorized", "UNAUTHORIZED"))) 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/chart/service/ChartService.kt b/src/main/java/com/ffii/fpsms/modules/chart/service/ChartService.kt index 83b4ce5..2ea697e 100644 --- a/src/main/java/com/ffii/fpsms/modules/chart/service/ChartService.kt +++ b/src/main/java/com/ffii/fpsms/modules/chart/service/ChartService.kt @@ -717,9 +717,13 @@ open class ChartService( } /** - * Staff delivery performance: daily pick ticket count and total time per staff. + * FP-MTMS Version Checklist | Functions Ref. No. 61 | v1.0.0 | 2026-08-10 + * Staff delivery performance: daily pick ticket count, duration, item kinds, and actual picked qty. * Uses delivery_order_pick_order (handler = handledBy); time = sum of * (ticketCompleteDateTime - ticketReleaseTime) per completed ticket. + * itemKindCount = sum of per-ticket COUNT(DISTINCT pol.itemId); + * itemQtyPicked = sum of stock_out_line.qty via pick_order_line (actual picked, not pol.qty). + * Scoped CTE first (date/store/staff) then lineAgg — avoids full-history pick/sol scan. * staffNos: when non-empty, filter to these staff by user.staffNo (multi-select). * storeIdNull: when true, only rows with dop.storeId IS NULL (takes precedence over storeId). * storeId: when non-blank and storeIdNull is not true, filter dop.storeId equality (trimmed). @@ -742,12 +746,13 @@ open class ChartService( args["endExclusive"] = endDate.plusDays(1).atStartOfDay() "AND dop.ticketCompleteDateTime < :endExclusive" } else "" - val staffSql = if (!staffNos.isNullOrEmpty()) { - val nos = staffNos.map { it.trim() }.filter { it.isNotBlank() } - if (nos.isEmpty()) "" else { - args["staffNos"] = nos - "AND u.staffNo IN (:staffNos)" - } + val staffNosFiltered = staffNos?.map { it.trim() }?.filter { it.isNotBlank() }.orEmpty() + val staffSql = if (staffNosFiltered.isNotEmpty()) { + args["staffNos"] = staffNosFiltered + "AND u_scope.staffNo IN (:staffNos)" + } else "" + val scopeUserJoin = if (staffSql.isNotEmpty()) { + "LEFT JOIN user u_scope ON dop.handledBy = u_scope.id AND u_scope.deleted = 0" } else "" val storeSql = when { storeIdNull == true -> "AND dop.storeId IS NULL" @@ -758,31 +763,61 @@ open class ChartService( else -> "" } val useStoreFilter = storeIdNull == true || !storeId.isNullOrBlank() - val fromClause = if (useStoreFilter) { + val dopFromClause = if (useStoreFilter) { "FROM delivery_order_pick_order dop" } else { "FROM delivery_order_pick_order dop FORCE INDEX (idx_dopo_staff_perf_complete)" } val sql = """ + WITH dop_scoped AS ( + SELECT + dop.id, + dop.ticketCompleteDateTime, + dop.ticketReleaseTime, + dop.handledBy, + dop.handlerName + $dopFromClause + $scopeUserJoin + WHERE dop.deleted = 0 + AND dop.ticketStatus = 'completed' + AND dop.ticketCompleteDateTime IS NOT NULL + $startSql $endSql $storeSql $staffSql + ), + lineAgg AS ( + SELECT + po.deliveryOrderPickOrderId AS dopId, + COUNT(DISTINCT pol.itemId) AS itemKindCount, + COALESCE(SUM(sol.qty), 0) AS itemQtyPicked + FROM dop_scoped d + INNER JOIN pick_order po + ON po.deliveryOrderPickOrderId = d.id + AND po.deleted = 0 + INNER JOIN pick_order_line pol + ON pol.poId = po.id + AND pol.deleted = 0 + LEFT JOIN stock_out_line sol FORCE INDEX (idx_sol_polid_deleted_status_qty) + ON sol.pickOrderLineId = pol.id + AND sol.deleted = 0 + GROUP BY po.deliveryOrderPickOrderId + ) SELECT - DATE_FORMAT(dop.ticketCompleteDateTime, '%Y-%m-%d') AS date, - COALESCE(NULLIF(TRIM(COALESCE(u.name, '')), ''), dop.handlerName, 'Unknown') AS staffName, - COUNT(dop.id) AS orderCount, + DATE_FORMAT(d.ticketCompleteDateTime, '%Y-%m-%d') AS date, + COALESCE(NULLIF(TRIM(COALESCE(u.name, '')), ''), d.handlerName, 'Unknown') AS staffName, + COUNT(d.id) AS orderCount, COALESCE(SUM( CASE - WHEN dop.ticketReleaseTime IS NOT NULL AND dop.ticketCompleteDateTime IS NOT NULL - THEN GREATEST(0, TIMESTAMPDIFF(MINUTE, dop.ticketReleaseTime, dop.ticketCompleteDateTime)) + WHEN d.ticketReleaseTime IS NOT NULL AND d.ticketCompleteDateTime IS NOT NULL + THEN GREATEST(0, TIMESTAMPDIFF(MINUTE, d.ticketReleaseTime, d.ticketCompleteDateTime)) ELSE 0 END - ), 0) AS totalMinutes - $fromClause - LEFT JOIN user u ON dop.handledBy = u.id AND u.deleted = 0 - WHERE dop.deleted = 0 - AND dop.ticketStatus = 'completed' - AND dop.ticketCompleteDateTime IS NOT NULL - $startSql $endSql $staffSql $storeSql - GROUP BY DATE_FORMAT(dop.ticketCompleteDateTime, '%Y-%m-%d'), - dop.handledBy, u.name, dop.handlerName + ), 0) AS totalMinutes, + COALESCE(SUM(la.itemKindCount), 0) AS itemKindCount, + COALESCE(SUM(la.itemQtyPicked), 0) AS itemQtyPicked + FROM dop_scoped d + LEFT JOIN user u ON d.handledBy = u.id AND u.deleted = 0 + LEFT JOIN lineAgg la ON la.dopId = d.id + GROUP BY DATE_FORMAT(d.ticketCompleteDateTime, '%Y-%m-%d'), + d.handledBy, u.name, d.handlerName ORDER BY date, orderCount DESC """.trimIndent() return jdbcDao.queryForList(sql, args) diff --git a/src/main/java/com/ffii/fpsms/modules/chart/web/ChartController.kt b/src/main/java/com/ffii/fpsms/modules/chart/web/ChartController.kt index 7d568ec..f520e77 100644 --- a/src/main/java/com/ffii/fpsms/modules/chart/web/ChartController.kt +++ b/src/main/java/com/ffii/fpsms/modules/chart/web/ChartController.kt @@ -194,9 +194,13 @@ class ChartController( chartService.getStaffDeliveryPerformanceHandlers() /** + * FP-MTMS Version Checklist | Functions Ref. No. 61 | v1.0.0 | 2026-08-10 * GET /chart/staff-delivery-performance?startDate=&endDate=&staffNo=A001&staffNo=A002&storeId=2/F&storeIdNull=true - * Returns [{ date, staffName, orderCount, totalMinutes }]. Data from delivery_order_pick_order - * (handledBy), orderCount = completed pick tickets, totalMinutes = sum(ticketCompleteDateTime - ticketReleaseTime). + * Returns [{ date, staffName, orderCount, totalMinutes, itemKindCount, itemQtyPicked }]. + * Data from delivery_order_pick_order (handledBy); orderCount = completed pick tickets; + * totalMinutes = sum(ticketCompleteDateTime - ticketReleaseTime); + * itemKindCount = sum of per-ticket COUNT(DISTINCT pick_order_line.itemId); + * itemQtyPicked = sum of stock_out_line.qty via pick_order_line. * Optional storeId filters delivery_order_pick_order.storeId; storeIdNull=true means IS NULL (overrides storeId). */ @GetMapping("/staff-delivery-performance") diff --git a/src/main/java/com/ffii/fpsms/modules/common/ErrorCodes.java b/src/main/java/com/ffii/fpsms/modules/common/ErrorCodes.java index 7da8d24..612aa4e 100644 --- a/src/main/java/com/ffii/fpsms/modules/common/ErrorCodes.java +++ b/src/main/java/com/ffii/fpsms/modules/common/ErrorCodes.java @@ -10,6 +10,8 @@ public class ErrorCodes { public static final String SEND_EMAIL_ERROR = "SEND_EMAIL_ERROR"; public static final String USERNAME_NOT_AVAILABLE = "USERNAME_NOT_AVAILABLE"; + public static final String NAME_NOT_AVAILABLE = "NAME_NOT_AVAILABLE"; + public static final String STAFF_NO_NOT_AVAILABLE = "STAFF_NO_NOT_AVAILABLE"; public static final String INIT_EXCEL_ERROR = "INIT_EXCEL_ERROR"; diff --git a/src/main/java/com/ffii/fpsms/modules/deliveryOrder/service/DoWorkbenchMainService.kt b/src/main/java/com/ffii/fpsms/modules/deliveryOrder/service/DoWorkbenchMainService.kt index 5c2bc1e..e1563c5 100644 --- a/src/main/java/com/ffii/fpsms/modules/deliveryOrder/service/DoWorkbenchMainService.kt +++ b/src/main/java/com/ffii/fpsms/modules/deliveryOrder/service/DoWorkbenchMainService.kt @@ -244,9 +244,10 @@ open class DoWorkbenchMainService( } /** + * FP-MTMS Version Checklist | Functions Ref. No. 38 | v1.0.2 | 2026-08-13 * Warehouse exclude list for workbench **re-suggest** after scan-pick (shortfall / lot split). * - JO: same list as assign ([JoWorkbenchPickConstants.DEFAULT_EXCLUDE_WAREHOUSE_CODES]). - * - Consumable: hardcoded user [ConsumableWorkbenchPickConstants.HARDCODED_EXCLUDE_USER_ID] → JO list; else `null`. + * - Consumable: hardcoded user [ConsumableWorkbenchPickConstants.HARDCODED_EXCLUDE_USER_ID] → JO list; else empty (no limit). * - DO / other: pass through request (`null` → service default excludes). */ private fun workbenchResuggestExcludeWarehouseCodes( @@ -267,7 +268,7 @@ open class DoWorkbenchMainService( } /** - * FP-MTMS Version Checklist | Functions Ref. No. 38 | v1.0.0 | 2026-08-03 + * FP-MTMS Version Checklist | Functions Ref. No. 38 | v1.0.2 | 2026-08-13 * Workbench scan-pick (DO FG): * 1) Post outbound on scanned inventory lot line first; on failure return a clear message. * 2) If the lot runs out before this stock-out line’s chunk is filled and the user did not pass a short [qty], @@ -2364,6 +2365,7 @@ return MessageResponse( } } + /** FP-MTMS Version Checklist | Functions Ref. No. 38 | v1.0.2 | 2026-08-13 */ private fun runWorkbenchPickDeferredFollowUps( solId: Long, polId: Long, @@ -2386,10 +2388,20 @@ return MessageResponse( var postMs = 0L try { if (pickOrderId != null) { - val suggestionStoreId = resolveWorkbenchSuggestionStoreId(pickOrderId, requestStoreId) + // JO / Consumable prime with storeId=null; re-suggest must match or non-2F lots + // are filtered out after resolveWorkbenchSuggestionStoreId defaults to "2/F". + val resolvedPoType = pickOrderRepository.findById(pickOrderId).orElse(null)?.type + val suggestionStoreId = + if (resolvedPoType == PickOrderType.JOB_ORDER || + resolvedPoType == PickOrderType.Consumable + ) { + null + } else { + resolveWorkbenchSuggestionStoreId(pickOrderId, requestStoreId) + } val resuggestExcludeWarehouseCodes = workbenchResuggestExcludeWarehouseCodes( pickOrderId = pickOrderId, - poType = null, + poType = resolvedPoType, userId = userId, requestExcludeWarehouseCodes = effectiveExcludeWarehouseCodes, ) @@ -2620,7 +2632,7 @@ return MessageResponse( } /** - * FP-MTMS Version Checklist | Functions Ref. No. 38 | v1.0.0 | 2026-08-03 + * FP-MTMS Version Checklist | Functions Ref. No. 38 | v1.0.2 | 2026-08-13 * DO user-audit: Just Complete is reportable only when the item still has pickable stock * at complete time (AVAILABLE, not expired, in−out > 0). * diff --git a/src/main/java/com/ffii/fpsms/modules/deliveryOrder/web/models/WorkbenchScanPickRequest.kt b/src/main/java/com/ffii/fpsms/modules/deliveryOrder/web/models/WorkbenchScanPickRequest.kt index a3e0474..983b7d5 100644 --- a/src/main/java/com/ffii/fpsms/modules/deliveryOrder/web/models/WorkbenchScanPickRequest.kt +++ b/src/main/java/com/ffii/fpsms/modules/deliveryOrder/web/models/WorkbenchScanPickRequest.kt @@ -3,7 +3,7 @@ package com.ffii.fpsms.modules.deliveryOrder.web.models import java.math.BigDecimal /** - * FP-MTMS Version Checklist | Functions Ref. No. 38 | v1.0.0 | 2026-08-03 + * FP-MTMS Version Checklist | Functions Ref. No. 38 | v1.0.2 | 2026-08-13 * Workbench v1: scan lot and post pick immediately (no separate submit step). * [qty] optional: when null, posts up to remaining quantity for this stock-out line chunk; when set, may exceed that * chunk and is capped only by available quantity on the scanned inventory lot line (overscan / UI edit). 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/JoWorkbenchPickConstants.kt b/src/main/java/com/ffii/fpsms/modules/jobOrder/service/JoWorkbenchPickConstants.kt index 76480c9..727c54a 100644 --- a/src/main/java/com/ffii/fpsms/modules/jobOrder/service/JoWorkbenchPickConstants.kt +++ b/src/main/java/com/ffii/fpsms/modules/jobOrder/service/JoWorkbenchPickConstants.kt @@ -5,6 +5,7 @@ package com.ffii.fpsms.modules.jobOrder.service * * [DEFAULT_EXCLUDE_WAREHOUSE_CODES] applies on **assign / first prime** ([JoWorkbenchMainService]) * and on **scan-pick re-suggest** ([com.ffii.fpsms.modules.deliveryOrder.service.DoWorkbenchMainService]). + * JO re-suggest keeps [storeId] null (same as assign); it does not use DO floor store resolution. */ object JoWorkbenchPickConstants { val DEFAULT_EXCLUDE_WAREHOUSE_CODES: Set = setOf( 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/OnPackPp1181Master.kt b/src/main/java/com/ffii/fpsms/modules/jobOrder/service/OnPackPp1181Master.kt new file mode 100644 index 0000000..029bb6d --- /dev/null +++ b/src/main/java/com/ffii/fpsms/modules/jobOrder/service/OnPackPp1181Master.kt @@ -0,0 +1,34 @@ +package com.ffii.fpsms.modules.jobOrder.service + +/** + * Clone 汁水機 expiry ZIP templates from classpath [MASTER_IMAGE] / [MASTER_JOB]. + * FileName stems `pp1181*` become `{code}*`; `.job` points at the cloned `.image`. + */ +object OnPackPp1181Master { + const val MASTER_CODE = "pp1181" + const val MASTER_IMAGE = "onpack2030_exp/$MASTER_CODE.image" + const val MASTER_IMAGE_FALLBACK = "onpack2030/$MASTER_CODE.image" + const val MASTER_JOB = "onpack2030/$MASTER_CODE.job" + + fun rewriteImageXml(masterXml: String, itemCode: String): String { + val codeLower = itemCode.trim().lowercase() + require(codeLower.isNotEmpty()) { "itemCode is blank" } + return masterXml.replace(Regex("pp1181", RegexOption.IGNORE_CASE), codeLower) + } + + fun rewriteJobXml(masterXml: String, imageFileName: String): String { + val name = imageFileName.trim() + require(name.isNotEmpty()) { "imageFileName is blank" } + return masterXml.replace(Regex("""(?i)pp1181\.image"""), name) + } + + fun rewriteImageBytes(masterBytes: ByteArray, itemCode: String): ByteArray { + val (xml, encodeBack) = OnPackImageTemplateCodec.decode(masterBytes) + return encodeBack(rewriteImageXml(xml, itemCode)) + } + + fun rewriteJobBytes(masterBytes: ByteArray, imageFileName: String): ByteArray { + val (xml, encodeBack) = OnPackImageTemplateCodec.decode(masterBytes) + return encodeBack(rewriteJobXml(xml, imageFileName)) + } +} 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..2dc7790 --- /dev/null +++ b/src/main/java/com/ffii/fpsms/modules/jobOrder/service/OnPackTemplateFileService.kt @@ -0,0 +1,577 @@ +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.OnPackExpiryItemCodeDto +import com.ffii.fpsms.modules.jobOrder.web.model.OnPackExpiryItemCodeUpdateRequest +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 com.ffii.fpsms.modules.master.entity.ItemsRepository +import com.ffii.fpsms.modules.master.service.ItemDefaultShelfLifeService +import com.ffii.fpsms.modules.master.service.ItemUomService +import com.ffii.fpsms.py.PyJobOrderListMapper +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 itemsRepository: ItemsRepository, + private val itemUomService: ItemUomService, + private val itemDefaultShelfLifeService: ItemDefaultShelfLifeService, +) { + 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), + ) + } + + @Transactional(readOnly = true) + open fun listExpiryItemCodes(machineRaw: String?): List { + val machine = normalizeMachine(machineRaw ?: MACHINE_JUICE) + val rows = expiryCodeRows(machine) + return enrichExpiryRows(machine, rows) + } + + /** Uppercase item code → non-blank printName override for Product BMP. */ + @Transactional(readOnly = true) + open fun expiryPrintNames(machine: String = MACHINE_JUICE): Map { + val normalized = try { + normalizeMachine(machine) + } catch (_: IllegalArgumentException) { + MACHINE_JUICE + } + return expiryCodeRows(normalized) + .mapNotNull { (code, printName) -> + val name = printName?.trim()?.takeIf { it.isNotEmpty() } ?: return@mapNotNull null + code to name + } + .toMap() + } + + /** Uppercase item codes allowed in 汁水機 expiry ZIP. Falls back to folder PP* if table is empty/missing. */ + @Transactional(readOnly = true) + open fun expiryItemCodes(machine: String = MACHINE_JUICE): Set { + val normalized = try { + normalizeMachine(machine) + } catch (_: IllegalArgumentException) { + MACHINE_JUICE + } + val fromTable = try { + jdbcDao.queryForStrings( + """ + SELECT DISTINCT UPPER(TRIM(itemCode)) + FROM onpack_expiry_item_code + WHERE deleted = 0 + AND machine = :machine + AND TRIM(itemCode) <> '' + """.trimIndent(), + mapOf("machine" to normalized), + ).mapNotNull { it.trim().uppercase().takeIf { code -> code.isNotEmpty() } }.toSet() + } catch (e: DataAccessException) { + logger.warn("onpack_expiry_item_code is unavailable; using folder PP* list", e) + return if (normalized == MACHINE_JUICE) builtinImageCodes(MACHINE_JUICE) else emptySet() + } + return fromTable + } + + @Transactional + open fun addExpiryItemCode(machineRaw: String?, itemCodeRaw: String): OnPackExpiryItemCodeDto { + val machine = normalizeMachine(machineRaw ?: MACHINE_JUICE) + val itemCode = normalizeItemCode(itemCodeRaw) + val existing = try { + jdbcDao.queryForMap( + """ + SELECT id, deleted FROM onpack_expiry_item_code + WHERE machine = :machine AND UPPER(TRIM(itemCode)) = :code + LIMIT 1 + """.trimIndent(), + mapOf("machine" to machine, "code" to itemCode), + ) + } catch (e: DataAccessException) { + throw IllegalStateException("無法儲存品號。請重啟後端以執行 Liquibase(建立 onpack_expiry_item_code)。", e) + } + if (existing.isPresent) { + val row = existing.get() + val deleted = (row["deleted"] as? Number)?.toInt() == 1 || row["deleted"] == true + if (deleted) { + jdbcDao.executeUpdate( + """ + UPDATE onpack_expiry_item_code + SET deleted = 0, modified = NOW(), modifiedBy = 'ui' + WHERE id = :id + """.trimIndent(), + mapOf("id" to row["id"]), + ) + } + } else { + jdbcDao.executeUpdate( + """ + INSERT INTO onpack_expiry_item_code + (created, createdBy, version, modified, modifiedBy, deleted, machine, itemCode) + VALUES (NOW(), 'ui', 0, NOW(), 'ui', 0, :machine, :code) + """.trimIndent(), + mapOf("machine" to machine, "code" to itemCode), + ) + } + return enrichExpiryRows(machine, listOf(itemCode to loadPrintName(machine, itemCode))).first() + } + + @Transactional + open fun updateExpiryItemCode(body: OnPackExpiryItemCodeUpdateRequest): OnPackExpiryItemCodeDto { + val machine = normalizeMachine(body.machine ?: MACHINE_JUICE) + val itemCode = normalizeItemCode(body.itemCode) + if (body.printName != null) { + val stored = body.printName.trim().takeIf { it.isNotEmpty() }?.take(255) + try { + val n = jdbcDao.executeUpdate( + """ + UPDATE onpack_expiry_item_code + SET printName = :printName, modified = NOW(), modifiedBy = 'ui' + WHERE machine = :machine AND UPPER(TRIM(itemCode)) = :code AND deleted = 0 + """.trimIndent(), + mapOf("machine" to machine, "code" to itemCode, "printName" to stored), + ) + if (n == 0) { + throw IllegalArgumentException("清單中沒有 $itemCode") + } + } catch (e: DataAccessException) { + throw IllegalStateException("無法儲存列印名稱。請重啟後端以執行 Liquibase(printName 欄位)。", e) + } + } + if (body.useMinus18 != null) { + itemDefaultShelfLifeService.setUseMinus18(itemCode, body.useMinus18) + } + return enrichExpiryRows(machine, listOf(itemCode to loadPrintName(machine, itemCode))).first() + } + + @Transactional + open fun removeExpiryItemCode(machineRaw: String?, itemCodeRaw: String) { + val machine = normalizeMachine(machineRaw ?: MACHINE_JUICE) + val itemCode = normalizeItemCode(itemCodeRaw) + try { + jdbcDao.executeUpdate( + """ + UPDATE onpack_expiry_item_code + SET deleted = 1, modified = NOW(), modifiedBy = 'ui' + WHERE machine = :machine AND UPPER(TRIM(itemCode)) = :code AND deleted = 0 + """.trimIndent(), + mapOf("machine" to machine, "code" to itemCode), + ) + } catch (e: DataAccessException) { + throw IllegalStateException("無法刪除品號。請重啟後端以執行 Liquibase(建立 onpack_expiry_item_code)。", e) + } + } + + private fun expiryCodeRows(machine: String): List> { + return try { + jdbcDao.queryForList( + """ + SELECT UPPER(TRIM(itemCode)) AS itemCode, printName + FROM onpack_expiry_item_code + WHERE deleted = 0 + AND machine = :machine + AND TRIM(itemCode) <> '' + ORDER BY itemCode + """.trimIndent(), + mapOf("machine" to machine), + ).mapNotNull { row -> + val code = row["itemCode"]?.toString()?.trim()?.uppercase().orEmpty() + if (code.isEmpty()) null + else code to row["printName"]?.toString() + } + } catch (e: DataAccessException) { + logger.warn("onpack_expiry_item_code printName list failed; falling back to codes only", e) + expiryItemCodes(machine).sorted().map { it to null } + } + } + + private fun loadPrintName(machine: String, itemCode: String): String? { + return try { + jdbcDao.queryForString( + """ + SELECT printName FROM onpack_expiry_item_code + WHERE machine = :machine AND UPPER(TRIM(itemCode)) = :code AND deleted = 0 + LIMIT 1 + """.trimIndent(), + mapOf("machine" to machine, "code" to itemCode), + ).trim().takeIf { it.isNotEmpty() } + } catch (_: DataAccessException) { + null + } + } + + private fun enrichExpiryRows(machine: String, rows: List>): List { + val codes = rows.map { it.first } + val items = if (codes.isEmpty()) emptyMap() + else itemsRepository.findByDeletedFalseAndCodeIn((codes + codes.map { it.lowercase() }).distinct()) + .mapNotNull { item -> + val code = item.code?.trim()?.uppercase().orEmpty() + if (code.isEmpty()) null else code to item + } + .toMap() + val shelf = itemDefaultShelfLifeService.findRowsByItemCodes(codes) + return rows.map { (code, printName) -> + val item = items[code] + val stockDesc = item?.id?.let { itemUomService.findStockUnitByItemId(it)?.uom?.udfudesc } + val defaultPrintName = PyJobOrderListMapper.buildDisplayItemName(item?.name, stockDesc) + val sl = shelf[code] + OnPackExpiryItemCodeDto( + machine = machine, + itemCode = code, + printName = printName?.trim()?.takeIf { it.isNotEmpty() }, + defaultPrintName = defaultPrintName, + defaultDays = sl?.defaultDays, + minus18Days = sl?.minus18Days, + useMinus18 = sl?.useMinus18 == true, + effectiveDays = sl?.let { ItemDefaultShelfLifeService.effectiveDays(it) }, + ) + } + } + + private fun supportedItems(machine: String): List { + val registered = registeredCodes(machine) + val inDatabase = itemCodesWithImage(machine) + val builtin = builtinImageCodes(machine) + val expiry = if (machine == MACHINE_JUICE) expiryItemCodes(MACHINE_JUICE) else emptySet() + return mergeSupported(registered + expiry, 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..862c8be 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,25 @@ 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) +data class OnPackZipResult( + val bytes: ByteArray, + val skippedWithoutExpiry: List = emptyList(), +) + +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 +91,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 +106,70 @@ 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 + + /** + * Laser TCP: `json;itemCode;itemName;;` or, when [expiryDate] is set, + * `json;itemCode;itemName;Expiry Date yyyyMMdd;;`. + * The lemon EZCAD job must bind a 4th text param or it replies invalid. + */ + fun buildLaserBag2Payload( + itemId: Long?, + 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;;" + } + } + + fun laserAckLooksInvalid(ack: String?): Boolean = + ack?.contains("invalid", ignoreCase = true) == true + + /** ISO `yyyy-MM-dd`, compact `yyyyMMdd`, or already `Expiry Date yyyyMMdd`. */ + fun formatLaserExpiryParam(expiryDate: String?): String { + val raw = expiryDate?.trim().orEmpty() + 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 +258,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, + ) } } @@ -193,6 +289,18 @@ class PlasticBagPrinterService( * Bag2.py [send_job_to_laser] / [send_job_to_laser_with_retry]: UTF-8 TCP payload and optional ack read. */ fun sendLaserBag2Job(request: LaserBag2SendRequest): LaserBag2SendResponse { + return try { + sendLaserBag2JobInner(request) + } catch (e: Exception) { + logger.error("sendLaserBag2Job unexpected error", e) + LaserBag2SendResponse( + success = false, + message = "送出失敗:${e.message ?: e.javaClass.simpleName}", + ) + } + } + + private fun sendLaserBag2JobInner(request: LaserBag2SendRequest): LaserBag2SendResponse { val ip = (request.printerIp?.trim()?.takeIf { it.isNotEmpty() } ?: resolveLaserBag2Host()).trim() val port = request.printerPort ?: resolveLaserBag2Port() @@ -203,32 +311,28 @@ class PlasticBagPrinterService( stockInLineId = request.stockInLineId, itemCode = request.itemCode, itemName = request.itemName, + expiryDate = request.expiryDate, ) - val response = if (first.success) { - LaserBag2SendResponse( - success = true, - message = first.message, - payloadSent = first.payload, - printerAck = first.printerAck, - receiveAcknowledged = first.receiveAcknowledged, - ) + val chosen = if (first.success) { + first } else { - val second = sendLaserBag2TcpOnce( + sendLaserBag2TcpOnce( ip = ip, port = port, itemId = request.itemId, stockInLineId = request.stockInLineId, itemCode = request.itemCode, itemName = request.itemName, - ) - LaserBag2SendResponse( - success = second.success, - message = second.message, - payloadSent = second.payload, - printerAck = second.printerAck, - receiveAcknowledged = second.receiveAcknowledged, + expiryDate = request.expiryDate, ) } + val response = LaserBag2SendResponse( + success = chosen.success, + message = chosen.message, + payloadSent = chosen.payload, + printerAck = chosen.printerAck, + receiveAcknowledged = chosen.receiveAcknowledged, + ) if (response.success && response.receiveAcknowledged) { try { persistLaserLastReceiveSuccess(request, response.printerAck) @@ -292,14 +396,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 { @@ -346,6 +445,15 @@ class PlasticBagPrinterService( } catch (_: SocketTimeoutException) { // Same as Python Bag3: ignore read timeout, payload was still sent } + if (laserAckLooksInvalid(ackRaw)) { + return LaserBag2TcpResult( + false, + "檸檬機回覆 invalid,未接受指令。", + payload, + ackRaw, + false, + ) + } val msg = if (receiveAck) { "已送出激光機:$payload(已確認)" } else { @@ -564,7 +672,18 @@ 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, clones [OnPackPp1181Master] `.image` / `.job` for each code + * on the UI-managed `onpack_expiry_item_code` list, and generates product/code/date/expiry BMPs. + * Old ZIP callers must pass false; they keep per-code `onpack2030` templates unchanged. + */ + fun generateOnPackQrZip( + jobOrders: List, + includeExpiry: Boolean = false, + printDate: LocalDate? = null, + ): OnPackZipResult { val normalizedJobOrders = jobOrders .map { OnPackQrJobOrderRequest( @@ -600,6 +719,12 @@ class PlasticBagPrinterService( val packagingJobOrders = normalizedJobOrders.filter { it.jobOrderId in allowedJobOrderIds } require(packagingJobOrders.isNotEmpty()) { "No 包裝 process job orders found for export" } + val expiryPrintNames = if (includeExpiry) { + onPackTemplateFileService.expiryPrintNames(OnPackTemplateFileService.MACHINE_JUICE) + } else { + emptyMap() + } + val exportItemsRaw = packagingJobOrders .groupBy { it.itemCode.trim().lowercase() } .mapNotNull { (codeLower, orders) -> @@ -608,31 +733,146 @@ 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 computedName = PyJobOrderListMapper.buildDisplayItemName(baseName, stockDesc) + val itemCode = (stockInLine.item?.code ?: stockInLine.itemNo ?: codeLower).trim().uppercase() + val productName = expiryPrintNames[itemCode]?.trim()?.takeIf { it.isNotEmpty() } + ?: computedName + ?: itemCode + 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 allowedBmpCodes = codesOnPackMatchingTemplateType(codesUpper, "bmp") - val exportItems = exportItemsRaw.filter { allowedBmpCodes.contains(it.first.uppercase()) } + val codesUpper = exportItemsRaw.map { it.itemCode }.toSet() + val allowedBmpCodes = if (includeExpiry) { + onPackTemplateFileService.expiryItemCodes(OnPackTemplateFileService.MACHINE_JUICE) + } else { + codesOnPackMatchingTemplateType(codesUpper, "bmp") + } + val exportItemsListed = 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" } + require(exportItemsListed.isNotEmpty()) { + if (includeExpiry) { + "當日工單沒有在汁水機到期日 ZIP 品號清單中的項目(OnPack 模板可管理此清單)" + } else { + "No OnPack QR (bmp) rows in onpack_qr for the selected job orders, or no matching templates" + } + } + + val effectivePrintDate = printDate + ?: exportItemsListed.firstNotNullOfOrNull { it.planDate } + ?: ItemDefaultShelfLifeService.today() + + val skippedWithoutExpiry = mutableListOf() + val exportItems = if (includeExpiry) { + exportItemsListed.filter { item -> + val label = itemDefaultShelfLifeService.expiryDatePrintLabel(item.itemCode, effectivePrintDate) + if (label.isNullOrBlank()) { + skippedWithoutExpiry += item.itemCode + false + } else { + true + } + } + } else { + exportItemsListed + } + + require(exportItems.isNotEmpty()) { + if (includeExpiry && skippedWithoutExpiry.isNotEmpty()) { + "當日汁水機清單品號都沒有預設保質期,無法產生到期日 ZIP。請到設定 → 物品預設保質期新增:${skippedWithoutExpiry.joinToString("、")}" + } else if (includeExpiry) { + "當日工單沒有在汁水機到期日 ZIP 品號清單中的項目(OnPack 模板可管理此清單)" + } else { + "No OnPack QR (bmp) rows in onpack_qr for the selected job orders, or no matching templates" + } + } + + val expiryMasterImage = if (includeExpiry) loadPp1181ExpiryMasterImage() else null + val expiryMasterJob = if (includeExpiry) loadPp1181MasterJob() else null + if (includeExpiry) { + require(expiryMasterImage != null) { "找不到 PP1181 到期日主模板(onpack2030_exp/pp1181.image)" } + } 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 = if (includeExpiry) { + val master = expiryMasterImage ?: return@forEach + OnPackPp1181Master.rewriteImageBytes(master, codeLower) + } else { + loadOnPackImageTemplateOrNull(codeLower, forExpiry = false) ?: 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,19 +880,50 @@ class PlasticBagPrinterService( if (addedEntries.add(imageFileName)) { addToZip(zos, imageFileName, imageContent) } + if (!includeExpiry) { + 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" + if (includeExpiry) { + val masterJob = expiryMasterJob + if (masterJob != null && addedEntries.add(jobFileName)) { + addToZip(zos, jobFileName, OnPackPp1181Master.rewriteJobBytes(masterJob, imageFileName)) + } + } else { + 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" } } - return baos.toByteArray() + return OnPackZipResult(baos.toByteArray(), skippedWithoutExpiry.distinct()) } /** * 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, + ): OnPackZipResult { val normalizedJobOrders = jobOrders .map { OnPackQrJobOrderRequest( @@ -702,20 +973,56 @@ class PlasticBagPrinterService( val codesUpper = exportItemsRaw.map { it.first.uppercase() }.toSet() val allowedTextCodes = codesOnPackMatchingTemplateType(codesUpper, "text") - val exportItems = exportItemsRaw.filter { allowedTextCodes.contains(it.first.uppercase()) } + val exportItemsListed = exportItemsRaw.filter { allowedTextCodes.contains(it.first.uppercase()) } + + require(exportItemsListed.isNotEmpty()) { "No OnPack QR (text) rows in onpack_qr for the selected job orders, or no matching templates" } + + val effectivePrintDate = printDate ?: ItemDefaultShelfLifeService.today() + val skippedWithoutExpiry = mutableListOf() + val exportItems = if (includeExpiry) { + exportItemsListed.filter { (codeLower, _, _) -> + val code = codeLower.uppercase() + val label = itemDefaultShelfLifeService.expiryDatePrintLabel(code, effectivePrintDate) + if (label.isNullOrBlank()) { + skippedWithoutExpiry += code + false + } else { + true + } + } + } else { + exportItemsListed + } - require(exportItems.isNotEmpty()) { "No OnPack QR (text) rows in onpack_qr for the selected job orders, or no matching templates" } + require(exportItems.isNotEmpty()) { + if (includeExpiry && skippedWithoutExpiry.isNotEmpty()) { + "當日檸檬機品號都沒有預設保質期,無法產生到期日 ZIP。請到設定 → 物品預設保質期新增:${skippedWithoutExpiry.joinToString("、")}" + } else { + "No OnPack QR (text) rows in onpack_qr for the selected job orders, or no matching templates" + } + } val baos = ByteArrayOutputStream() ZipOutputStream(baos).use { zos -> 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, effectivePrintDate) + } else { + null + } + val imageContent = withOnPackStaticQrText( + codeLower, + imageTemplate, + itemId, + stockInLineId, + expiryCompact = expiryLabel, + ) if (addedEntries.add(imageFileName)) { addToZip(zos, imageFileName, imageContent) } @@ -723,7 +1030,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) @@ -737,7 +1044,7 @@ class PlasticBagPrinterService( } require(addedEntries.isNotEmpty()) { "No OnPack text template files could be generated for the selected date" } } - return baos.toByteArray() + return OnPackZipResult(baos.toByteArray(), skippedWithoutExpiry.distinct()) } /** @@ -753,7 +1060,7 @@ class PlasticBagPrinterService( ) } val zipBytes = try { - generateOnPackQrTextZip(jobOrders) + generateOnPackQrTextZip(jobOrders).bytes } catch (e: Exception) { logger.warn("OnPack text ZIP generation failed before NGPCL push", e) return NgpclPushResponse( @@ -816,10 +1123,46 @@ 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 loadPp1181ExpiryMasterImage(): ByteArray? { + listOf(OnPackPp1181Master.MASTER_IMAGE, OnPackPp1181Master.MASTER_IMAGE_FALLBACK).forEach { path -> + val resource = ClassPathResource(path) + if (resource.exists()) { + return resource.inputStream.use { it.readBytes() } + } + } + logger.warn("Missing PP1181 expiry master image on classpath") + return null + } + + private fun loadPp1181MasterJob(): ByteArray? { + val resource = ClassPathResource(OnPackPp1181Master.MASTER_JOB) + if (!resource.exists()) { + logger.warn("Missing PP1181 master job on classpath") + return null + } + return resource.inputStream.use { it.readBytes() } } - 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 +1171,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 +1180,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 +1210,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 +1228,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 +1271,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 +1294,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..9ca5741 --- /dev/null +++ b/src/main/java/com/ffii/fpsms/modules/jobOrder/web/OnPackTemplateController.kt @@ -0,0 +1,92 @@ +package com.ffii.fpsms.modules.jobOrder.web + +import com.ffii.fpsms.modules.jobOrder.service.OnPackTemplateFileService +import com.ffii.fpsms.modules.jobOrder.web.model.OnPackExpiryItemCodeDto +import com.ffii.fpsms.modules.jobOrder.web.model.OnPackExpiryItemCodeRequest +import com.ffii.fpsms.modules.jobOrder.web.model.OnPackExpiryItemCodeUpdateRequest +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.PutMapping +import org.springframework.web.bind.annotation.RequestBody +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() + } + + /** Item codes included in 汁水機 OnPack expiry ZIP (dynamic PP1181 template). */ + @GetMapping("/expiry-codes") + fun listExpiryCodes( + @RequestParam(required = false) machine: String?, + ): List { + return onPackTemplateFileService.listExpiryItemCodes(machine) + } + + @PostMapping("/expiry-codes") + fun addExpiryCode(@RequestBody body: OnPackExpiryItemCodeRequest): OnPackExpiryItemCodeDto { + return onPackTemplateFileService.addExpiryItemCode(body.machine, body.itemCode) + } + + @PutMapping("/expiry-codes") + fun updateExpiryCode(@RequestBody body: OnPackExpiryItemCodeUpdateRequest): OnPackExpiryItemCodeDto { + return onPackTemplateFileService.updateExpiryItemCode(body) + } + + @DeleteMapping("/expiry-codes") + fun deleteExpiryCode( + @RequestParam(required = false) machine: String?, + @RequestParam itemCode: String, + ): ResponseEntity { + onPackTemplateFileService.removeExpiryItemCode(machine, itemCode) + return ResponseEntity.noContent().build() + } + + @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"))) + } + + @ExceptionHandler(IllegalStateException::class) + fun unavailable(e: IllegalStateException): ResponseEntity> { + return ResponseEntity.status(HttpStatus.SERVICE_UNAVAILABLE).body(mapOf("message" to (e.message ?: "Unavailable"))) + } +} 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..e8c51e1 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 @@ -1,6 +1,7 @@ package com.ffii.fpsms.modules.jobOrder.web import com.ffii.fpsms.modules.jobOrder.service.LaserBag2AutoSendService +import com.ffii.fpsms.modules.jobOrder.service.OnPackZipResult import com.ffii.fpsms.modules.jobOrder.service.PlasticBagPrinterService import com.ffii.fpsms.modules.jobOrder.web.model.PrintRequest import com.ffii.fpsms.modules.jobOrder.web.model.LaserRequest @@ -60,11 +61,20 @@ class PlasticBagPrinterController( } /** - * Bag2.py laser TCP protocol: `{"itemId":n,"stockInLineId":m};code;name;;` or `0;code;name;;` + * Bag2/Bag4 laser TCP: `{"itemId":n,"stockInLineId":m};code;name;;` or + * `{"itemId":n,"stockInLineId":m};code;name;Expiry Date yyyyMMdd;;` when expiryDate is set. */ @PostMapping("/print-laser-bag2") fun printLaserBag2(@RequestBody request: LaserBag2SendRequest): ResponseEntity { - val resp = plasticBagPrinterService.sendLaserBag2Job(request) + val resp = try { + plasticBagPrinterService.sendLaserBag2Job(request) + } catch (e: Exception) { + logger.error("print-laser-bag2 failed", e) + LaserBag2SendResponse( + success = false, + message = "送出失敗:${e.message ?: e.javaClass.simpleName}", + ) + } return if (resp.success) { ResponseEntity.ok(resp) } else { @@ -118,15 +128,8 @@ class PlasticBagPrinterController( response: HttpServletResponse, ) { try { - val zipBytes = plasticBagPrinterService.generateOnPackQrZip(request.jobOrders) - response.contentType = "application/zip" - response.setHeader( - HttpHeaders.CONTENT_DISPOSITION, - "attachment; filename=\"onpack_qr_codes.zip\"" - ) - response.setContentLength(zipBytes.size) - response.outputStream.write(zipBytes) - response.outputStream.flush() + val zip = plasticBagPrinterService.generateOnPackQrZip(request.jobOrders) + writeOnPackZip(response, "onpack_qr_codes.zip", zip) } catch (e: IllegalArgumentException) { response.status = HttpServletResponse.SC_BAD_REQUEST response.contentType = "text/plain;charset=UTF-8" @@ -148,6 +151,44 @@ class PlasticBagPrinterController( } } + /** + * Same 汁水機 ZIP as [downloadOnPackQr], plus expiry BMP from item_default_shelf_life. + * Clones PP1181 `.image` / `.job` for each code on `onpack_expiry_item_code`. + * Old [downloadOnPackQr] is unchanged. + */ + @PostMapping("/download-onpack-qr-with-expiry") + fun downloadOnPackQrWithExpiry( + @RequestBody request: OnPackQrDownloadRequest, + response: HttpServletResponse, + ) { + try { + val zip = plasticBagPrinterService.generateOnPackQrZip( + request.jobOrders, + includeExpiry = true, + printDate = request.planDate, + ) + writeOnPackZip(response, "onpack_qr_exp.zip", zip) + } 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( @@ -155,15 +196,8 @@ class PlasticBagPrinterController( response: HttpServletResponse, ) { try { - val zipBytes = plasticBagPrinterService.generateOnPackQrTextZip(request.jobOrders) - response.contentType = "application/zip" - response.setHeader( - HttpHeaders.CONTENT_DISPOSITION, - "attachment; filename=\"onpack2023_lemon_qr.zip\"" - ) - response.setContentLength(zipBytes.size) - response.outputStream.write(zipBytes) - response.outputStream.flush() + val zip = plasticBagPrinterService.generateOnPackQrTextZip(request.jobOrders) + writeOnPackZip(response, "onpack2023_lemon_qr.zip", zip) } catch (e: IllegalArgumentException) { response.status = HttpServletResponse.SC_BAD_REQUEST response.contentType = "text/plain;charset=UTF-8" @@ -194,6 +228,43 @@ 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 zip = plasticBagPrinterService.generateOnPackQrTextZip( + request.jobOrders, + includeExpiry = true, + printDate = request.planDate, + ) + writeOnPackZip(response, "onpack2023_lemon_qr_exp.zip", zip) + } 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 @@ -315,4 +386,15 @@ class PlasticBagPrinterController( } } + private fun writeOnPackZip(response: HttpServletResponse, filename: String, zip: OnPackZipResult) { + if (zip.skippedWithoutExpiry.isNotEmpty()) { + response.setHeader("X-OnPack-Skipped-Expiry", zip.skippedWithoutExpiry.joinToString(",")) + } + response.contentType = "application/zip" + response.setHeader(HttpHeaders.CONTENT_DISPOSITION, "attachment; filename=\"$filename\"") + response.setContentLength(zip.bytes.size) + response.outputStream.write(zip.bytes) + response.outputStream.flush() + } + } \ No newline at end of file diff --git a/src/main/java/com/ffii/fpsms/modules/jobOrder/web/model/FlexibleExpiryDateDeserializer.kt b/src/main/java/com/ffii/fpsms/modules/jobOrder/web/model/FlexibleExpiryDateDeserializer.kt new file mode 100644 index 0000000..503aff0 --- /dev/null +++ b/src/main/java/com/ffii/fpsms/modules/jobOrder/web/model/FlexibleExpiryDateDeserializer.kt @@ -0,0 +1,33 @@ +package com.ffii.fpsms.modules.jobOrder.web.model + +import com.fasterxml.jackson.core.JsonParser +import com.fasterxml.jackson.core.JsonToken +import com.fasterxml.jackson.databind.DeserializationContext +import com.fasterxml.jackson.databind.JsonDeserializer + +/** + * Accepts `yyyy-MM-dd`, compact `yyyyMMdd`, `Expiry Date yyyyMMdd`, + * or Jackson date-array `[2026,8,27]` (happens when `@EnableWebMvc` uses a raw ObjectMapper). + */ +class FlexibleExpiryDateDeserializer : JsonDeserializer() { + override fun deserialize(p: JsonParser, ctxt: DeserializationContext): String? { + return when (p.currentToken) { + JsonToken.VALUE_NULL -> null + JsonToken.VALUE_STRING -> p.valueAsString?.trim()?.takeIf { it.isNotEmpty() } + JsonToken.VALUE_NUMBER_INT -> p.valueAsString + JsonToken.START_ARRAY -> { + val y = p.nextIntValue(0) + val m = p.nextIntValue(0) + val d = p.nextIntValue(0) + while (p.nextToken() != JsonToken.END_ARRAY && p.currentToken != null) { + // skip extra tokens (e.g. nanoseconds on a datetime array) + } + if (y <= 0 || m <= 0 || d <= 0) null else "%04d-%02d-%02d".format(y, m, d) + } + else -> { + p.skipChildren() + null + } + } + } +} 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..c4d30e0 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,12 +1,18 @@ package com.ffii.fpsms.modules.jobOrder.web.model +import com.fasterxml.jackson.annotation.JsonIgnoreProperties +import com.fasterxml.jackson.databind.annotation.JsonDeserialize + /** - * Body for Bag2.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). + * The lemon EZCAD job must bind the 4th `;` field or it replies invalid. * Optional [printerIp] / [printerPort] override system settings [LASER_PRINT.host] / [LASER_PRINT.port]. * * Optional job metadata is used to persist [com.ffii.fpsms.modules.common.SettingNames.LASER_PRINT_LAST_RECEIVE_SUCCESS] * when the printer returns a receive ack. */ +@JsonIgnoreProperties(ignoreUnknown = true) data class LaserBag2SendRequest( val itemId: Long? = null, val stockInLineId: Long? = null, @@ -17,6 +23,12 @@ 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`, + * Jackson `[yyyy,M,d]`, or already `Expiry Date yyyyMMdd`). Sent as the 4th TCP field. + */ + @JsonDeserialize(using = FlexibleExpiryDateDeserializer::class) + val expiryDate: String? = null, /** AUTO (auto-send) or MANUAL (/laserPrint); optional. */ val source: String? = null, ) 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..9aebf14 --- /dev/null +++ b/src/main/java/com/ffii/fpsms/modules/jobOrder/web/model/OnPackTemplateModels.kt @@ -0,0 +1,53 @@ +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, +) + +data class OnPackExpiryItemCodeDto( + val machine: String, + val itemCode: String, + val printName: String? = null, + val defaultPrintName: String? = null, + val defaultDays: Int? = null, + val minus18Days: Int? = null, + val useMinus18: Boolean = false, + val effectiveDays: Int? = null, +) + +data class OnPackExpiryItemCodeRequest( + val itemCode: String, + val machine: String? = "juice", +) + +data class OnPackExpiryItemCodeUpdateRequest( + val itemCode: String, + val machine: String? = "juice", + /** Empty string clears the override (use default name + unit). Omitted = leave unchanged. */ + val printName: String? = null, + val useMinus18: Boolean? = null, +) 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..2223f3e --- /dev/null +++ b/src/main/java/com/ffii/fpsms/modules/master/entity/ItemDefaultShelfLifeRepository.kt @@ -0,0 +1,18 @@ +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 + + fun findByItemCodeIgnoreCase(itemCode: String): ItemDefaultShelfLife? + + fun findByIdAndDeletedFalse(id: Long): ItemDefaultShelfLife? + + fun findAllByDeletedFalseOrderByItemCodeAsc(): List +} diff --git a/src/main/java/com/ffii/fpsms/modules/master/entity/ItemsRepository.kt b/src/main/java/com/ffii/fpsms/modules/master/entity/ItemsRepository.kt index 4b581e2..433d0bd 100644 --- a/src/main/java/com/ffii/fpsms/modules/master/entity/ItemsRepository.kt +++ b/src/main/java/com/ffii/fpsms/modules/master/entity/ItemsRepository.kt @@ -18,6 +18,7 @@ interface ItemsRepository : AbstractRepository { fun findByCodeAndTypeAndDeletedFalse(code: String, type: String): Items?; fun findByCodeAndDeletedFalse(code: String): Items?; + fun findByDeletedFalseAndCodeIn(codes: Collection): List fun findByNameAndDeletedFalse(name: String): Items?; fun findByM18IdAndDeletedIsFalse(m18Id: Long): Items?; diff --git a/src/main/java/com/ffii/fpsms/modules/master/service/BomService.kt b/src/main/java/com/ffii/fpsms/modules/master/service/BomService.kt index 31db195..3a1bbd3 100644 --- a/src/main/java/com/ffii/fpsms/modules/master/service/BomService.kt +++ b/src/main/java/com/ffii/fpsms/modules/master/service/BomService.kt @@ -75,6 +75,7 @@ open class BomService( ) { companion object { private const val BOM_WIP_DESCRIPTION = "WIP" + private const val BOM_DETAIL_EXPORT_TEMPLATE = "excelTemplate/bom_import_blank.xlsx" } open fun uploadBomFiles(files: List): BomUploadResponse { @@ -1127,7 +1128,8 @@ open class BomService( } 3 -> { val equipmentName = tempCell.stringCellValue.trim() - if (equipmentName != "不適用") { + // 不合用 / 不適用:不掛 equipment(與格式檢查同等處理) + if (!isNotApplicableEquipment(equipmentName)) { val equipment = bomGetOrCreateEquipment(equipmentName) // println("equipment created") bomProcessRequest.equipment = equipment @@ -1913,6 +1915,314 @@ open class BomService( } } + /** Fill blank import template from a saved BOM version (BOM 明細 → 匯出 Excel). */ + @Transactional(readOnly = true) + open fun exportBomDetailExcel(id: Long): BomImportExportCorrectedResult { + val detail = getBomDetail(id) + val resource = ClassPathResource(BOM_DETAIL_EXPORT_TEMPLATE) + if (!resource.exists()) { + throw BadRequestException("BOM export template not found: $BOM_DETAIL_EXPORT_TEMPLATE") + } + resource.inputStream.use { input -> + val workbook: Workbook = XSSFWorkbook(input) + try { + val sheet = resolveImportBomSheet(workbook) + fillBomDetailOntoBlankTemplate(workbook, detail) + recalculateMaterialDerivedColumns(sheet) + refreshOutputUomFormulaDependents(sheet) + evaluateBlankTemplateFormulaDependents(sheet) + val bytes = ByteArrayOutputStream().use { out -> + workbook.write(out) + out.toByteArray() + } + val code = detail.itemCode?.trim().orEmpty().ifEmpty { "BOM" } + val rev = detail.revisionNo ?: 1 + return BomImportExportCorrectedResult( + bytes = bytes, + downloadFileName = "${code}_V${rev}.xlsx", + ) + } finally { + workbook.close() + } + } + } + + /** + * Source cells on bom_import_blank.xlsx (食物成品): + * R5 name, R6 code (A2=R6, E2=R5), J2 kind, B4 stock qty, W5 stock unit code, S6 version. + */ + private fun fillBomDetailOntoBlankTemplate(workbook: Workbook, detail: BomDetailResponse) { + val sheet = resolveImportBomSheet(workbook) + detail.itemName?.trim()?.takeIf { it.isNotEmpty() }?.let { + getOrCreateCell(sheet, 4, 17).setCellValue(it) + } + detail.itemCode?.trim()?.takeIf { it.isNotEmpty() }?.let { + getOrCreateCell(sheet, 5, 17).setCellValue(it) + } + val kind = detail.bomKind?.trim()?.takeIf { it.isNotEmpty() } + ?: detail.description?.trim()?.takeIf { it.isNotEmpty() } + kind?.let { getOrCreateCell(sheet, 1, 9).setCellValue(it) } + val rev = detail.revisionNo ?: 1 + getOrCreateCell(sheet, 5, 18).setCellValue("V$rev") + val stockQty = detail.outputQtyStock ?: detail.outputQty + stockQty?.let { + getOrCreateCell(sheet, 3, 1).setCellValue(it.toDouble()) + } + val stockUnitCode = detail.itemId + ?.let { itemUomService.findStockUnitByItemId(it)?.uom?.code?.trim() } + ?.takeIf { it.isNotEmpty() } + stockUnitCode?.let { + getOrCreateCell(sheet, 4, 22).setCellValue(it) + } + detail.isDark?.let { setBasicInfoScaleByHeaderContains(sheet, "深淺", it) } + detail.isFloat?.let { setBasicInfoScaleByHeaderContains(sheet, "浮沉", it) } + detail.isDense?.let { setBasicInfoScaleByHeaderContains(sheet, "濃淡", it) } + detail.scrapRate?.let { setBasicInfoScaleByHeaderContains(sheet, "損耗率", it) } + detail.timeSequence?.let { setBasicInfoScaleByHeaderContains(sheet, "生產時段先後數值", it) } + detail.complexity?.let { setBasicInfoScaleByHeaderContains(sheet, "複雜度", it) } + detail.allergicSubstances?.let { setBasicInfoAllergicSubstances(sheet, it) } + fillDbMaterialsOntoBlankTemplate(sheet, detail) + fillDbProcessesOntoBlankTemplate(sheet, detail.processes) + restoreSellingPriceHeaderFormulas(workbook) + fillProductRecipeSheet(workbook, detail) + } + + /** Selling Price B1/B2/B3 follow 食物成品 name/code/version (original Excel formulas). */ + private fun restoreSellingPriceHeaderFormulas(workbook: Workbook) { + val selling = workbook.getSheet("Selling Price (售價)") ?: return + setCellFormulaIfBlank(selling, 0, 1, "'食物成品 '!R5") + setCellFormulaIfBlank(selling, 1, 1, "'食物成品 '!A2") + setCellFormulaIfBlank(selling, 2, 1, "'食物成品 '!N1") + } + + private fun setCellFormulaIfBlank(sheet: Sheet, rowIdx: Int, colIdx: Int, formula: String) { + val cell = getOrCreateCell(sheet, rowIdx, colIdx) + if (cell.cellType == CellType.FORMULA) return + if (cell.cellType != CellType.BLANK && readStringCellValue(cell) != null) return + cell.cellFormula = formula + } + + /** + * 產品製方: title + version; materials fill 主要成份 then 其他汁料 (20+20); + * 製作方式 filled sequentially (A=1..n, B=description). Brand columns left empty. + */ + private fun fillProductRecipeSheet(workbook: Workbook, detail: BomDetailResponse) { + val sheet = workbook.getSheet("產品製方") ?: return + val code = detail.itemCode?.trim().orEmpty() + val name = detail.itemName?.trim().orEmpty() + val title = when { + code.isNotEmpty() && name.isNotEmpty() -> "產品名稱: ${code}_${name}" + name.isNotEmpty() -> "產品名稱: $name" + code.isNotEmpty() -> "產品名稱: $code" + else -> null + } + title?.let { getOrCreateCell(sheet, 3, 0).setCellValue(it) } + val rev = detail.revisionNo ?: 1 + getOrCreateCell(sheet, 3, 10).setCellValue("V$rev") + fillProductRecipePackWeight(sheet, detail) + detail.outputQtyStock?.let { + getOrCreateCell(sheet, 7, 8).setCellValue(it.toDouble()) + } + + val leftNameCol = 1 + val leftQtyCol = 3 + val leftUomCol = 4 + val rightNameCol = 8 + val rightQtyCol = 10 + val rightUomCol = 11 + val leftStart = 14 + val rightStart = 14 + val slotsPerSide = 20 + detail.materials.forEachIndexed { index, material -> + val itemName = material.itemName?.trim()?.takeIf { it.isNotEmpty() } ?: return@forEachIndexed + val uom = material.recipeUom?.trim()?.takeIf { it.isNotEmpty() } + ?: material.recipeUomId?.let { uomConversionRepository.findById(it).orElse(null)?.code?.trim() } + val qty = material.recipeQty + if (index < slotsPerSide) { + val rowIdx = leftStart + index + getOrCreateCell(sheet, rowIdx, leftNameCol).setCellValue(itemName) + qty?.let { getOrCreateCell(sheet, rowIdx, leftQtyCol).setCellValue(it.toDouble()) } + uom?.let { getOrCreateCell(sheet, rowIdx, leftUomCol).setCellValue(it) } + } else { + val rowIdx = rightStart + (index - slotsPerSide) + if (index - slotsPerSide >= slotsPerSide) return@forEachIndexed + getOrCreateCell(sheet, rowIdx, rightNameCol).setCellValue(itemName) + qty?.let { getOrCreateCell(sheet, rowIdx, rightQtyCol).setCellValue(it.toDouble()) } + uom?.let { getOrCreateCell(sheet, rowIdx, rightUomCol).setCellValue(it) } + } + } + + val processStart = findProductRecipeProcessStartRow(sheet) + val processEnd = processStart + 12 + val processes = detail.processes.sortedBy { it.seqNo ?: Long.MAX_VALUE } + for (rowIdx in processStart..processEnd) { + val i = rowIdx - processStart + getOrCreateCell(sheet, rowIdx, 0).setCellValue((i + 1).toDouble()) + val process = processes.getOrNull(i) + val text = process?.processDescription?.trim()?.takeIf { it.isNotEmpty() } + ?: process?.processName?.trim()?.takeIf { it.isNotEmpty() } + if (text != null) { + getOrCreateCell(sheet, rowIdx, 1).setCellValue(text) + } else { + clearCellValue(sheet, rowIdx, 1) + } + } + } + + /** I7 成品每包重量 = 1 stock unit converted to item base unit. */ + private fun fillProductRecipePackWeight(sheet: Sheet, detail: BomDetailResponse) { + val itemId = detail.itemId ?: return + val stockUom = itemUomService.findStockUnitByItemId(itemId)?.uom ?: return + val stockUomId = stockUom.id ?: return + val packWeight = runCatching { + itemUomService.convertQtyToBaseQtyPrecise(itemId, stockUomId, BigDecimal.ONE) + }.getOrNull() ?: return + getOrCreateCell(sheet, 6, 8).setCellValue(packWeight.toDouble()) + val baseCode = itemUomService.findBaseUnitByItemId(itemId)?.uom?.code?.trim()?.takeIf { it.isNotEmpty() } + if (baseCode != null && readStringCellValue(sheet.getRow(6)?.getCell(9)) == null) { + getOrCreateCell(sheet, 6, 9).setCellValue(baseCode) + } + } + + private fun findProductRecipeProcessStartRow(sheet: Sheet): Int { + for (r in 30..45) { + val v = readStringCellValue(sheet.getRow(r)?.getCell(0)) ?: continue + if (v.contains("製作方式")) return r + 1 + } + return 37 + } + + private fun fillDbMaterialsOntoBlankTemplate(sheet: Sheet, detail: BomDetailResponse) { + val headerRowIndex = findMaterialHeaderRowIndex(sheet) ?: return + val styleRowIdx = headerRowIndex + 1 + val seqByProcessId = detail.processes.mapNotNull { p -> + val id = p.id ?: return@mapNotNull null + val seq = p.seqNo ?: return@mapNotNull null + id to seq + }.toMap() + detail.materials.forEachIndexed { index, material -> + val rowIdx = headerRowIndex + 1 + index + val itemCode = material.itemCode?.trim().orEmpty() + if (itemCode.isNotEmpty()) { + getOrCreateStyledCell(sheet, rowIdx, 0, styleRowIdx).setCellValue(itemCode) + getOrCreateStyledCell(sheet, rowIdx, 16, styleRowIdx).setCellValue(itemCode) + } + material.itemName?.trim()?.takeIf { it.isNotEmpty() }?.let { + getOrCreateStyledCell(sheet, rowIdx, 1, styleRowIdx).setCellValue(it) + getOrCreateStyledCell(sheet, rowIdx, 17, styleRowIdx).setCellValue(it) + } + material.recipeQty?.let { + getOrCreateStyledCell(sheet, rowIdx, 2, styleRowIdx).setCellValue(it.toDouble()) + } + val uomCode = material.recipeUomId + ?.let { uomConversionRepository.findById(it).orElse(null)?.code?.trim() } + ?.takeIf { it.isNotEmpty() } + ?: material.recipeUom?.trim()?.takeIf { it.isNotEmpty() } + uomCode?.let { + getOrCreateStyledCell(sheet, rowIdx, 3, styleRowIdx).setCellValue(it) + } + val joinSeq = material.processStepIds.mapNotNull { seqByProcessId[it] }.minOrNull() + joinSeq?.let { + getOrCreateStyledCell(sheet, rowIdx, 10, styleRowIdx).setCellValue(it.toDouble()) + } + getOrCreateStyledCell(sheet, rowIdx, 15, styleRowIdx).setCellValue((index + 1).toDouble()) + } + } + + private fun fillDbProcessesOntoBlankTemplate(sheet: Sheet, processes: List) { + val startRowIndex = findProcessDataStartRowIndex(sheet) ?: return + val styleRowIdx = startRowIndex + processes.sortedBy { it.seqNo ?: Long.MAX_VALUE }.forEachIndexed { index, process -> + val rowIdx = startRowIndex + index + process.seqNo?.let { + getOrCreateStyledCell(sheet, rowIdx, 0, styleRowIdx).setCellValue(it.toDouble()) + } + val processLabel = process.processName?.trim()?.takeIf { it.isNotEmpty() } + ?: process.processCode?.trim()?.takeIf { it.isNotEmpty() } + processLabel?.let { + getOrCreateStyledCell(sheet, rowIdx, 1, styleRowIdx).setCellValue(it) + } + process.processDescription?.let { + getOrCreateStyledCell(sheet, rowIdx, 2, styleRowIdx).setCellValue(it) + } + val equipmentText = formatProcessEquipmentForExcel( + BomImportPreviewProcessLine( + equipmentDescription = process.equipmentDescription, + equipmentName = process.equipmentName, + ), + ) ?: "不適用" + getOrCreateStyledCell(sheet, rowIdx, 3, styleRowIdx).setCellValue(equipmentText) + process.durationInMinute?.let { + getOrCreateStyledCell(sheet, rowIdx, 5, styleRowIdx).setCellValue(it.toDouble()) + } + process.byProduct?.trim()?.takeIf { it.isNotEmpty() }?.let { + getOrCreateStyledCell(sheet, rowIdx, 8, styleRowIdx).setCellValue(it) + } + process.byProductUom?.trim()?.takeIf { it.isNotEmpty() }?.let { + getOrCreateStyledCell(sheet, rowIdx, 10, styleRowIdx).setCellValue(it) + } + process.prepTimeInMinute?.let { + getOrCreateStyledCell(sheet, rowIdx, 11, styleRowIdx).setCellValue(it.toDouble()) + } + process.postProdTimeInMinute?.let { + getOrCreateStyledCell(sheet, rowIdx, 12, styleRowIdx).setCellValue(it.toDouble()) + } + } + } + + private fun findProcessDataStartRowIndex(sheet: Sheet): Int? { + var startRowIndex = 30 + val maxHeaderSearch = 70 + var headerFound = false + while (startRowIndex < maxHeaderSearch) { + val cell = sheet.getRow(startRowIndex)?.getCell(0) + if (cell != null && cell.cellType == CellType.STRING && cell.stringCellValue.trim() == "工序") { + headerFound = true + startRowIndex += 2 + break + } + startRowIndex++ + } + return if (headerFound) startRowIndex else null + } + + private fun getOrCreateStyledCell( + sheet: Sheet, + rowIdx: Int, + colIdx: Int, + styleRowIdx: Int, + ): org.apache.poi.ss.usermodel.Cell { + val row = sheet.getRow(rowIdx) ?: sheet.createRow(rowIdx).also { created -> + sheet.getRow(styleRowIdx)?.let { created.height = it.height } + } + val existing = row.getCell(colIdx) + if (existing != null) return existing + val created = row.createCell(colIdx) + sheet.getRow(styleRowIdx)?.getCell(colIdx)?.cellStyle?.let { created.cellStyle = it } + return created + } + + private fun evaluateBlankTemplateFormulaDependents(sheet: Sheet) { + val workbook = sheet.workbook + val evaluator = workbook.creationHelper.createFormulaEvaluator() + val cells = mutableListOf( + sheet.getRow(1)?.getCell(0), // A2 =R6 + sheet.getRow(1)?.getCell(4), // E2 =R5 + sheet.getRow(0)?.getCell(13), // N1 =S6 + ) + val selling = workbook.getSheet("Selling Price (售價)") + if (selling != null) { + cells += selling.getRow(0)?.getCell(1) // B1 ='食物成品 '!R5 + cells += selling.getRow(1)?.getCell(1) // B2 ='食物成品 '!A2 + cells += selling.getRow(2)?.getCell(1) // B3 ='食物成品 '!N1 + } + for (cell in cells) { + if (cell != null && cell.cellType == CellType.FORMULA) { + evaluator.evaluateFormulaCell(cell) + } + } + } + private fun resolveImportBomFilePath(batchId: String, fileName: String): Path? { val path = getBatchDir(batchId).resolve(fileName) return if (Files.exists(path)) path else null @@ -2467,11 +2777,17 @@ open class BomService( } return lines } + /** FP-MTMS Version Checklist | Functions Ref. No. 63 | v1.0.0 | 2026-08-10 */ + /** BOM 工序「使用設備」為不合用/不適用時,不掛 equipment FK */ + private fun isNotApplicableEquipment(value: String): Boolean { + val trimmed = value.trim() + return trimmed == "不合用" || trimmed == "不適用" + } private fun isValidEquipmentType(value: String): Boolean { val trimmed = value.trim() if (trimmed.isEmpty()) return false - if (trimmed == "不合用" || trimmed == "不適用") return true + if (isNotApplicableEquipment(trimmed)) return true if (trimmed.contains(",")) return false // 新增:不允許逗號 val regex = Regex("^[^-/]+-[^-/]+$") // 例:工具類-切絲機 return regex.matches(trimmed) @@ -3508,8 +3824,11 @@ for (r in 0..20) { processCode = p.process?.code, processName = p.process?.name, processDescription = p.description, + byProduct = p.byProduct, + byProductUom = p.byProductUom, equipmentCode = p.equipment?.code, equipmentName = p.equipment?.name, + equipmentDescription = p.equipment?.description, durationInMinute = p.durationInMinute, prepTimeInMinute = p.prepTimeInMinute, postProdTimeInMinute = p.postProdTimeInMinute, 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..5921423 --- /dev/null +++ b/src/main/java/com/ffii/fpsms/modules/master/service/ItemDefaultShelfLifeService.kt @@ -0,0 +1,242 @@ +package com.ffii.fpsms.modules.master.service + +import com.ffii.fpsms.modules.master.entity.ItemDefaultShelfLife +import com.ffii.fpsms.modules.master.entity.ItemDefaultShelfLifeRepository +import com.ffii.fpsms.modules.master.entity.ItemsRepository +import com.ffii.fpsms.modules.master.web.ItemDefaultShelfLifeRequest +import com.ffii.fpsms.modules.master.web.ItemDefaultShelfLifeRow +import org.springframework.http.HttpStatus +import org.springframework.stereotype.Service +import org.springframework.transaction.annotation.Transactional +import org.springframework.web.server.ResponseStatusException +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 +open class ItemDefaultShelfLifeService( + private val repository: ItemDefaultShelfLifeRepository, + private val itemsRepository: ItemsRepository, +) { + open 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() + } + + open fun defaultDaysByItemCodes(codes: Collection): Map = + printShelfLifeByItemCodes(codes).mapValues { it.value.effectiveDays } + + open 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) + } + + open fun findRowsByItemCodes(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() + if (code.isEmpty()) null else code to row + } + .toMap() + } + + @Transactional + open fun setUseMinus18(itemCode: String?, useMinus18: Boolean): ItemDefaultShelfLifeRow { + val code = normalizeItemCode(itemCode) + if (code.isEmpty()) { + throw ResponseStatusException(HttpStatus.BAD_REQUEST, "Item code is required") + } + val row = repository.findByDeletedFalseAndItemCodeIgnoreCase(code) + ?: throw ResponseStatusException( + HttpStatus.BAD_REQUEST, + "找不到 $code 的預設保質期。請先到設定 → 物品預設保質期新增。", + ) + row.useMinus18 = useMinus18 + return toRow(repository.save(row)) + } + + open fun expiryDate(itemCode: String?, printDate: LocalDate = today()): LocalDate? { + val days = defaultDays(itemCode) ?: return null + return expiryOn(printDate, days) + } + + open fun expiryDateIso(itemCode: String?, printDate: LocalDate = today()): String? = + expiryDate(itemCode, printDate)?.toString() + + open fun expiryDateCompact(itemCode: String?, printDate: LocalDate = today()): String? = + expiryDate(itemCode, printDate)?.format(COMPACT) + + /** Printed bag wording, e.g. `Expiry Date 20260821`. */ + open fun expiryDatePrintLabel(itemCode: String?, printDate: LocalDate = today()): String? = + expiryDate(itemCode, printDate)?.let { formatPrintLabel(it) } + + open fun list(q: String? = null): List { + val rows = repository.findAllByDeletedFalseOrderByItemCodeAsc() + val names = itemNamesByCode(rows.mapNotNull { it.itemCode }) + val needle = q?.trim()?.lowercase().orEmpty() + return rows + .map { toRow(it, names[it.itemCode?.trim()?.uppercase().orEmpty()]) } + .filter { row -> + if (needle.isEmpty()) true + else row.itemCode.lowercase().contains(needle) || + row.itemName.orEmpty().lowercase().contains(needle) || + row.remarks.orEmpty().lowercase().contains(needle) + } + } + + @Transactional + open fun create(request: ItemDefaultShelfLifeRequest): ItemDefaultShelfLifeRow { + val code = normalizeItemCode(request.itemCode) + validateRequest(request, code) + val existing = repository.findByItemCodeIgnoreCase(code) + if (existing != null && existing.deleted != true) { + throw ResponseStatusException(HttpStatus.CONFLICT, "Item code already exists: $code") + } + val row = existing ?: ItemDefaultShelfLife() + applyRequest(row, request, code) + row.deleted = false + return toRow(repository.save(row)) + } + + @Transactional + open fun update(id: Long, request: ItemDefaultShelfLifeRequest): ItemDefaultShelfLifeRow { + val row = repository.findByIdAndDeletedFalse(id) + ?: throw ResponseStatusException(HttpStatus.NOT_FOUND, "Shelf life row $id not found") + val code = normalizeItemCode(request.itemCode) + validateRequest(request, code) + val other = repository.findByItemCodeIgnoreCase(code) + if (other != null && other.id != id) { + throw ResponseStatusException(HttpStatus.CONFLICT, "Item code already exists: $code") + } + applyRequest(row, request, code) + return toRow(repository.save(row)) + } + + @Transactional + open fun markDeleted(id: Long): List { + val row = repository.findByIdAndDeletedFalse(id) + ?: throw ResponseStatusException(HttpStatus.NOT_FOUND, "Shelf life row $id not found") + row.deleted = true + repository.save(row) + return list() + } + + private fun applyRequest(row: ItemDefaultShelfLife, request: ItemDefaultShelfLifeRequest, code: String) { + row.itemCode = code + row.defaultDays = request.defaultDays + row.minus18Days = request.minus18Days + row.useMinus18 = request.useMinus18 == true + row.openedDays = request.openedDays + row.storageC = request.storageC?.trim()?.takeIf { it.isNotEmpty() } + row.remarks = request.remarks?.trim()?.takeIf { it.isNotEmpty() } + } + + private fun toRow(row: ItemDefaultShelfLife, itemName: String? = null): ItemDefaultShelfLifeRow { + val code = row.itemCode?.trim().orEmpty() + val name = itemName ?: itemNameFor(code) + return ItemDefaultShelfLifeRow( + id = row.id ?: 0L, + itemCode = code, + itemName = name, + defaultDays = row.defaultDays, + minus18Days = row.minus18Days, + useMinus18 = row.useMinus18 == true, + openedDays = row.openedDays, + storageC = row.storageC, + remarks = row.remarks, + effectiveDays = effectiveDays(row), + ) + } + + private fun itemNameFor(code: String): String? { + if (code.isEmpty()) return null + return itemsRepository.findByCodeAndDeletedFalse(code)?.name?.trim()?.takeIf { it.isNotEmpty() } + } + + private fun itemNamesByCode(codes: Collection): Map { + val raw = codes.mapNotNull { it.trim().takeIf { c -> c.isNotEmpty() } }.distinct() + if (raw.isEmpty()) return emptyMap() + val lookup = (raw + raw.map { it.uppercase() }).distinct() + return itemsRepository.findByDeletedFalseAndCodeIn(lookup) + .mapNotNull { item -> + val code = item.code?.trim()?.uppercase().orEmpty() + val name = item.name?.trim()?.takeIf { it.isNotEmpty() } + if (code.isEmpty() || name == null) null else code to name + } + .toMap() + } + + 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 } + } + + fun normalizeItemCode(raw: String?): String = + raw?.trim()?.uppercase().orEmpty() + + fun validateRequest(request: ItemDefaultShelfLifeRequest, code: String) { + if (code.isEmpty()) { + throw ResponseStatusException(HttpStatus.BAD_REQUEST, "Item code is required") + } + if (code.length > 50) { + throw ResponseStatusException(HttpStatus.BAD_REQUEST, "Item code must be at most 50 characters") + } + requireDays("defaultDays", request.defaultDays) + requireDays("minus18Days", request.minus18Days) + requireDays("openedDays", request.openedDays) + val storage = request.storageC?.trim().orEmpty() + if (storage.length > 20) { + throw ResponseStatusException(HttpStatus.BAD_REQUEST, "storageC must be at most 20 characters") + } + val remarks = request.remarks?.trim().orEmpty() + if (remarks.length > 255) { + throw ResponseStatusException(HttpStatus.BAD_REQUEST, "remarks must be at most 255 characters") + } + } + + private fun requireDays(field: String, value: Int?) { + if (value != null && value < 0) { + throw ResponseStatusException(HttpStatus.BAD_REQUEST, "$field must be 0 or greater") + } + } + } +} 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 0f383d6..bf9e18a 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 @@ -634,6 +634,10 @@ open fun listBagItemsCombo(): List> { 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) } @@ -703,34 +707,67 @@ open fun listBagItemsCombo(): List> { @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/modules/master/web/BomController.kt b/src/main/java/com/ffii/fpsms/modules/master/web/BomController.kt index 0c3903f..0af7088 100644 --- a/src/main/java/com/ffii/fpsms/modules/master/web/BomController.kt +++ b/src/main/java/com/ffii/fpsms/modules/master/web/BomController.kt @@ -242,6 +242,15 @@ fun getBomDetail(@PathVariable id: Long): BomDetailResponse { return bomService.getBomDetail(id) } + @GetMapping("/{id}/export-excel") + fun exportBomDetailExcel(@PathVariable id: Long): ResponseEntity { + val result = bomService.exportBomDetailExcel(id) + return ResponseEntity.ok() + .header(HttpHeaders.CONTENT_DISPOSITION, "attachment") + .header(HttpHeaders.CONTENT_TYPE, "application/vnd.openxmlformats-officedocument.spreadsheetml.sheet") + .body(ByteArrayResource(result.bytes)) + } + /** FP-MTMS Version Checklist | Functions Ref. No. 17 | v1.0.0 | 2026-07-16 */ @PostMapping("/{id}/activate-version") fun activateBomVersion(@PathVariable id: Long): BomDetailResponse { diff --git a/src/main/java/com/ffii/fpsms/modules/master/web/ItemDefaultShelfLifeController.kt b/src/main/java/com/ffii/fpsms/modules/master/web/ItemDefaultShelfLifeController.kt new file mode 100644 index 0000000..1fa17f4 --- /dev/null +++ b/src/main/java/com/ffii/fpsms/modules/master/web/ItemDefaultShelfLifeController.kt @@ -0,0 +1,64 @@ +package com.ffii.fpsms.modules.master.web + +import com.ffii.fpsms.modules.master.service.ItemDefaultShelfLifeService +import org.springframework.web.bind.annotation.DeleteMapping +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.PutMapping +import org.springframework.web.bind.annotation.RequestBody +import org.springframework.web.bind.annotation.RequestMapping +import org.springframework.web.bind.annotation.RequestParam +import org.springframework.web.bind.annotation.RestController + +@RequestMapping("itemDefaultShelfLives") +@RestController +class ItemDefaultShelfLifeController( + private val itemDefaultShelfLifeService: ItemDefaultShelfLifeService, +) { + @GetMapping + fun list(@RequestParam(required = false) q: String?): List { + return itemDefaultShelfLifeService.list(q) + } + + @PostMapping + fun create(@RequestBody request: ItemDefaultShelfLifeRequest): ItemDefaultShelfLifeRow { + return itemDefaultShelfLifeService.create(request) + } + + @PutMapping("/{id}") + fun update( + @PathVariable id: Long, + @RequestBody request: ItemDefaultShelfLifeRequest, + ): ItemDefaultShelfLifeRow { + return itemDefaultShelfLifeService.update(id, request) + } + + @DeleteMapping("/{id}") + fun delete(@PathVariable id: Long): List { + return itemDefaultShelfLifeService.markDeleted(id) + } +} + +data class ItemDefaultShelfLifeRequest( + val itemCode: String? = null, + val defaultDays: Int? = null, + val minus18Days: Int? = null, + val useMinus18: Boolean? = false, + val openedDays: Int? = null, + val storageC: String? = null, + val remarks: String? = null, +) + +data class ItemDefaultShelfLifeRow( + val id: Long, + val itemCode: String, + val itemName: String? = null, + val defaultDays: Int? = null, + val minus18Days: Int? = null, + val useMinus18: Boolean = false, + val openedDays: Int? = null, + val storageC: String? = null, + val remarks: String? = null, + val effectiveDays: Int? = null, +) diff --git a/src/main/java/com/ffii/fpsms/modules/pickOrder/entity/PickOrderRepository.kt b/src/main/java/com/ffii/fpsms/modules/pickOrder/entity/PickOrderRepository.kt index 12462c3..1ad668a 100644 --- a/src/main/java/com/ffii/fpsms/modules/pickOrder/entity/PickOrderRepository.kt +++ b/src/main/java/com/ffii/fpsms/modules/pickOrder/entity/PickOrderRepository.kt @@ -159,6 +159,7 @@ fun findCompletedWithPlasticBoxCartonQtyInPlanStartRange( ): List +/** FP-MTMS Version Checklist | Functions Ref. No. 37 | v1.0.2 | 2026-08-25 */ @Modifying(clearAutomatically = true, flushAutomatically = true) @Query( value = """ @@ -169,6 +170,7 @@ fun findCompletedWithPlasticBoxCartonQtyInPlanStartRange( modifiedBy = :modifiedBy WHERE deliveryOrderPickOrderId = :dopoId AND deleted = 0 + AND LOWER(COALESCE(status, '')) <> 'completed' """, nativeQuery = true ) diff --git a/src/main/java/com/ffii/fpsms/modules/pickOrder/service/ConsumableWorkbenchPickConstants.kt b/src/main/java/com/ffii/fpsms/modules/pickOrder/service/ConsumableWorkbenchPickConstants.kt index 21c29c1..caaadd8 100644 --- a/src/main/java/com/ffii/fpsms/modules/pickOrder/service/ConsumableWorkbenchPickConstants.kt +++ b/src/main/java/com/ffii/fpsms/modules/pickOrder/service/ConsumableWorkbenchPickConstants.kt @@ -6,13 +6,16 @@ import com.ffii.fpsms.modules.jobOrder.service.JoWorkbenchPickConstants * Temporary consumable workbench exclude list until per-user DB config (Scheme A) lands. * * - [HARDCODED_EXCLUDE_USER_ID] → same warehouses as JO ([JoWorkbenchPickConstants]). - * - All other users → `null` → DO 2F default excludes in [SuggestedPickLotWorkbenchService]. + * - All other users → empty list (no warehouse limit). Do not return null: + * [SuggestedPickLotWorkbenchService] treats null as DO default 2F excludes. + * Consumable re-suggest keeps storeId null (same as first prime / JO); it does not use DO floor store resolution. */ object ConsumableWorkbenchPickConstants { const val HARDCODED_EXCLUDE_USER_ID: Long = 246L - fun resolveExcludeWarehouseCodes(userId: Long): List? { - if (userId != HARDCODED_EXCLUDE_USER_ID) return null + /** FP-MTMS Version Checklist | Functions Ref. No. 66 | v1.0.0 | 2026-08-13 */ + fun resolveExcludeWarehouseCodes(userId: Long): List { + if (userId != HARDCODED_EXCLUDE_USER_ID) return emptyList() return JoWorkbenchPickConstants.DEFAULT_EXCLUDE_WAREHOUSE_CODES.toList() } } diff --git a/src/main/java/com/ffii/fpsms/modules/productProcess/entity/ProductProcessLineRepository.kt b/src/main/java/com/ffii/fpsms/modules/productProcess/entity/ProductProcessLineRepository.kt index d121b4b..db23083 100644 --- a/src/main/java/com/ffii/fpsms/modules/productProcess/entity/ProductProcessLineRepository.kt +++ b/src/main/java/com/ffii/fpsms/modules/productProcess/entity/ProductProcessLineRepository.kt @@ -29,12 +29,13 @@ interface ProductProcessLineRepository : JpaRepository """) fun findByProductProcess_IdInWithOperatorAndEquipment(@Param("ids") ids: List): List + /** FP-MTMS Version Checklist | Functions Ref. No. 41 | v1.0.3 | 2026-08-10 */ @Query( """ SELECT p.jobOrder.id AS jobOrderId, COUNT(l.id) AS totalLines, - SUM(CASE WHEN l.status IN ('Completed', 'Pass') THEN 1 ELSE 0 END) AS doneLines + SUM(CASE WHEN l.status IN ('Completed', 'Pass', 'autoPass') THEN 1 ELSE 0 END) AS doneLines FROM ProductProcessLine l JOIN l.productProcess p WHERE l.deleted = false diff --git a/src/main/java/com/ffii/fpsms/modules/productProcess/service/DrinkShipmentQtyService.kt b/src/main/java/com/ffii/fpsms/modules/productProcess/service/DrinkShipmentQtyService.kt new file mode 100644 index 0000000..bafd7c3 --- /dev/null +++ b/src/main/java/com/ffii/fpsms/modules/productProcess/service/DrinkShipmentQtyService.kt @@ -0,0 +1,400 @@ +package com.ffii.fpsms.modules.productProcess.service + +import com.ffii.core.support.JdbcDao +import com.ffii.fpsms.modules.productProcess.web.model.DrinkShipmentQtyDeliveryDetail +import com.ffii.fpsms.modules.productProcess.web.model.DrinkShipmentQtyResponse +import org.springframework.stereotype.Service +import org.springframework.transaction.annotation.Transactional +import java.math.BigDecimal +import java.sql.Date +import java.sql.Timestamp +import java.time.LocalDate + +@Service +@Transactional(readOnly = true) +open class DrinkShipmentQtyService( + private val jdbcDao: JdbcDao, +) { + /** 訂單列:預計送貨日=選取日,或當日有掃出。實際出貨=當日 stock_ledger.outQty。 */ + open fun getDrinkShipmentQty(date: LocalDate?): List { + val targetDate = date ?: LocalDate.now() + val params = mapOf("targetDate" to targetDate.toString()) + val rows = jdbcDao.queryForList(dolSql, params) + + jdbcDao.queryForList(replenishmentOnlySql, params) + if (rows.isEmpty()) { + return emptyList() + } + + data class GroupKey( + val itemCode: String?, + val itemName: String?, + ) + + data class DeliveryKey( + val deliveryOrderId: Long, + val itemCode: String?, + ) + + val deliveriesByItem = linkedMapOf>>() + rows.forEach { row -> + val itemCode = jdbcString(row, "itemCode") + val itemName = jdbcString(row, "itemName") + val uom = jdbcString(row, "uom") + val itemKey = GroupKey(itemCode, itemName) + val detail = DrinkShipmentQtyDeliveryDetail( + deliveryOrderId = jdbcLong(row, "deliveryOrderId"), + deliveryOrderCode = jdbcString(row, "deliveryOrderCode"), + deliveryDate = jdbcDate(row, "deliveryDate"), + shopCode = jdbcString(row, "shopCode"), + shopName = shopLabel( + jdbcString(row, "shopCode"), + jdbcString(row, "shopName"), + ), + deliveryOrderStatus = jdbcString(row, "deliveryOrderStatus"), + orderQty = jdbcDecimal(row, "orderQty"), + shippedQty = jdbcDecimal(row, "shippedQty"), + ) + val deliveryKey = DeliveryKey(detail.deliveryOrderId, itemCode) + val byDelivery = deliveriesByItem.getOrPut(itemKey) { linkedMapOf() } + val existing = byDelivery[deliveryKey] + if (existing == null) { + byDelivery[deliveryKey] = detail to uom + } else { + val merged = existing.first.copy( + orderQty = existing.first.orderQty + detail.orderQty, + shippedQty = existing.first.shippedQty + detail.shippedQty, + shopName = existing.first.shopName ?: detail.shopName, + deliveryOrderCode = existing.first.deliveryOrderCode ?: detail.deliveryOrderCode, + deliveryOrderStatus = existing.first.deliveryOrderStatus ?: detail.deliveryOrderStatus, + ) + byDelivery[deliveryKey] = merged to (existing.second ?: uom) + } + } + + return deliveriesByItem + .map { (key, byDelivery) -> + val deliveries = byDelivery.values + .map { it.first } + .sortedBy { it.deliveryOrderCode ?: "" } + DrinkShipmentQtyResponse( + itemCode = key.itemCode, + itemName = key.itemName, + uom = byDelivery.values.firstNotNullOfOrNull { it.second }, + totalOrderQty = deliveries.fold(BigDecimal.ZERO) { acc, row -> acc + row.orderQty }, + totalShippedQty = deliveries.fold(BigDecimal.ZERO) { acc, row -> acc + row.shippedQty }, + deliveries = deliveries, + ) + } + .sortedWith( + compareBy { it.itemCode ?: "" } + .thenBy { it.itemName ?: "" }, + ) + } + + private fun shopLabel(shopCode: String?, shopName: String?): String? { + val code = shopCode?.trim()?.takeIf { it.isNotEmpty() } + val name = shopName?.trim()?.takeIf { it.isNotEmpty() } + return when { + code != null && name != null && name.startsWith("$code -") -> name + code != null && name != null -> "$code - $name" + name != null -> name + else -> code + } + } + + private fun jdbcString(row: Map, key: String): String? { + val value = row[key] ?: row[key.lowercase()] ?: return null + val text = value.toString().trim() + return text.takeIf { it.isNotEmpty() && !it.equals("null", ignoreCase = true) } + } + + private fun jdbcLong(row: Map, key: String): Long { + val value = row[key] ?: row[key.lowercase()] ?: return 0L + return when (value) { + is Number -> value.toLong() + else -> value.toString().toLongOrNull() ?: 0L + } + } + + private fun jdbcDecimal(row: Map, key: String): BigDecimal { + val value = row[key] ?: row[key.lowercase()] ?: return BigDecimal.ZERO + return when (value) { + is BigDecimal -> value + is Number -> BigDecimal(value.toString()) + else -> value.toString().toBigDecimalOrNull() ?: BigDecimal.ZERO + } + } + + private fun jdbcDate(row: Map, key: String): LocalDate? { + val value = row[key] ?: row[key.lowercase()] ?: return null + return when (value) { + is LocalDate -> value + is Date -> value.toLocalDate() + is Timestamp -> value.toLocalDateTime().toLocalDate() + else -> runCatching { LocalDate.parse(value.toString().take(10)) }.getOrNull() + } + } + + companion object { + private const val DRINK_ITEM_JOIN = """ + INNER JOIN ( + SELECT itemId, MIN(id) AS bomId + FROM bom + WHERE deleted = 0 + AND IFNULL(isDrink, 0) = 1 + AND itemId IS NOT NULL + GROUP BY itemId + ) drink_item ON drink_item.itemId = it.id + LEFT JOIN bom b ON b.id = drink_item.bomId + LEFT JOIN item_uom iu + ON iu.id = ( + SELECT MIN(iu2.id) + FROM item_uom iu2 + WHERE iu2.itemId = it.id + AND iu2.stockUnit = 1 + AND iu2.deleted = 0 + ) + LEFT JOIN uom_conversion uc ON uc.id = iu.uomId + """ + + private const val SOL_DO_TYPE_FILTER = """ + AND LOWER(IFNULL(sol.type, 'nor')) NOT IN ('trf', 'adj', 'tke', 'stocktake', 'jo') + AND ( + so.id IS NULL + OR ( + IFNULL(so.deleted, 0) = 0 + AND LOWER(IFNULL(so.type, 'do')) = 'do' + ) + ) + """ + + /** + * 當天掃出去的量:優先 stock_ledger.outQty(跨日提料只計當日增量)。 + * 無 ledger 的舊資料才 fallback 到當日 pickTime 的 sol.qty。 + */ + private const val SHIPPED_QTY_FOR_DO = """ + IFNULL(( + SELECT SUM( + CASE + WHEN sl.id IS NOT NULL THEN IFNULL(sl.outQty, 0) + WHEN DATE(IFNULL(sol.pickTime, sol.created)) = :targetDate + AND NOT EXISTS ( + SELECT 1 + FROM stock_ledger slx + WHERE slx.stockOutLineId = sol.id + AND IFNULL(slx.deleted, 0) = 0 + ) + THEN IFNULL(sol.qty, 0) + ELSE 0 + END + ) + FROM pick_order po + INNER JOIN pick_order_line pol + ON pol.poId = po.id + AND pol.deleted = 0 + AND pol.itemId = it.id + INNER JOIN stock_out_line sol + ON sol.pickOrderLineId = pol.id + AND sol.deleted = 0 + LEFT JOIN stock_out so + ON so.id = sol.stockOutId + LEFT JOIN stock_ledger sl + ON sl.stockOutLineId = sol.id + AND IFNULL(sl.deleted, 0) = 0 + AND DATE(sl.date) = :targetDate + AND IFNULL(sl.outQty, 0) > 0 + WHERE po.deleted = 0 + AND po.doId = do.id + AND LOWER(IFNULL(po.type, 'do')) = 'do' + $SOL_DO_TYPE_FILTER + ), 0) + """ + + private const val SCANNED_TODAY_FOR_DO = """ + EXISTS ( + SELECT 1 + FROM pick_order po + INNER JOIN pick_order_line pol + ON pol.poId = po.id + AND pol.deleted = 0 + AND pol.itemId = it.id + INNER JOIN stock_out_line sol + ON sol.pickOrderLineId = pol.id + AND sol.deleted = 0 + LEFT JOIN stock_out so + ON so.id = sol.stockOutId + LEFT JOIN stock_ledger sl + ON sl.stockOutLineId = sol.id + AND IFNULL(sl.deleted, 0) = 0 + AND DATE(sl.date) = :targetDate + AND IFNULL(sl.outQty, 0) > 0 + WHERE po.deleted = 0 + AND po.doId = do.id + AND LOWER(IFNULL(po.type, 'do')) = 'do' + $SOL_DO_TYPE_FILTER + AND ( + sl.id IS NOT NULL + OR ( + IFNULL(sol.qty, 0) > 0 + AND DATE(IFNULL(sol.pickTime, sol.created)) = :targetDate + AND NOT EXISTS ( + SELECT 1 + FROM stock_ledger slx + WHERE slx.stockOutLineId = sol.id + AND IFNULL(slx.deleted, 0) = 0 + ) + ) + ) + ) + """ + + private val dolSql = """ + SELECT + it.code AS itemCode, + it.name AS itemName, + COALESCE( + NULLIF(TRIM(b.outputQtyUom), ''), + NULLIF(TRIM(b.excelUom), ''), + uc.udfudesc + ) AS uom, + do.id AS deliveryOrderId, + do.code AS deliveryOrderCode, + do.status AS deliveryOrderStatus, + DATE(IFNULL(do.estimatedArrivalDate, do.orderDate)) AS deliveryDate, + IFNULL(sp.code, '') AS shopCode, + IFNULL(sp.name, '') AS shopName, + SUM(IFNULL(dol.qty, 0)) + + IFNULL(( + SELECT SUM(IFNULL(r.replenishQty, 0)) + FROM do_replenishment r + WHERE IFNULL(r.deleted, 0) = 0 + AND r.targetDoId = do.id + AND r.itemId = it.id + ), 0) AS orderQty, + $SHIPPED_QTY_FOR_DO AS shippedQty + FROM delivery_order_line dol + INNER JOIN delivery_order do + ON dol.deliveryOrderId = do.id + AND do.deleted = 0 + INNER JOIN items it + ON dol.itemId = it.id + AND it.deleted = 0 + $DRINK_ITEM_JOIN + LEFT JOIN shop sp + ON do.shopId = sp.id + AND sp.deleted = 0 + WHERE dol.deleted = 0 + AND ( + DATE(IFNULL(do.estimatedArrivalDate, do.orderDate)) = :targetDate + OR $SCANNED_TODAY_FOR_DO + ) + GROUP BY + it.id, + it.code, + it.name, + b.outputQtyUom, + b.excelUom, + uc.udfudesc, + do.id, + do.code, + do.status, + DATE(IFNULL(do.estimatedArrivalDate, do.orderDate)), + sp.code, + sp.name + ORDER BY it.code, do.code + """.trimIndent() + + private val replenishmentOnlySql = """ + SELECT + it.code AS itemCode, + it.name AS itemName, + COALESCE( + NULLIF(TRIM(b.outputQtyUom), ''), + NULLIF(TRIM(b.excelUom), ''), + uc.udfudesc + ) AS uom, + COALESCE(r.targetDoId, r.sourceDoId, 0) AS deliveryOrderId, + COALESCE(do.code, r.targetDoCode, r.sourceDoCode) AS deliveryOrderCode, + do.status AS deliveryOrderStatus, + r.deliveryDate AS deliveryDate, + IFNULL(r.shopCode, IFNULL(sp.code, '')) AS shopCode, + IFNULL(r.shopName, IFNULL(sp.name, '')) AS shopName, + SUM(IFNULL(r.replenishQty, 0)) AS orderQty, + IFNULL(MAX(ship.shippedQty), 0) AS shippedQty + FROM do_replenishment r + INNER JOIN items it + ON r.itemId = it.id + AND it.deleted = 0 + $DRINK_ITEM_JOIN + LEFT JOIN delivery_order do + ON do.id = COALESCE(r.targetDoId, r.sourceDoId) + AND do.deleted = 0 + LEFT JOIN shop sp + ON do.shopId = sp.id + AND sp.deleted = 0 + LEFT JOIN ( + SELECT + COALESCE(r2.targetDoId, r2.sourceDoId, 0) AS deliveryOrderId, + r2.itemId AS itemId, + SUM( + CASE + WHEN sl.id IS NOT NULL THEN IFNULL(sl.outQty, 0) + WHEN DATE(IFNULL(sol.pickTime, sol.created)) = :targetDate + AND NOT EXISTS ( + SELECT 1 + FROM stock_ledger slx + WHERE slx.stockOutLineId = sol.id + AND IFNULL(slx.deleted, 0) = 0 + ) + THEN IFNULL(sol.qty, 0) + ELSE 0 + END + ) AS shippedQty + FROM do_replenishment r2 + INNER JOIN stock_out_line sol + ON sol.pickOrderLineId = r2.pickOrderLineId + AND sol.deleted = 0 + LEFT JOIN stock_out so + ON so.id = sol.stockOutId + LEFT JOIN stock_ledger sl + ON sl.stockOutLineId = sol.id + AND IFNULL(sl.deleted, 0) = 0 + AND DATE(sl.date) = :targetDate + AND IFNULL(sl.outQty, 0) > 0 + WHERE IFNULL(r2.deleted, 0) = 0 + $SOL_DO_TYPE_FILTER + GROUP BY + COALESCE(r2.targetDoId, r2.sourceDoId, 0), + r2.itemId + ) ship + ON ship.deliveryOrderId = COALESCE(r.targetDoId, r.sourceDoId, 0) + AND ship.itemId = it.id + WHERE IFNULL(r.deleted, 0) = 0 + AND ( + r.deliveryDate = :targetDate + OR IFNULL(ship.shippedQty, 0) > 0 + ) + AND NOT EXISTS ( + SELECT 1 + FROM delivery_order_line dol + WHERE dol.deleted = 0 + AND dol.itemId = r.itemId + AND dol.deliveryOrderId = COALESCE(r.targetDoId, r.sourceDoId) + ) + GROUP BY + it.id, + it.code, + it.name, + b.outputQtyUom, + b.excelUom, + uc.udfudesc, + COALESCE(r.targetDoId, r.sourceDoId, 0), + COALESCE(do.code, r.targetDoCode, r.sourceDoCode), + do.status, + r.deliveryDate, + IFNULL(r.shopCode, IFNULL(sp.code, '')), + IFNULL(r.shopName, IFNULL(sp.name, '')) + ORDER BY it.code, COALESCE(do.code, r.targetDoCode, r.sourceDoCode) + """.trimIndent() + } +} diff --git a/src/main/java/com/ffii/fpsms/modules/productProcess/service/ProductProcessService.kt b/src/main/java/com/ffii/fpsms/modules/productProcess/service/ProductProcessService.kt index 9158f35..bd560ae 100644 --- a/src/main/java/com/ffii/fpsms/modules/productProcess/service/ProductProcessService.kt +++ b/src/main/java/com/ffii/fpsms/modules/productProcess/service/ProductProcessService.kt @@ -1307,7 +1307,7 @@ open class ProductProcessService( ) } - /** FP-MTMS Version Checklist | Functions Ref. No. 58 | v1.0.0 | 2026-08-09 */ + /** FP-MTMS Version Checklist | Functions Ref. No. 58 | v1.0.1 | 2026-08-10 */ open fun updateProductProcessLineStatus(productProcessLineId: Long, status: String): MessageResponse { println(" Service: Updating ProductProcessLine Status: $productProcessLineId") val productProcessLine = productProcessLineRepository.findById(productProcessLineId).orElse(null) @@ -1317,8 +1317,8 @@ open class ProductProcessService( productProcessLineRepository.save(productProcessLine) println(" Service: ProductProcessLine Status Updated: ${productProcessLine.status}") - // One packaging Complete/Pass → auto-Pass remaining packaging lines on the same JO. - if (isLineDone(status) && isPackagingLine(productProcessLine)) { + // One packaging Complete/Pass → autoPass remaining packaging lines on the same JO. + if (isManualLineDone(status) && isPackagingLine(productProcessLine)) { autoPassSiblingPackagingLines(productProcessLineId) } @@ -1376,7 +1376,7 @@ open class ProductProcessService( val productProcessLines = productProcessLineRepository.findByProductProcess_Id(productProcessId) println(" Service: ProductProcessLines: $productProcessLines") - if (productProcessLines.all { it.status == "Completed" || it.status == "Pass" }) { + if (productProcessLines.all { isLineDone(it.status) }) { productProcess.status = ProductProcessStatus.COMPLETED if (productProcess.endTime == null) { productProcess.endTime = LocalDateTime.now() @@ -1568,9 +1568,9 @@ bomDescription = productProcesses.bom?.bomKind, } /** - * FP-MTMS Version Checklist | Functions Ref. No. 41 | v1.0.2 | 2026-08-09 - * QC 列表:按 jobOrder 粒度分页,qcReady 由该 jobOrder 下所有 productProcessLine 是否全部 Completed/Pass 决定。 - * (包裝 Complete/Pass 時會自動 Pass 同 JO 其餘包裝 line,因此不再需要「任一包裝即可」特例。) + * FP-MTMS Version Checklist | Functions Ref. No. 41 | v1.0.3 | 2026-08-10 + * QC 列表:按 jobOrder 粒度分页,qcReady 由该 jobOrder 下所有 productProcessLine 是否全部 Completed/Pass/autoPass 决定。 + * (包裝 Complete/Pass 時會自動 autoPass 同 JO 其餘包裝 line,因此不再需要「任一包裝即可」特例。) * * 注意:date/itemCode/jobOrderCode/bomIds 用来筛选“候选 jobOrder”,但 qcReady 判断会用该 jobOrder 下全部 productProcessLine。 */ @@ -1720,10 +1720,6 @@ bomDescription = productProcesses.bom?.bomKind, val linesByJobOrder = candidateLines.groupBy { it.productProcess.jobOrder?.id ?: 0L } - val done = { s: String? -> - val x = s?.trim()?.lowercase() - x == "completed" || x == "pass" - } val qcKeys = candidateAggregates.mapNotNull { agg -> val jobOrderId = agg.jobOrderId val stockInLine = stockInLineByJobOrderId[jobOrderId] @@ -1745,15 +1741,10 @@ bomDescription = productProcesses.bom?.bomKind, val lineAggregate = lineStatusByJobOrderId[jobOrderId] val totalLines = lineAggregate?.totalLines ?: 0L - val done = { s: String? -> - val x = s?.trim()?.lowercase() - x == "completed" || x == "pass" - } - - - // After packaging Complete/Pass auto-passes sibling 包裝 lines, qcReady is simply all lines done. + + // After packaging Complete/Pass autoPasses sibling 包裝 lines, qcReady is simply all lines done. val jobLines = linesByJobOrder[jobOrderId].orEmpty() - val allLinesDone = jobLines.isNotEmpty() && jobLines.all { done(it.status) } + val allLinesDone = jobLines.isNotEmpty() && jobLines.all { isLineDone(it.status) } val ready = includedInList && stockInLine != null && allLinesDone @@ -1943,10 +1934,7 @@ bomDescription = productProcesses.bom?.bomKind, val joPickOrdersList = if (pickOrderId != null) joPickOrdersByPickOrderId[pickOrderId].orEmpty() else emptyList() val productProcessLines = linesByProcessId[productProcess.id ?: 0L].orEmpty() - val finishedCount = productProcessLines.count { - val s = it.status?.trim()?.lowercase() - s == "completed" || s == "pass" - } + val finishedCount = productProcessLines.count { isLineDone(it.status) } val bomIsDrink = productProcess.bom?.isDrink val matchStatus = if (joPickOrdersList.isNotEmpty() && @@ -2020,7 +2008,7 @@ bomDescription = productProcess.bom?.bomKind, } /** - * FG QC / 上架 reminders: same eligibility as 完成QC工單 (qcReady): all lines Completed/Pass, stock-in exists and not completed/rejected; + * FG QC / 上架 reminders: same eligibility as 完成QC工單 (qcReady): all lines Completed/Pass/autoPass, stock-in exists and not completed/rejected; * only job orders that have a product process dated **today or yesterday** (server local date). */ open fun findJobOrderFgQcAndPutAwayAlertsForTodayYesterday(): JobOrderFgAlertsResponse { @@ -2074,7 +2062,7 @@ bomDescription = productProcess.bom?.bomKind, val jobOrderLines = processes.flatMap { p -> linesByProcessId[p.id ?: 0L].orEmpty() } val allLinesDone = jobOrderLines.isNotEmpty() && - jobOrderLines.all { it.status == "Completed" || it.status == "Pass" } + jobOrderLines.all { isLineDone(it.status) } if (!allLinesDone) continue val maxDate = processes.mapNotNull { it.date }.maxOrNull() @@ -2176,6 +2164,12 @@ bomDescription = productProcess.bom?.bomKind, status?.trim()?.lowercase()?.replace(" ", "") ?: "" private fun isLineDone(status: String?): Boolean { + val n = normalizeLineStatus(status) + return n == "completed" || n == "pass" || n == "autopass" + } + + /** Manual Complete/Pass only — does not include autoPass (avoids cascade loops). */ + private fun isManualLineDone(status: String?): Boolean { val n = normalizeLineStatus(status) return n == "completed" || n == "pass" } @@ -2185,14 +2179,14 @@ bomDescription = productProcess.bom?.bomKind, return n == "inprogress" || n == "paused" } - /** FP-MTMS Version Checklist | Functions Ref. No. 58 | v1.0.0 | 2026-08-09 */ + /** FP-MTMS Version Checklist | Functions Ref. No. 58 | v1.0.1 | 2026-08-10 */ private fun isPackagingLine(line: ProductProcessLine): Boolean = (line.name ?: "").trim() == PACKAGING_PROCESS_NAME /** - * FP-MTMS Version Checklist | Functions Ref. No. 58 | v1.0.0 | 2026-08-09 - * When one 包裝 line is Completed/Pass, auto-Pass other unfinished 包裝 lines on the same job order, - * then sync each affected ProductProcess.status (Pass counts as done → COMPLETED). + * FP-MTMS Version Checklist | Functions Ref. No. 58 | v1.0.1 | 2026-08-10 + * When one 包裝 line is Completed/Pass, set other unfinished 包裝 lines on the same job order to autoPass, + * then sync each affected ProductProcess.status (autoPass counts as done → COMPLETED). */ private fun autoPassSiblingPackagingLines(triggerLineId: Long) { val trigger = productProcessLineRepository.findById(triggerLineId).orElse(null) ?: return @@ -2220,7 +2214,7 @@ bomDescription = productProcess.bom?.bomKind, line.startTime = now } line.endTime = now - line.status = "Pass" + line.status = "autoPass" productProcessLineRepository.save(line) line.productProcess?.id?.let { affectedProcessIds.add(it) } } @@ -2232,7 +2226,7 @@ bomDescription = productProcess.bom?.bomKind, /** * Align parent [ProductProcess.status] with all line states. - * - All Completed/Pass -> completed (via [ifAllLinesCompletedOrPassed]) + * - All Completed/Pass/autoPass -> completed (via [ifAllLinesCompletedOrPassed]) * - Any line started but not all done -> in_progress * - Does not override STOPPED or CANCELLED on the parent. */ @@ -2312,8 +2306,8 @@ bomDescription = productProcess.bom?.bomKind, // 获取所有 product process lines val allproductProcessLines = productProcessLineRepository.findByProductProcess_Id(productProcessId) - // 检查是否所有 lines 都是 "Completed" 或 "Pass" - if (allproductProcessLines.all { it.status == "Completed" || it.status == "Pass" }) { + // 检查是否所有 lines 都是 Completed / Pass / autoPass + if (allproductProcessLines.all { isLineDone(it.status) }) { // 更新 product process 的 endTime 和状态 updateProductProcessEndTime(productProcessId) updateProductProcessStatus(productProcessId, ProductProcessStatus.COMPLETED) @@ -2828,6 +2822,7 @@ bomDescription = productProcess.bom?.bomKind, } val drinkOrders = candidateJobOrders + .filter { it.isHidden != true } .filter { it.bom?.isDrink == true } .filter { jo -> isPlannedView || jo.status != JobOrderStatus.PLANNING @@ -3154,7 +3149,7 @@ bomDescription = productProcess.bom?.bomKind, } val currentProcesses = operatorLines - .filter { it.endTime == null || (it.status != null && it.status != "Completed" && it.status != "Pass") } + .filter { it.endTime == null || (it.status != null && !isLineDone(it.status)) } .map { line -> val productProcess = line.productProcess val jobOrder = productProcess.jobOrder diff --git a/src/main/java/com/ffii/fpsms/modules/productProcess/web/ProductProcessController.kt b/src/main/java/com/ffii/fpsms/modules/productProcess/web/ProductProcessController.kt index c081000..10843be 100644 --- a/src/main/java/com/ffii/fpsms/modules/productProcess/web/ProductProcessController.kt +++ b/src/main/java/com/ffii/fpsms/modules/productProcess/web/ProductProcessController.kt @@ -3,6 +3,7 @@ package com.ffii.fpsms.modules.productProcess.web import com.ffii.fpsms.modules.productProcess.entity.ProductProcess import com.ffii.fpsms.modules.productProcess.entity.ProductProcessLine import com.ffii.fpsms.modules.productProcess.enums.ProductProcessStatus +import com.ffii.fpsms.modules.productProcess.service.DrinkShipmentQtyService import com.ffii.fpsms.modules.productProcess.service.ProductProcessService import com.ffii.fpsms.modules.productProcess.web.model.* import org.springframework.data.domain.Page @@ -16,7 +17,8 @@ import java.time.format.DateTimeFormatter @RestController @RequestMapping("/product-process") class ProductProcessController( - private val productProcessService: ProductProcessService + private val productProcessService: ProductProcessService, + private val drinkShipmentQtyService: DrinkShipmentQtyService, ) { @GetMapping @@ -200,7 +202,7 @@ class ProductProcessController( return productProcessService.getAllJoborderProductProcessInfo(bomType) } - /** FP-MTMS Version Checklist | Functions Ref. No. 41 | v1.0.1 | 2026-08-06 */ + /** FP-MTMS Version Checklist | Functions Ref. No. 41 | v1.0.3 | 2026-08-10 */ @GetMapping("/Demo/Process/search") fun demoprocesssearch( @RequestParam(required = false) date: String?, @@ -333,4 +335,14 @@ class ProductProcessController( } return productProcessService.getDrinkProductionQty(parsedDate, viewMode) } + + @GetMapping("/Demo/DrinkShipmentQty") + fun getDrinkShipmentQty( + @RequestParam(required = false) date: String?, + ): List { + val parsedDate = date?.takeIf { it.isNotBlank() }?.let { + LocalDate.parse(it, DateTimeFormatter.ISO_DATE) + } + return drinkShipmentQtyService.getDrinkShipmentQty(parsedDate) + } } \ No newline at end of file diff --git a/src/main/java/com/ffii/fpsms/modules/productProcess/web/model/SaveProductProcessRequest.kt b/src/main/java/com/ffii/fpsms/modules/productProcess/web/model/SaveProductProcessRequest.kt index 68a8a82..01afa35 100644 --- a/src/main/java/com/ffii/fpsms/modules/productProcess/web/model/SaveProductProcessRequest.kt +++ b/src/main/java/com/ffii/fpsms/modules/productProcess/web/model/SaveProductProcessRequest.kt @@ -218,7 +218,7 @@ data class JobOrderProductProcessPageResponse( ) /** - * Nav alerts aligned with 完成QC工單 list: all product process lines Completed/Pass, stock-in not completed/rejected; + * Nav alerts aligned with 完成QC工單 list: all product process lines Completed/Pass/autoPass, stock-in not completed/rejected; * job order has at least one process dated today or yesterday. [qc] = before received; [putAway] = received / partially_completed. */ data class JobOrderFgAlertRowResponse( @@ -400,4 +400,25 @@ data class DrinkProductionQtyResponse( val totalReqQty: BigDecimal, val totalQty: BigDecimal, val jobOrders: List = emptyList(), +) + +data class DrinkShipmentQtyDeliveryDetail( + val deliveryOrderId: Long, + val deliveryOrderCode: String?, + @JsonFormat(shape = JsonFormat.Shape.STRING, pattern = "yyyy-MM-dd") + val deliveryDate: LocalDate?, + val shopCode: String?, + val shopName: String?, + val deliveryOrderStatus: String?, + val orderQty: BigDecimal, + val shippedQty: BigDecimal, +) + +data class DrinkShipmentQtyResponse( + val itemCode: String?, + val itemName: String?, + val uom: String?, + val totalOrderQty: BigDecimal, + val totalShippedQty: BigDecimal, + val deliveries: List = emptyList(), ) \ No newline at end of file diff --git a/src/main/java/com/ffii/fpsms/modules/report/service/DoInventoryUomMismatchReportService.kt b/src/main/java/com/ffii/fpsms/modules/report/service/DoInventoryUomMismatchReportService.kt new file mode 100644 index 0000000..a5fcde2 --- /dev/null +++ b/src/main/java/com/ffii/fpsms/modules/report/service/DoInventoryUomMismatchReportService.kt @@ -0,0 +1,128 @@ +package com.ffii.fpsms.modules.report.service + +import com.ffii.core.support.JdbcDao +import com.ffii.fpsms.modules.deliveryOrder.service.DoFloorSupplierSettingsService +import org.springframework.stereotype.Service +import java.time.LocalDate +import java.time.format.DateTimeParseException + +@Service +class DoInventoryUomMismatchReportService( + private val jdbcDao: JdbcDao, + private val doFloorSupplierSettingsService: DoFloorSupplierSettingsService, +) { + /** + * FP-MTMS Version Checklist | Functions Ref. No. 60 | v1.0.0 | 2026-08-10 + * Pending DO lines by estimatedArrivalDate whose current inventory lot UOM + * does not match the DO line UOM. Excel-only report. + * Supplier scope follows DoSearch floor lists (2F / 4F / All = both). + */ + fun searchDoInventoryUomMismatch( + deliveryDate: String?, + storeId: String? = null, + ): List> { + val raw = deliveryDate?.trim()?.replace("/", "-").orEmpty() + if (raw.isBlank()) { + throw IllegalArgumentException("deliveryDate is required") + } + + val date = try { + LocalDate.parse(raw) + } catch (_: DateTimeParseException) { + throw IllegalArgumentException("deliveryDate must be yyyy-MM-dd") + } + + val today = LocalDate.now() + if (date.isBefore(today)) { + throw IllegalArgumentException("deliveryDate must not be before today") + } + + val floor = storeId?.trim().orEmpty().let { f -> + when { + f.isEmpty() || f.equals("All", ignoreCase = true) -> "ALL" + else -> f + } + } + val allowedSupplierCodes = doFloorSupplierSettingsService.allowedSupplierCodesForFloor(floor) + if (allowedSupplierCodes.isEmpty()) { + return emptyList() + } + + val args = mutableMapOf( + "deliveryDate" to date.toString(), + "allowedSupplierCodes" to allowedSupplierCodes, + ) + + // Drive from pending DO by estimatedArrivalDate (not orderDate / pick requiredDeliveryDate). + // STRAIGHT_JOIN keeps DO CTE as driver (avoids full inventory_lot scan). + // Supplier filter matches DoSearch: d.supplier.code IN floor settings (2F/4F/All). + val sql = """ + WITH do_lines AS ( + SELECT DISTINCT + do.code AS doCode, + DATE(do.estimatedArrivalDate) AS deliveryDate, + s.code AS supplierCode, + s.name AS supplierName, + dol.id AS doLineId, + dol.itemId AS itemId, + dol.itemNo AS itemNo, + dol.qty AS doQty, + dol.uomId AS doUomId, + uc_do.udfudesc AS doUom + FROM delivery_order do + INNER JOIN shop s + ON s.id = do.supplierId + AND s.deleted = 0 + AND s.code IN (:allowedSupplierCodes) + INNER JOIN delivery_order_line dol + ON dol.deliveryOrderId = do.id + AND dol.deleted = 0 + LEFT JOIN uom_conversion uc_do + ON uc_do.id = dol.uomId + AND uc_do.deleted = 0 + WHERE do.deleted = 0 + AND do.status = 'pending' + AND do.supplierId IS NOT NULL + AND do.estimatedArrivalDate IS NOT NULL + AND DATE(do.estimatedArrivalDate) = :deliveryDate + ) + SELECT + DATE_FORMAT(d.deliveryDate, '%Y-%m-%d') AS deliveryDate, + d.doCode AS doCode, + d.supplierCode AS supplierCode, + d.supplierName AS supplierName, + d.itemNo AS itemCode, + it.name AS itemName, + d.doQty AS doQty, + d.doUom AS doUom, + d.doUomId AS doUomId, + il.lotNo AS mismatchLotNo, + (IFNULL(ill.inQty, 0) - IFNULL(ill.outQty, 0)) AS mismatchLotQty, + uc_ill.udfudesc AS mismatchLotUom, + iu.uomId AS inventoryUomId + FROM do_lines d + STRAIGHT_JOIN inventory_lot il + ON il.itemId = d.itemId + AND il.deleted = 0 + STRAIGHT_JOIN inventory_lot_line ill + ON ill.inventoryLotId = il.id + AND ill.deleted = 0 + AND (IFNULL(ill.inQty, 0) - IFNULL(ill.outQty, 0)) > 0 + STRAIGHT_JOIN item_uom iu + ON iu.id = ill.stockItemUomId + AND iu.deleted = 0 + LEFT JOIN uom_conversion uc_ill + ON uc_ill.id = iu.uomId + AND uc_ill.deleted = 0 + LEFT JOIN items it + ON it.id = d.itemId + AND it.deleted = 0 + WHERE d.doUomId IS NULL + OR iu.uomId IS NULL + OR d.doUomId <> iu.uomId + ORDER BY d.doCode, d.itemNo, il.lotNo + """.trimIndent() + + return jdbcDao.queryForList(sql, args) + } +} diff --git a/src/main/java/com/ffii/fpsms/modules/report/service/ItemQcFailReportService.kt b/src/main/java/com/ffii/fpsms/modules/report/service/ItemQcFailReportService.kt index 94a9038..fc2d1b6 100644 --- a/src/main/java/com/ffii/fpsms/modules/report/service/ItemQcFailReportService.kt +++ b/src/main/java/com/ffii/fpsms/modules/report/service/ItemQcFailReportService.kt @@ -7,6 +7,7 @@ import org.springframework.stereotype.Service open class ItemQcFailReportService( private val jdbcDao: JdbcDao, ) { + /** FP-MTMS Version Checklist | Functions Ref. No. 64 | v1.0.0 | 2026-08-11 */ fun searchItemQcFailReport( stockCategory: String?, itemCode: String?, @@ -33,16 +34,19 @@ open class ItemQcFailReportService( val qcItemScopeSql = buildQcItemScopeClause(measurable, other) val measuredValueSql = buildMeasuredValueClause(measurable, scope) + // Date filter = QC result created time (true QC finish), not receiptDate / put-away. + // JO EPQC: receiptDate is often SIL create day; put-away ≈ QC via auto put-away. + // PO IQC: put-away can be days after QC — must not use ill.created for this filter. val lastInDateStartSql = if (!lastInDateStart.isNullOrBlank()) { val formattedDate = lastInDateStart.replace("/", "-") args["lastInDateStart"] = formattedDate - "AND DATE(sil.receiptDate) >= DATE(:lastInDateStart)" + "AND DATE(qr.created) >= DATE(:lastInDateStart)" } else "" val lastInDateEndSql = if (!lastInDateEnd.isNullOrBlank()) { val formattedDate = lastInDateEnd.replace("/", "-") args["lastInDateEnd"] = formattedDate - "AND DATE(sil.receiptDate) <= DATE(:lastInDateEnd)" + "AND DATE(qr.created) <= DATE(:lastInDateEnd)" } else "" val sql = """ diff --git a/src/main/java/com/ffii/fpsms/modules/report/service/ShopOrderReplenishmentReportService.kt b/src/main/java/com/ffii/fpsms/modules/report/service/ShopOrderReplenishmentReportService.kt index 7319118..a66e7f5 100644 --- a/src/main/java/com/ffii/fpsms/modules/report/service/ShopOrderReplenishmentReportService.kt +++ b/src/main/java/com/ffii/fpsms/modules/report/service/ShopOrderReplenishmentReportService.kt @@ -15,6 +15,7 @@ import java.math.BigDecimal class ShopOrderReplenishmentReportService( private val jdbcDao: JdbcDao, ) { + /** FP-MTMS Version Checklist | Functions Ref. No. 49 | v1.0.1 | 2026-08-10 */ fun searchShopOrderReplenishmentReport( reorderDateStart: String?, reorderDateEnd: String?, @@ -87,6 +88,7 @@ class ShopOrderReplenishmentReportService( DATE_FORMAT(dr.created, '%Y-%m-%d') AS reorderDate, dr.reason AS reason, DATE_FORMAT(dop.requiredDeliveryDate, '%Y-%m-%d') AS deliveredDate, + TRIM(IFNULL(dop.handlerName, '')) AS actualDeliveredHandler, dr.sourceDoId AS sourceDoId, dr.itemId AS itemId, dr.pickOrderLineId AS pickOrderLineId @@ -130,7 +132,7 @@ class ShopOrderReplenishmentReportService( val itemIds = baseRows.mapNotNull { longVal(it["itemId"]) }.distinct() val actualDeliveredByPolId = loadActualDeliveredQtyByPickOrderLineId(pickOrderLineIds) - val firstOrderPickBySourceKey = loadFirstOrderActualPickQty(sourceDoIds, itemIds, pickOrderLineIds) + val firstOrderPickBySourceKey = loadFirstOrderPickInfo(sourceDoIds, itemIds, pickOrderLineIds) val rows = baseRows.map { row -> val polId = longVal(row["pickOrderLineId"]) @@ -138,6 +140,7 @@ class ShopOrderReplenishmentReportService( val itemId = longVal(row["itemId"]) val sourceKey = if (sourceDoId != null && itemId != null) sourceDoId to itemId else null + val firstOrder = sourceKey?.let { firstOrderPickBySourceKey[it] } linkedMapOf( "shopNo" to row["shopNo"], @@ -147,11 +150,13 @@ class ShopOrderReplenishmentReportService( "itemNo" to row["itemNo"], "itemName" to row["itemName"], "firstOrderQty" to row["firstOrderQty"], - "firstOrderActualPickQty" to (sourceKey?.let { firstOrderPickBySourceKey[it] } ?: BigDecimal.ZERO), + "firstOrderActualPickQty" to (firstOrder?.qty ?: BigDecimal.ZERO), + "firstOrderPickerHandler" to (firstOrder?.handler ?: ""), "reorderQty" to row["reorderQty"], "reorderDate" to row["reorderDate"], "reason" to row["reason"], "actualDeliveredQty" to (polId?.let { actualDeliveredByPolId[it] } ?: BigDecimal.ZERO), + "actualDeliveredHandler" to (row["actualDeliveredHandler"] ?: ""), "deliveredDate" to row["deliveredDate"], ) } @@ -185,14 +190,15 @@ class ShopOrderReplenishmentReportService( } /** - * Sum stock_out_line.qty for source DO pick order lines of the same item, + * First-order pick qty + handler for source DO + item, * excluding any POL that is itself a replenishment line (incl. current report POLs). + * Same ticket has at most one handler; MAX() is for GROUP BY only. */ - private fun loadFirstOrderActualPickQty( + private fun loadFirstOrderPickInfo( sourceDoIds: List, itemIds: List, excludePickOrderLineIds: List, - ): Map, BigDecimal> { + ): Map, FirstOrderPickInfo> { if (sourceDoIds.isEmpty() || itemIds.isEmpty()) return emptyMap() val args = mutableMapOf( @@ -210,7 +216,8 @@ class ShopOrderReplenishmentReportService( SELECT po.doId AS sourceDoId, pol.itemId AS itemId, - SUM(IFNULL(sol.qty, 0)) AS firstOrderActualPickQty + SUM(IFNULL(sol.qty, 0)) AS firstOrderActualPickQty, + MAX(TRIM(IFNULL(dop.handlerName, ''))) AS firstOrderPickerHandler FROM pick_order po INNER JOIN pick_order_line pol ON pol.poId = po.id @@ -218,6 +225,9 @@ class ShopOrderReplenishmentReportService( LEFT JOIN stock_out_line sol ON sol.pickOrderLineId = pol.id AND IFNULL(sol.deleted, 0) = 0 + LEFT JOIN delivery_order_pick_order dop + ON dop.id = po.deliveryOrderPickOrderId + AND IFNULL(dop.deleted, 0) = 0 WHERE IFNULL(po.deleted, 0) = 0 AND po.doId IN (:sourceDoIds) AND pol.itemId IN (:itemIds) @@ -236,10 +246,18 @@ class ShopOrderReplenishmentReportService( return rows.mapNotNull { row -> val sourceDoId = longVal(row["sourceDoId"]) ?: return@mapNotNull null val itemId = longVal(row["itemId"]) ?: return@mapNotNull null - (sourceDoId to itemId) to decimalVal(row["firstOrderActualPickQty"]) + (sourceDoId to itemId) to FirstOrderPickInfo( + qty = decimalVal(row["firstOrderActualPickQty"]), + handler = row["firstOrderPickerHandler"]?.toString()?.trim().orEmpty(), + ) }.toMap() } + private data class FirstOrderPickInfo( + val qty: BigDecimal, + val handler: String, + ) + private fun normalizeDate(raw: String): String = raw.trim().replace("/", "-") private fun dateStartClause( diff --git a/src/main/java/com/ffii/fpsms/modules/report/service/StockLedgerReportService.kt b/src/main/java/com/ffii/fpsms/modules/report/service/StockLedgerReportService.kt index 4ab4978..55c546b 100644 --- a/src/main/java/com/ffii/fpsms/modules/report/service/StockLedgerReportService.kt +++ b/src/main/java/com/ffii/fpsms/modules/report/service/StockLedgerReportService.kt @@ -4,17 +4,25 @@ import com.ffii.core.support.JdbcDao import org.springframework.stereotype.Service import java.time.LocalDate import java.time.format.DateTimeFormatter + @Service open class StockLedgerReportService( private val jdbcDao: JdbcDao, ) { + private val dateFmt = DateTimeFormatter.ofPattern("yyyy-MM-dd") + /** + * FP-MTMS Version Checklist | Functions Ref. No. 65 | v1.0.0 | 2026-08-11 * Stock Ledger 報表查詢 * * - stockSubCategory = items.type * - trnDate = stock_ledger.date * - trnRefNo = stock_ledger.type + * - cumBalance = stock_ledger.balance(異動後纍計存量) + * - cumOpeningBal = balance - inQty + outQty(異動前纍計期初) + * + * 只查 [start, end] 期間列,不掃起日以前全歷史。 */ fun searchStockLedgerReport( stockCategory: String?, @@ -23,18 +31,15 @@ open class StockLedgerReportService( reportPeriodStart: String?, reportPeriodEnd: String?, ): List> { - + val args = mutableMapOf() - - // 1) 先決定 reportPeriodEnd:如果有填 end,就用使用者的;否則用今天 + val reportPeriodEnd = (reportPeriodEnd?.replace("/", "-") - ?: LocalDate.now().format(DateTimeFormatter.ofPattern("yyyy-MM-dd"))) - - // 2) 如果有填 start,就用使用者的;否則從 DB 查最早一筆日期 + ?: LocalDate.now().format(dateFmt)) + val reportPeriodStart = if (!reportPeriodStart.isNullOrBlank()) { reportPeriodStart.replace("/", "-") } else { - // 用簡單 SQL 查全表最早一筆日期(可加上類似 stockCategory/itemCode 過濾) val minDateSql = """ SELECT DATE_FORMAT(MIN(sl.date), '%Y-%m-%d') AS firstDate FROM stock_ledger sl @@ -42,188 +47,39 @@ open class StockLedgerReportService( AND sl.itemCode IS NOT NULL AND sl.itemCode <> '' """.trimIndent() - + val minDateRow = jdbcDao.queryForList(minDateSql, emptyMap()).firstOrNull() (minDateRow?.get("firstDate") as? String) - ?: reportPeriodEnd // 如果表是空的,就退回用今天 + ?: reportPeriodEnd } - - // 3) 把 from/to 塞到 args,供後面 SQL 使用 + + val endExclusive = LocalDate.parse(reportPeriodEnd).plusDays(1).format(dateFmt) args["reportPeriodStart"] = reportPeriodStart - args["reportPeriodEnd"] = reportPeriodEnd - - // 4) 之後再用你原來的 stockCategorySql / itemCodeSql / storeLocationSql + args["reportPeriodEndExclusive"] = endExclusive + val stockCategorySql = buildMultiValueExactClause( stockCategory, "it.type", "stockCategory", args ) - + val itemCodeSql = buildMultiValueLikeClause( itemCode, "sl.itemCode", "itemCode", args ) - + + // 用 lot 子查詢的 storeLocation,避免 ill_in 放大列數 val storeLocationSql = if (!storeLocation.isNullOrBlank()) { args["storeLocation"] = "%$storeLocation%" - "AND (wh_in.code LIKE :storeLocation OR wh_out.code LIKE :storeLocation)" + "AND lot.storeLocation LIKE :storeLocation" } else { "" } - - val reportPeriodEndSql = "AND DATE(sl.date) <= :reportPeriodEnd" - val sql = """ -WITH base AS ( - SELECT - sl.id AS slId, - DATE(sl.date) AS trnDateRaw, - DATE_FORMAT(sl.date, '%Y-%m-%d') AS trnDate, - CASE - WHEN UPPER(TRIM(COALESCE(sl.type, ''))) = 'TKE' THEN 'TKE' - WHEN UPPER(TRIM(COALESCE(sl.type, ''))) = 'ADJ' - AND (so.stockTakeId IS NOT NULL OR so.type = 'stockTake' OR si.stockTakeId IS NOT NULL) THEN 'TKE' - ELSE COALESCE(sl.type, '') - END AS trnRefNoRaw, - - sl.itemId AS itemId, - sl.itemCode AS itemCode, - - COALESCE(sl.inQty, 0) AS inQty, - COALESCE(sl.outQty, 0) AS outQty, - (COALESCE(sl.inQty, 0) - COALESCE(sl.outQty, 0)) AS delta, - - it.type AS stockSubCategory, - it.code AS itemNo, - it.name AS itemName, - uc.udfudesc AS unitOfMeasure, - - lot.lotNo AS lotNo, - COALESCE(DATE_FORMAT(lot.expiryDate, '%Y-%m-%d'), '') AS expiryDate, - lot.storeLocation AS storeLocation, - - COALESCE(TRIM(do.code), TRIM(jo_po.code), TRIM(jo.code), TRIM(po.code), '') AS orderRefNo, - COALESCE(TRIM(jo.code), '') AS jobOrderNo, - - '' AS openingBalance, - '' AS cumStockIn, - '' AS cumStockOut, - '' AS currentBalance, - '' AS lastInDate, - '' AS lastOutDate, - '' AS reOrderLevel, - '' AS reOrderQty - FROM stock_ledger sl - LEFT JOIN stock_in_line sil - ON sl.stockInLineId = sil.id - AND sil.deleted = 0 - LEFT JOIN inventory_lot il_in - ON sil.inventoryLotId = il_in.id - AND il_in.deleted = 0 - - LEFT JOIN stock_out_line sol - ON sl.stockOutLineId = sol.id - AND sol.deleted = 0 - LEFT JOIN inventory_lot_line ill_out - ON sol.inventoryLotLineId = ill_out.id - AND ill_out.deleted = 0 - LEFT JOIN inventory_lot il_out - ON ill_out.inventoryLotId = il_out.id - AND il_out.deleted = 0 - - LEFT JOIN ( - SELECT - il.id AS lotId, - il.lotNo AS lotNo, - il.expiryDate AS expiryDate, - MAX(wh.code) AS storeLocation - FROM inventory_lot il - LEFT JOIN inventory_lot_line ill - ON ill.inventoryLotId = il.id - AND ill.deleted = 0 - LEFT JOIN warehouse wh - ON ill.warehouseId = wh.id - AND wh.deleted = 0 - GROUP BY - il.id, il.lotNo, il.expiryDate - ) lot - ON lot.lotId = COALESCE(il_in.id, il_out.id) - - LEFT JOIN items it - ON sl.itemId = it.id - AND it.deleted = 0 - LEFT JOIN item_uom iu - ON it.id = iu.itemId - AND iu.stockUnit = 1 - AND iu.deleted = 0 - LEFT JOIN uom_conversion uc - ON iu.uomId = uc.id - - -- 這兩個 alias 是為了配合你上面 storeLocationSql 的 wh_in / wh_out - LEFT JOIN inventory_lot_line ill_in - ON il_in.id = ill_in.inventoryLotId - AND ill_in.deleted = 0 - LEFT JOIN warehouse wh_in - ON ill_in.warehouseId = wh_in.id - AND wh_in.deleted = 0 - LEFT JOIN warehouse wh_out - ON ill_out.warehouseId = wh_out.id - AND wh_out.deleted = 0 - LEFT JOIN stock_out so - ON sol.stockOutId = so.id - AND so.deleted = 0 - LEFT JOIN pick_order_line pol - ON sol.pickOrderLineId = pol.id - AND pol.deleted = 0 - LEFT JOIN pick_order po_out - ON pol.poId = po_out.id - AND po_out.deleted = 0 - LEFT JOIN job_order jo_po - ON po_out.joId = jo_po.id - AND jo_po.deleted = 0 - LEFT JOIN delivery_order do - ON po_out.doId = do.id - AND do.deleted = 0 - LEFT JOIN stock_in si - ON sil.stockInId = si.id - AND si.deleted = 0 - LEFT JOIN job_order jo - ON sil.jobOrderId = jo.id - AND jo.deleted = 0 - LEFT JOIN purchase_order po - ON sil.purchaseOrderId = po.id - AND po.deleted = 0 - WHERE - sl.deleted = 0 - AND sl.itemCode IS NOT NULL - AND sl.itemCode <> '' - AND DATE(sl.date) <= :reportPeriodEnd - $stockCategorySql - $itemCodeSql - $storeLocationSql - AND lot.lotId IS NOT NULL -), -opening AS ( - SELECT - itemCode, - COALESCE(SUM(delta), 0) AS openingBeforeStart - FROM base - WHERE trnDateRaw < :reportPeriodStart - GROUP BY itemCode -), -period AS ( - SELECT - b.*, - COALESCE(o.openingBeforeStart, 0) AS openingBeforeStart - FROM base b - LEFT JOIN opening o - ON o.itemCode = b.itemCode - WHERE b.trnDateRaw BETWEEN :reportPeriodStart AND :reportPeriodEnd -) SELECT stockSubCategory, itemNo, @@ -231,7 +87,7 @@ SELECT unitOfMeasure, lotNo, expiryDate, - trnDate, + trnDate, CASE trnRefNoRaw WHEN 'OPEN' THEN '開倉' WHEN 'NOR' THEN '出入倉' @@ -255,39 +111,171 @@ SELECT reOrderLevel, reOrderQty, - -- jrxml 需要 String;負數括號顯示,無小數 - CASE WHEN COALESCE(inQty, 0) < 0 THEN CONCAT('(', FORMAT(-inQty, 0), ')') ELSE FORMAT(COALESCE(inQty, 0), 0) END AS stockIn, - CASE WHEN COALESCE(outQty, 0) < 0 THEN CONCAT('(', FORMAT(-outQty, 0), ')') ELSE FORMAT(COALESCE(outQty, 0), 0) END AS stockOut, - - -- 累計存量(跨 lot:只用 itemCode 分區) - CASE WHEN (openingBeforeStart + SUM(delta) OVER (PARTITION BY itemCode ORDER BY trnDateRaw, slId)) < 0 - THEN CONCAT('(', FORMAT(-(openingBeforeStart + SUM(delta) OVER (PARTITION BY itemCode ORDER BY trnDateRaw, slId)), 0), ')') - ELSE FORMAT(openingBeforeStart + SUM(delta) OVER (PARTITION BY itemCode ORDER BY trnDateRaw, slId), 0) END AS cumBalance, - - -- 累計期初存量 = 本行累計 - 本行異動 - CASE WHEN (openingBeforeStart + SUM(delta) OVER (PARTITION BY itemCode ORDER BY trnDateRaw, slId) - delta) < 0 - THEN CONCAT('(', FORMAT(-(openingBeforeStart + SUM(delta) OVER (PARTITION BY itemCode ORDER BY trnDateRaw, slId) - delta), 0), ')') - ELSE FORMAT(openingBeforeStart + SUM(delta) OVER (PARTITION BY itemCode ORDER BY trnDateRaw, slId) - delta, 0) END AS cumOpeningBal, - - -- footer totals(同樣輸出 String) - CASE WHEN COALESCE(openingBeforeStart, 0) < 0 THEN CONCAT('(', FORMAT(-openingBeforeStart, 0), ')') ELSE FORMAT(COALESCE(openingBeforeStart, 0), 0) END AS totalCumOpeningBal, - CASE WHEN SUM(COALESCE(inQty, 0)) OVER (PARTITION BY itemCode) < 0 THEN CONCAT('(', FORMAT(-SUM(COALESCE(inQty, 0)) OVER (PARTITION BY itemCode), 0), ')') ELSE FORMAT(SUM(COALESCE(inQty, 0)) OVER (PARTITION BY itemCode), 0) END AS totalStockIn, - CASE WHEN SUM(COALESCE(outQty, 0)) OVER (PARTITION BY itemCode) < 0 THEN CONCAT('(', FORMAT(-SUM(COALESCE(outQty, 0)) OVER (PARTITION BY itemCode), 0), ')') ELSE FORMAT(SUM(COALESCE(outQty, 0)) OVER (PARTITION BY itemCode), 0) END AS totalStockOut, - CASE WHEN (openingBeforeStart + SUM(inQty) OVER (PARTITION BY itemCode) - SUM(outQty) OVER (PARTITION BY itemCode)) < 0 - THEN CONCAT('(', FORMAT(-(openingBeforeStart + SUM(inQty) OVER (PARTITION BY itemCode) - SUM(outQty) OVER (PARTITION BY itemCode)), 0), ')') - ELSE FORMAT(openingBeforeStart + SUM(inQty) OVER (PARTITION BY itemCode) - SUM(outQty) OVER (PARTITION BY itemCode), 0) END AS totalCumBalance - -FROM period + CASE WHEN inQty < 0 THEN CONCAT('(', FORMAT(-inQty, 0), ')') ELSE FORMAT(inQty, 0) END AS stockIn, + CASE WHEN outQty < 0 THEN CONCAT('(', FORMAT(-outQty, 0), ')') ELSE FORMAT(outQty, 0) END AS stockOut, + + CASE WHEN cumOpeningBalRaw < 0 + THEN CONCAT('(', FORMAT(-cumOpeningBalRaw, 0), ')') + ELSE FORMAT(cumOpeningBalRaw, 0) END AS cumOpeningBal, + CASE WHEN bal < 0 + THEN CONCAT('(', FORMAT(-bal, 0), ')') + ELSE FORMAT(bal, 0) END AS cumBalance, + + CASE WHEN totalCumOpeningBalRaw < 0 + THEN CONCAT('(', FORMAT(-totalCumOpeningBalRaw, 0), ')') + ELSE FORMAT(totalCumOpeningBalRaw, 0) END AS totalCumOpeningBal, + CASE WHEN totalStockInRaw < 0 + THEN CONCAT('(', FORMAT(-totalStockInRaw, 0), ')') + ELSE FORMAT(totalStockInRaw, 0) END AS totalStockIn, + CASE WHEN totalStockOutRaw < 0 + THEN CONCAT('(', FORMAT(-totalStockOutRaw, 0), ')') + ELSE FORMAT(totalStockOutRaw, 0) END AS totalStockOut, + CASE WHEN totalCumBalanceRaw < 0 + THEN CONCAT('(', FORMAT(-totalCumBalanceRaw, 0), ')') + ELSE FORMAT(totalCumBalanceRaw, 0) END AS totalCumBalance + +FROM ( + SELECT + x.*, + FIRST_VALUE(cumOpeningBalRaw) OVER ( + PARTITION BY itemCode ORDER BY trnDateRaw, slId + ) AS totalCumOpeningBalRaw, + SUM(inQty) OVER (PARTITION BY itemCode) AS totalStockInRaw, + SUM(outQty) OVER (PARTITION BY itemCode) AS totalStockOutRaw, + FIRST_VALUE(bal) OVER ( + PARTITION BY itemCode ORDER BY trnDateRaw DESC, slId DESC + ) AS totalCumBalanceRaw + FROM ( + SELECT + sl.id AS slId, + DATE(sl.date) AS trnDateRaw, + DATE_FORMAT(sl.date, '%Y-%m-%d') AS trnDate, + CASE + WHEN UPPER(TRIM(COALESCE(sl.type, ''))) = 'TKE' THEN 'TKE' + WHEN UPPER(TRIM(COALESCE(sl.type, ''))) = 'ADJ' + AND (so.stockTakeId IS NOT NULL OR so.type = 'stockTake' OR si.stockTakeId IS NOT NULL) THEN 'TKE' + ELSE COALESCE(sl.type, '') + END AS trnRefNoRaw, + + sl.itemCode AS itemCode, + COALESCE(sl.inQty, 0) AS inQty, + COALESCE(sl.outQty, 0) AS outQty, + COALESCE(sl.balance, 0) AS bal, + COALESCE(sl.balance, 0) + - COALESCE(sl.inQty, 0) + + COALESCE(sl.outQty, 0) AS cumOpeningBalRaw, + + it.type AS stockSubCategory, + it.code AS itemNo, + it.name AS itemName, + uc.udfudesc AS unitOfMeasure, + + lot.lotNo AS lotNo, + COALESCE(DATE_FORMAT(lot.expiryDate, '%Y-%m-%d'), '') AS expiryDate, + lot.storeLocation AS storeLocation, + + COALESCE(TRIM(do.code), TRIM(jo_po.code), TRIM(jo.code), TRIM(po.code), '') AS orderRefNo, + COALESCE(TRIM(jo.code), '') AS jobOrderNo, + + '' AS openingBalance, + '' AS cumStockIn, + '' AS cumStockOut, + '' AS currentBalance, + '' AS lastInDate, + '' AS lastOutDate, + '' AS reOrderLevel, + '' AS reOrderQty + FROM stock_ledger sl + LEFT JOIN stock_in_line sil + ON sl.stockInLineId = sil.id + AND sil.deleted = 0 + LEFT JOIN inventory_lot il_in + ON sil.inventoryLotId = il_in.id + AND il_in.deleted = 0 + + LEFT JOIN stock_out_line sol + ON sl.stockOutLineId = sol.id + AND sol.deleted = 0 + LEFT JOIN inventory_lot_line ill_out + ON sol.inventoryLotLineId = ill_out.id + AND ill_out.deleted = 0 + LEFT JOIN inventory_lot il_out + ON ill_out.inventoryLotId = il_out.id + AND il_out.deleted = 0 + + LEFT JOIN ( + SELECT + il.id AS lotId, + il.lotNo AS lotNo, + il.expiryDate AS expiryDate, + MAX(wh.code) AS storeLocation + FROM inventory_lot il + LEFT JOIN inventory_lot_line ill + ON ill.inventoryLotId = il.id + AND ill.deleted = 0 + LEFT JOIN warehouse wh + ON ill.warehouseId = wh.id + AND wh.deleted = 0 + WHERE il.deleted = 0 + GROUP BY + il.id, il.lotNo, il.expiryDate + ) lot + ON lot.lotId = COALESCE(il_in.id, il_out.id) + + LEFT JOIN items it + ON sl.itemId = it.id + AND it.deleted = 0 + LEFT JOIN item_uom iu + ON it.id = iu.itemId + AND iu.stockUnit = 1 + AND iu.deleted = 0 + LEFT JOIN uom_conversion uc + ON iu.uomId = uc.id + + LEFT JOIN stock_out so + ON sol.stockOutId = so.id + AND so.deleted = 0 + LEFT JOIN pick_order_line pol + ON sol.pickOrderLineId = pol.id + AND pol.deleted = 0 + LEFT JOIN pick_order po_out + ON pol.poId = po_out.id + AND po_out.deleted = 0 + LEFT JOIN job_order jo_po + ON po_out.joId = jo_po.id + AND jo_po.deleted = 0 + LEFT JOIN delivery_order do + ON po_out.doId = do.id + AND do.deleted = 0 + LEFT JOIN stock_in si + ON sil.stockInId = si.id + AND si.deleted = 0 + LEFT JOIN job_order jo + ON sil.jobOrderId = jo.id + AND jo.deleted = 0 + LEFT JOIN purchase_order po + ON sil.purchaseOrderId = po.id + AND po.deleted = 0 + WHERE + sl.deleted = 0 + AND sl.itemCode IS NOT NULL + AND sl.itemCode <> '' + AND sl.date >= :reportPeriodStart + AND sl.date < :reportPeriodEndExclusive + $stockCategorySql + $itemCodeSql + $storeLocationSql + AND lot.lotId IS NOT NULL + ) x +) y ORDER BY itemNo, trnDateRaw, slId, lotNo """.trimIndent() - - val result = jdbcDao.queryForList(sql, args) - return result + return jdbcDao.queryForList(sql, args) } /** LIKE 多值工具方法 */ @@ -327,4 +315,4 @@ ORDER BY } return "AND (${conditions.joinToString(" OR ")})" } -} \ No newline at end of file +} diff --git a/src/main/java/com/ffii/fpsms/modules/report/service/StockLotOnhandReportService.kt b/src/main/java/com/ffii/fpsms/modules/report/service/StockLotOnhandReportService.kt new file mode 100644 index 0000000..aecdd1f --- /dev/null +++ b/src/main/java/com/ffii/fpsms/modules/report/service/StockLotOnhandReportService.kt @@ -0,0 +1,419 @@ +package com.ffii.fpsms.modules.report.service + +import com.ffii.core.support.JdbcDao +import org.springframework.stereotype.Service +import java.time.LocalDate +import java.time.format.DateTimeFormatter + +/** + * 庫存批次現況(Stock Balance):永遠今天。 + * 現存讀 [inventory_lot_line](available、未過期、in-out > 0)。 + * 最後異動:有 inventoryLotLineId 的帳本用 MAX(id);缺的比 SIL/SOL 時間。 + * 單位均價/庫存總價值只填 root PO 為 PP/PF 的批(TRF 往回走);其他來源空白。 + */ +@Service +open class StockLotOnhandReportService( + private val jdbcDao: JdbcDao, +) { + private val dateFmt = DateTimeFormatter.ofPattern("yyyy-MM-dd") + + companion object { + /** Same TRF walk as stock-take variance: root stock-in / PO after transfers. */ + private const val LOT_ROOT_ORIGIN_CTE_SQL = """ +lot_trace AS ( + SELECT + il.id AS lotId, + il.stockInLineId AS silId, + 0 AS depth + FROM inventory_lot il + WHERE il.deleted = 0 + AND il.stockInLineId IS NOT NULL + UNION ALL + SELECT + lt.lotId AS lotId, + il_src.stockInLineId AS silId, + lt.depth + 1 AS depth + FROM lot_trace lt + INNER JOIN stock_in_line sil + ON sil.id = lt.silId + AND sil.deleted = 0 + INNER JOIN stock_transfer_record tr + ON tr.id = sil.stockTransferId + AND tr.deleted = 0 + INNER JOIN stock_out_line sol + ON sol.id = tr.stockOutLineId + AND sol.deleted = 0 + INNER JOIN inventory_lot_line ill_src + ON ill_src.id = sol.inventoryLotLineId + AND ill_src.deleted = 0 + INNER JOIN inventory_lot il_src + ON il_src.id = ill_src.inventoryLotId + AND il_src.deleted = 0 + WHERE lt.depth < 8 + AND il_src.stockInLineId IS NOT NULL + AND ( + UPPER(TRIM(COALESCE(sil.type, ''))) = 'TRF' + OR sil.stockTransferId IS NOT NULL + ) +), +lot_root_origin AS ( + SELECT + lotId, + silId AS rootSilId + FROM ( + SELECT + lotId, + silId, + ROW_NUMBER() OVER (PARTITION BY lotId ORDER BY depth DESC) AS rn + FROM lot_trace + ) t + WHERE t.rn = 1 +)""" + + private const val ROOT_STOCK_IN_JOIN_SQL = """ + LEFT JOIN lot_root_origin lro + ON lro.lotId = il.id + LEFT JOIN stock_in_line root_sil + ON root_sil.id = lro.rootSilId AND root_sil.deleted = 0 + LEFT JOIN purchase_order root_po + ON root_po.id = root_sil.purchaseOrderId AND root_po.deleted = 0 +""" + } + + data class SearchResult( + val rows: List>, + val stockDate: String, + ) + + /** FP-MTMS Version Checklist | Functions Ref. No. 68 | v1.0.0 | 2026-08-31 */ + fun search( + itemCode: String?, + storeId: String?, + warehouse: String?, + area: String?, + slot: String?, + lotNo: String?, + stockTakeSectionDescription: String?, + lotOrigin: String?, + ): SearchResult { + val asOfDateStr = LocalDate.now().format(dateFmt) + + val masterArgs = HashMap() + val itemCodeSqlIt = buildMultiValueLikeClause(itemCode, "it.code", "itemCodeIt", masterArgs) + val lotNoSql = buildMultiValueLikeClause(lotNo, "il.lotNo", "lotNo", masterArgs) + val storeIdSql = if (!storeId.isNullOrBlank() && storeId.trim() != "All") { + masterArgs["storeId"] = storeId.trim() + "AND REPLACE(COALESCE(wh.store_id, ''), '/', '') = REPLACE(:storeId, '/', '')" + } else { + "" + } + val warehouseSql = if (!warehouse.isNullOrBlank() && warehouse.trim() != "All") { + buildMultiValueLikeClause(warehouse, "wh.warehouse", "warehousePart", masterArgs) + } else { + "" + } + val areaSql = if (!area.isNullOrBlank() && area.trim() != "All") { + buildMultiValueLikeClause(area, "wh.area", "areaPart", masterArgs) + } else { + "" + } + val slotSql = if (!slot.isNullOrBlank() && slot.trim() != "All") { + buildMultiValueLikeClause(slot, "wh.slot", "slotPart", masterArgs) + } else { + "" + } + val sectionDescSql = if ( + !stockTakeSectionDescription.isNullOrBlank() && + stockTakeSectionDescription.trim() != "All" + ) { + masterArgs["stockTakeSectionDescription"] = stockTakeSectionDescription.trim() + "AND COALESCE(wh.stockTakeSectionDescription, '') = :stockTakeSectionDescription" + } else { + "" + } + val lotOriginFilterSql = buildLotOriginFilterSql(lotOrigin) + val originJoinSql = ROOT_STOCK_IN_JOIN_SQL + val ctePrefix = "WITH RECURSIVE\n$LOT_ROOT_ORIGIN_CTE_SQL\n" + + val liveLots = jdbcDao.queryForList( + """ + ${ctePrefix}SELECT + ill.id AS inventoryLotLineId, + COALESCE(it.code, '') AS itemNo, + COALESCE(it.name, '') AS itemName, + COALESCE(uc.udfudesc, uc.code, '') AS unitOfMeasure, + COALESCE(il.lotNo, '') AS lotNo, + COALESCE(DATE_FORMAT(il.expiryDate, '%Y-%m-%d'), '') AS expiryDate, + COALESCE(wh.store_id, '') AS storeId, + COALESCE(wh.warehouse, '') AS warehousePart, + COALESCE(wh.area, '') AS areaPart, + COALESCE(wh.slot, '') AS slotPart, + (COALESCE(ill.inQty, 0) - COALESCE(ill.outQty, 0)) AS lotQtyRaw, + COALESCE(CAST(NULLIF(it.AverageUnitPrice, '') AS DECIMAL(14, 4)), 0) AS avgUnitPriceRaw, + UPPER(LEFT(TRIM(COALESCE(root_po.code, '')), 2)) AS rootPoPrefix + FROM inventory_lot_line ill + INNER JOIN inventory_lot il + ON il.id = ill.inventoryLotId AND il.deleted = 0 + INNER JOIN items it + ON it.id = il.itemId AND it.deleted = 0 + INNER JOIN warehouse wh + ON wh.id = ill.warehouseId AND wh.deleted = 0 + LEFT JOIN item_uom iu + ON iu.id = ill.stockItemUomId AND iu.deleted = 0 + LEFT JOIN uom_conversion uc + ON uc.id = iu.uomId + $originJoinSql + WHERE ill.deleted = 0 + AND it.code IS NOT NULL AND it.code <> '' + AND LOWER(COALESCE(ill.status, '')) = 'available' + AND (il.expiryDate IS NULL OR il.expiryDate >= CURRENT_DATE) + AND (COALESCE(ill.inQty, 0) - COALESCE(ill.outQty, 0)) > 0 + $itemCodeSqlIt + $storeIdSql + $warehouseSql + $areaSql + $slotSql + $sectionDescSql + $lotNoSql + $lotOriginFilterSql + """.trimIndent(), + masterArgs, + ) + + val lotIds = liveLots.map { toLong(it["inventoryLotLineId"]) }.filter { it > 0 } + val lastTrnByLot = HashMap>(lotIds.size * 2) + loadLastTrnFromLedger(lotIds, lastTrnByLot) + val missing = lotIds.distinct().filter { it !in lastTrnByLot } + if (missing.isNotEmpty()) { + val silSolHits = HashMap(missing.size * 2) + fillLastTrnFromLotHeaderSil(missing, silSolHits) + fillLastTrnFromSilLine(missing, silSolHits) + fillLastTrnFromSol(missing, silSolHits) + for ((lotId, hit) in silSolHits) { + lastTrnByLot[lotId] = hit.date to hit.kind + } + } + + val rows = assembleRows(liveLots, lastTrnByLot) + return SearchResult(rows = rows, stockDate = asOfDateStr) + } + + private data class SilSolHit(val ts: String, val date: String, val kind: String) + + /** FP-MTMS Version Checklist | Functions Ref. No. 68 | v1.0.0 | 2026-08-31 */ + private fun loadLastTrnFromLedger(lotIds: List, out: MutableMap>) { + if (lotIds.isEmpty()) return + // idx_ledger_lot_date_id is (lot, date, id) — bad for MAX(id); force the lot-only index + // (InnoDB secondary includes PK id, so MAX(id) per lot is an index tail lookup). + for (chunk in lotIds.distinct().chunked(2000)) { + val rows = jdbcDao.queryForList( + """ + SELECT + sl.inventoryLotLineId, + DATE_FORMAT(sl.date, '%Y-%m-%d') AS lastTrnDate, + COALESCE(sl.inQty, 0) AS inQty, + COALESCE(sl.outQty, 0) AS outQty + FROM stock_ledger sl + INNER JOIN ( + SELECT inventoryLotLineId, MAX(id) AS maxId + FROM stock_ledger FORCE INDEX (idx_ledger_inventoryLotLineId) + WHERE deleted = 0 + AND inventoryLotLineId IN (:lotIds) + GROUP BY inventoryLotLineId + ) t ON t.maxId = sl.id + """.trimIndent(), + mapOf("lotIds" to chunk), + ) + for (r in rows) { + val lotId = toLong(r["inventoryLotLineId"]) + if (lotId <= 0) continue + val date = r["lastTrnDate"]?.toString().orEmpty() + out[lotId] = date to lastTrnType(toDouble(r["inQty"]), toDouble(r["outQty"])) + } + } + } + + /** FP-MTMS Version Checklist | Functions Ref. No. 68 | v1.0.0 | 2026-08-31 */ + private fun fillLastTrnFromLotHeaderSil(missing: List, hits: MutableMap): Int { + var n = 0 + for (chunk in missing.chunked(800)) { + val rows = jdbcDao.queryForList( + """ + SELECT + ill.id AS lotLineId, + DATE_FORMAT(COALESCE(sil.receiptDate, sil.modified, sil.created), '%Y-%m-%d %H:%i:%s') AS lastTs, + DATE_FORMAT(COALESCE(sil.receiptDate, sil.modified, sil.created), '%Y-%m-%d') AS lastTrnDate + FROM inventory_lot_line ill + INNER JOIN inventory_lot il + ON il.id = ill.inventoryLotId AND il.deleted = 0 + INNER JOIN stock_in_line sil + ON sil.id = il.stockInLineId AND sil.deleted = 0 + WHERE ill.deleted = 0 + AND ill.id IN (:lotIds) + """.trimIndent(), + mapOf("lotIds" to chunk), + ) + n += rows.size + applySilSolHits(rows, hits, "入庫") + } + return n + } + + /** FP-MTMS Version Checklist | Functions Ref. No. 68 | v1.0.0 | 2026-08-31 */ + private fun fillLastTrnFromSilLine(missing: List, hits: MutableMap): Int { + var n = 0 + for (chunk in missing.chunked(800)) { + val rows = jdbcDao.queryForList( + """ + SELECT + sil.inventoryLotLineId AS lotLineId, + DATE_FORMAT(COALESCE(sil.receiptDate, sil.modified, sil.created), '%Y-%m-%d %H:%i:%s') AS lastTs, + DATE_FORMAT(COALESCE(sil.receiptDate, sil.modified, sil.created), '%Y-%m-%d') AS lastTrnDate + FROM stock_in_line sil + WHERE sil.deleted = 0 + AND sil.inventoryLotLineId IN (:lotIds) + """.trimIndent(), + mapOf("lotIds" to chunk), + ) + n += rows.size + applySilSolHits(rows, hits, "入庫") + } + return n + } + + /** FP-MTMS Version Checklist | Functions Ref. No. 68 | v1.0.0 | 2026-08-31 */ + private fun fillLastTrnFromSol(missing: List, hits: MutableMap): Int { + var n = 0 + for (chunk in missing.chunked(800)) { + val rows = jdbcDao.queryForList( + """ + SELECT + sol.inventoryLotLineId AS lotLineId, + DATE_FORMAT(COALESCE(sol.pickTime, sol.modified, sol.created), '%Y-%m-%d %H:%i:%s') AS lastTs, + DATE_FORMAT(COALESCE(sol.pickTime, sol.modified, sol.created), '%Y-%m-%d') AS lastTrnDate + FROM stock_out_line sol + WHERE sol.deleted = 0 + AND sol.inventoryLotLineId IN (:lotIds) + """.trimIndent(), + mapOf("lotIds" to chunk), + ) + n += rows.size + applySilSolHits(rows, hits, "出庫") + } + return n + } + + private fun applySilSolHits(rows: List>, hits: MutableMap, kind: String) { + for (r in rows) { + val lotId = toLong(r["lotLineId"]) + val ts = r["lastTs"]?.toString().orEmpty() + if (lotId <= 0 || ts.isBlank()) continue + val date = r["lastTrnDate"]?.toString().orEmpty() + val prev = hits[lotId] + val newer = prev == null || + ts > prev.ts || + (ts == prev.ts && kind == "出庫" && prev.kind != "出庫") + if (newer) hits[lotId] = SilSolHit(ts, date, kind) + } + } + + private fun lastTrnType(inQty: Double, outQty: Double): String = + when { + outQty > 0 && inQty <= 0 -> "出庫" + inQty > 0 && outQty <= 0 -> "入庫" + inQty > 0 && outQty > 0 -> if (outQty >= inQty) "出庫" else "入庫" + else -> "" + } + + /** FP-MTMS Version Checklist | Functions Ref. No. 68 | v1.0.0 | 2026-08-31 */ + private fun assembleRows( + liveLots: List>, + lastTrnByLot: Map>, + ): List> { + val tot = HashMap() + for (r in liveLots) { + val key = "${r["itemNo"]}|${r["unitOfMeasure"]}" + tot[key] = (tot[key] ?: 0.0) + toDouble(r["lotQtyRaw"]) + } + val out = ArrayList>(liveLots.size) + for (r in liveLots) { + val lotId = toLong(r["inventoryLotLineId"]) + val lotQty = toDouble(r["lotQtyRaw"]) + val avg = toDouble(r["avgUnitPriceRaw"]) + val key = "${r["itemNo"]}|${r["unitOfMeasure"]}" + val last = lastTrnByLot[lotId] + val origin = r["rootPoPrefix"]?.toString().orEmpty().uppercase() + val showPrice = origin == "PP" || origin == "PF" + val row = HashMap(22) + row["inventoryLotLineId"] = lotId.toString() + row["itemNo"] = r["itemNo"] ?: "" + row["itemName"] = r["itemName"] ?: "" + row["unitOfMeasure"] = r["unitOfMeasure"] ?: "" + row["lotNo"] = r["lotNo"] ?: "" + row["expiryDate"] = r["expiryDate"] ?: "" + row["storeId"] = r["storeId"] ?: "" + row["warehousePart"] = r["warehousePart"] ?: "" + row["areaPart"] = r["areaPart"] ?: "" + row["slotPart"] = r["slotPart"] ?: "" + row["lotQtyRaw"] = lotQty + row["totalQtyRaw"] = tot[key] ?: 0.0 + row["avgUnitPriceRaw"] = if (showPrice) avg else "" + row["stockValueRaw"] = if (showPrice) avg * lotQty else "" + row["lastTrnDate"] = last?.first ?: "" + row["lastTrnType"] = last?.second ?: "" + out.add(row) + } + out.sortWith( + compareBy> { it["itemNo"]?.toString().orEmpty() } + .thenBy { it["unitOfMeasure"]?.toString().orEmpty() } + .thenBy { it["lotNo"]?.toString().orEmpty() } + .thenBy { it["storeId"]?.toString().orEmpty() } + .thenBy { it["warehousePart"]?.toString().orEmpty() } + .thenBy { it["areaPart"]?.toString().orEmpty() } + .thenBy { it["slotPart"]?.toString().orEmpty() } + .thenBy { it["inventoryLotLineId"]?.toString().orEmpty() }, + ) + return out + } + + private fun toDouble(v: Any?): Double { + if (v == null) return 0.0 + if (v is Number) return v.toDouble() + return v.toString().replace(",", "").toDoubleOrNull() ?: 0.0 + } + + private fun toLong(v: Any?): Long { + if (v == null) return 0L + if (v is Number) return v.toLong() + return v.toString().toLongOrNull() ?: 0L + } + + /** PP/PF = root PO code prefix after TRF walk; other = not PP/PF (ADJ, TRF of other origin, JO, OPEN, …). */ + private fun buildLotOriginFilterSql(lotOrigin: String?): String { + val v = lotOrigin?.trim().orEmpty() + if (v.isBlank() || v.equals("All", ignoreCase = true)) return "" + return when (v.lowercase()) { + "pp" -> "AND UPPER(LEFT(TRIM(COALESCE(root_po.code, '')), 2)) = 'PP'" + "pf" -> "AND UPPER(LEFT(TRIM(COALESCE(root_po.code, '')), 2)) = 'PF'" + "other" -> "AND UPPER(LEFT(TRIM(COALESCE(root_po.code, '')), 2)) NOT IN ('PP', 'PF')" + else -> "" + } + } + + private fun buildMultiValueLikeClause( + paramValue: String?, + columnName: String, + paramPrefix: String, + args: MutableMap, + ): String { + if (paramValue.isNullOrBlank()) return "" + val values = paramValue.split(",").map { it.trim() }.filter { it.isNotBlank() } + if (values.isEmpty()) return "" + val conditions = values.mapIndexed { index, value -> + val paramName = "${paramPrefix}_$index" + args[paramName] = "%$value%" + "$columnName LIKE :$paramName" + } + return "AND (${conditions.joinToString(" OR ")})" + } +} diff --git a/src/main/java/com/ffii/fpsms/modules/report/web/DoInventoryUomMismatchReportController.kt b/src/main/java/com/ffii/fpsms/modules/report/web/DoInventoryUomMismatchReportController.kt new file mode 100644 index 0000000..a646fae --- /dev/null +++ b/src/main/java/com/ffii/fpsms/modules/report/web/DoInventoryUomMismatchReportController.kt @@ -0,0 +1,116 @@ +package com.ffii.fpsms.modules.report.web + +import com.ffii.fpsms.modules.report.service.DoInventoryUomMismatchReportService +import org.apache.poi.ss.usermodel.BorderStyle +import org.apache.poi.ss.usermodel.FillPatternType +import org.apache.poi.ss.usermodel.HorizontalAlignment +import org.apache.poi.ss.usermodel.IndexedColors +import org.apache.poi.ss.usermodel.VerticalAlignment +import org.apache.poi.ss.util.WorkbookUtil +import org.apache.poi.xssf.usermodel.XSSFWorkbook +import org.springframework.http.HttpHeaders +import org.springframework.http.HttpStatus +import org.springframework.http.MediaType +import org.springframework.http.ResponseEntity +import org.springframework.web.bind.annotation.GetMapping +import org.springframework.web.bind.annotation.RequestMapping +import org.springframework.web.bind.annotation.RequestParam +import org.springframework.web.bind.annotation.RestController +import java.io.ByteArrayOutputStream + +@RestController +@RequestMapping("/report") +class DoInventoryUomMismatchReportController( + private val doInventoryUomMismatchReportService: DoInventoryUomMismatchReportService, +) { + /** FP-MTMS Version Checklist | Functions Ref. No. 60 | v1.0.1 | 2026-08-11 */ + @GetMapping("/print-do-inventory-uom-mismatch-excel") + fun exportExcel( + @RequestParam(required = false) deliveryDate: String?, + @RequestParam(required = false) storeId: String?, + ): ResponseEntity { + val rows = try { + doInventoryUomMismatchReportService.searchDoInventoryUomMismatch(deliveryDate, storeId) + } catch (ex: IllegalArgumentException) { + return ResponseEntity.status(HttpStatus.BAD_REQUEST).build() + } + + val bytes = buildExcel(rows) + val headers = HttpHeaders().apply { + contentType = MediaType.parseMediaType( + "application/vnd.openxmlformats-officedocument.spreadsheetml.sheet", + ) + // ASCII only — Tomcat rejects non-Latin-1 in Content-Disposition + setContentDispositionFormData("attachment", "DoInventoryUomMismatchReport.xlsx") + } + return ResponseEntity(bytes, headers, HttpStatus.OK) + } + + private fun buildExcel(rows: List>): ByteArray { + val workbook = XSSFWorkbook() + val sheet = workbook.createSheet(WorkbookUtil.createSafeSheetName("DO_UOM_mismatch")) + val headerStyle = workbook.createCellStyle().apply { + alignment = HorizontalAlignment.CENTER + verticalAlignment = VerticalAlignment.CENTER + fillForegroundColor = IndexedColors.GREY_25_PERCENT.index + fillPattern = FillPatternType.SOLID_FOREGROUND + borderTop = BorderStyle.THIN + borderBottom = BorderStyle.THIN + borderLeft = BorderStyle.THIN + borderRight = BorderStyle.THIN + val font = workbook.createFont().apply { bold = true } + setFont(font) + } + val textStyle = workbook.createCellStyle().apply { + borderTop = BorderStyle.THIN + borderBottom = BorderStyle.THIN + borderLeft = BorderStyle.THIN + borderRight = BorderStyle.THIN + } + + val columns = listOf( + "deliveryDate" to "預計送貨日期", + "doCode" to "送貨單號", + "supplierCode" to "供應商編號", + "supplierName" to "供應商名稱", + "itemCode" to "貨品編號", + "itemName" to "貨品名稱", + "doQty" to "送貨單數量", + "doUom" to "送貨單單位", + "mismatchLotNo" to "不符批號", + "mismatchLotQty" to "不符批數量", + "mismatchLotUom" to "不符批單位", + ) + + var r = 0 + val headerRow = sheet.createRow(r++) + columns.forEachIndexed { i, (_, label) -> + headerRow.createCell(i).apply { + setCellValue(label) + cellStyle = headerStyle + } + } + + for (row in rows) { + val excelRow = sheet.createRow(r++) + columns.forEachIndexed { i, (key, _) -> + val v = row[key] + val cell = excelRow.createCell(i) + cell.cellStyle = textStyle + when (v) { + null -> cell.setCellValue("") + is Number -> cell.setCellValue(v.toDouble()) + else -> cell.setCellValue(v.toString()) + } + } + } + + val widths = intArrayOf(14, 16, 12, 18, 14, 28, 12, 14, 18, 12, 14) + widths.forEachIndexed { i, w -> sheet.setColumnWidth(i, w * 256) } + + val out = ByteArrayOutputStream() + workbook.write(out) + workbook.close() + return out.toByteArray() + } +} diff --git a/src/main/java/com/ffii/fpsms/modules/report/web/ItemQcFailReportController.kt b/src/main/java/com/ffii/fpsms/modules/report/web/ItemQcFailReportController.kt index 8565087..73902a8 100644 --- a/src/main/java/com/ffii/fpsms/modules/report/web/ItemQcFailReportController.kt +++ b/src/main/java/com/ffii/fpsms/modules/report/web/ItemQcFailReportController.kt @@ -21,6 +21,7 @@ class ItemQcFailReportController( private val itemQcFailReportService: ItemQcFailReportService, ) { + /** FP-MTMS Version Checklist | Functions Ref. No. 64 | v1.0.0 | 2026-08-11 */ @GetMapping("/print-item-qc-fail") fun generateItemQcFailReport( @RequestParam(required = false) stockCategory: String?, @@ -29,7 +30,7 @@ class ItemQcFailReportController( @RequestParam(required = false) lastInDateEnd: String?, @RequestParam(required = false) qcType: String?, @RequestParam(required = false, defaultValue = "true") includeMeasurable: String?, - @RequestParam(required = false, defaultValue = "false") includeOther: String?, + @RequestParam(required = false, defaultValue = "true") includeOther: String?, @RequestParam(required = false, defaultValue = "all") measurableScope: String?, ): ResponseEntity { val dbData = itemQcFailReportService.searchItemQcFailReport( @@ -70,6 +71,7 @@ class ItemQcFailReportController( return ResponseEntity(pdfBytes, headers, HttpStatus.OK) } + /** FP-MTMS Version Checklist | Functions Ref. No. 64 | v1.0.0 | 2026-08-11 */ @GetMapping("/print-item-qc-fail-excel") fun exportItemQcFailReportExcel( @RequestParam(required = false) stockCategory: String?, @@ -78,7 +80,7 @@ class ItemQcFailReportController( @RequestParam(required = false) lastInDateEnd: String?, @RequestParam(required = false) qcType: String?, @RequestParam(required = false, defaultValue = "true") includeMeasurable: String?, - @RequestParam(required = false, defaultValue = "false") includeOther: String?, + @RequestParam(required = false, defaultValue = "true") includeOther: String?, @RequestParam(required = false, defaultValue = "all") measurableScope: String?, ): ResponseEntity { val dbData = itemQcFailReportService.searchItemQcFailReport( @@ -297,7 +299,7 @@ class ItemQcFailReportController( "不合格數量", "實測值", "備註", - "訂單/工單" + "訂單/工單", ) run { @@ -321,12 +323,16 @@ class ItemQcFailReportController( fun writeNumber(col: Int, value: Any?) { val raw = value?.toString()?.trim() ?: "" - val cleaned = raw.removeSuffix(".") + // SQL FORMAT() may emit thousand separators (e.g. "1,021") — strip before parse. + val cleaned = raw + .replace(",", "") + .replace(" ", "") + .removeSuffix(".") val bd = cleaned.toBigDecimalOrNull() val cell = row.createCell(col) if (bd == null) { - cell.setCellValue(cleaned) + cell.setCellValue(raw) cell.cellStyle = textStyle } else { val stripped = bd.stripTrailingZeros() diff --git a/src/main/java/com/ffii/fpsms/modules/report/web/StockLedgerReportController.kt b/src/main/java/com/ffii/fpsms/modules/report/web/StockLedgerReportController.kt index e7baa83..5345032 100644 --- a/src/main/java/com/ffii/fpsms/modules/report/web/StockLedgerReportController.kt +++ b/src/main/java/com/ffii/fpsms/modules/report/web/StockLedgerReportController.kt @@ -14,10 +14,10 @@ import org.apache.poi.ss.usermodel.HorizontalAlignment import org.apache.poi.ss.usermodel.IndexedColors import org.apache.poi.ss.usermodel.Row import org.apache.poi.ss.usermodel.VerticalAlignment +import org.apache.poi.ss.usermodel.Workbook import org.apache.poi.ss.util.CellRangeAddress import org.apache.poi.ss.util.WorkbookUtil -import org.apache.poi.xssf.usermodel.XSSFCellStyle -import org.apache.poi.xssf.usermodel.XSSFWorkbook +import org.apache.poi.xssf.streaming.SXSSFWorkbook import java.io.ByteArrayOutputStream @RestController @@ -27,66 +27,66 @@ class StockLedgerReportController( private val stockLedgerReportService: StockLedgerReportService, ) { private data class ExcelStyles( - val title: XSSFCellStyle, - val subtitle: XSSFCellStyle, - val header: XSSFCellStyle, - val text: XSSFCellStyle, - val center: XSSFCellStyle, - val int: XSSFCellStyle, - val dash: XSSFCellStyle, - val sumQty: XSSFCellStyle, - val sumLabel: XSSFCellStyle, - val sumEmpty: XSSFCellStyle, - val sumHidden: XSSFCellStyle, + val title: CellStyle, + val subtitle: CellStyle, + val header: CellStyle, + val text: CellStyle, + val center: CellStyle, + val int: CellStyle, + val dash: CellStyle, + val sumQty: CellStyle, + val sumLabel: CellStyle, + val sumEmpty: CellStyle, + val sumHidden: CellStyle, ) + /** FP-MTMS Version Checklist | Functions Ref. No. 65 | v1.0.0 | 2026-08-11 */ @GetMapping("/print-stock-ledger") -fun generateStockLedgerReport( - @RequestParam(required = false) stockCategory: String?, - @RequestParam(required = false) itemCode: String?, - @RequestParam(required = false) storeLocation: String?, - // URL 參數名仍然是 lastInDateStart / lastInDateEnd - @RequestParam(name = "lastInDateStart", required = false) reportPeriodStart: String?, - @RequestParam(name = "lastInDateEnd", required = false) reportPeriodEnd: String?, -): ResponseEntity { - val parameters = mutableMapOf() - - parameters["stockCategory"] = stockCategory ?: "All" - parameters["stockSubCategory"] = stockCategory ?: "All" - parameters["itemNo"] = itemCode ?: "All" - parameters["year"] = LocalDate.now().year.toString() - parameters["reportDate"] = LocalDate.now().format(DateTimeFormatter.ofPattern("yyyy-MM-dd")) - parameters["reportTime"] = LocalTime.now().format(DateTimeFormatter.ofPattern("HH:mm:ss")) + fun generateStockLedgerReport( + @RequestParam(required = false) stockCategory: String?, + @RequestParam(required = false) itemCode: String?, + @RequestParam(required = false) storeLocation: String?, + @RequestParam(name = "lastInDateStart", required = false) reportPeriodStart: String?, + @RequestParam(name = "lastInDateEnd", required = false) reportPeriodEnd: String?, + ): ResponseEntity { + val parameters = mutableMapOf() - parameters["storeLocation"] = storeLocation ?: "" - parameters["balanceFilterStart"] = "" - parameters["balanceFilterEnd"] = "" - parameters["reportPeriodStart"] = reportPeriodStart ?: "" - parameters["reportPeriodEnd"] = reportPeriodEnd ?: "" + parameters["stockCategory"] = stockCategory ?: "All" + parameters["stockSubCategory"] = stockCategory ?: "All" + parameters["itemNo"] = itemCode ?: "All" + parameters["year"] = LocalDate.now().year.toString() + parameters["reportDate"] = LocalDate.now().format(DateTimeFormatter.ofPattern("yyyy-MM-dd")) + parameters["reportTime"] = LocalTime.now().format(DateTimeFormatter.ofPattern("HH:mm:ss")) + parameters["storeLocation"] = storeLocation ?: "" + parameters["balanceFilterStart"] = "" + parameters["balanceFilterEnd"] = "" + parameters["reportPeriodStart"] = reportPeriodStart ?: "" + parameters["reportPeriodEnd"] = reportPeriodEnd ?: "" - val dbData = stockLedgerReportService.searchStockLedgerReport( - stockCategory = stockCategory, - itemCode = itemCode, - storeLocation = storeLocation, - reportPeriodStart = reportPeriodStart, - reportPeriodEnd = reportPeriodEnd, - ) + val dbData = stockLedgerReportService.searchStockLedgerReport( + stockCategory = stockCategory, + itemCode = itemCode, + storeLocation = storeLocation, + reportPeriodStart = reportPeriodStart, + reportPeriodEnd = reportPeriodEnd, + ) - val pdfBytes = reportService.createPdfResponse( - "/jasper/StockLedgarReport.jrxml", - parameters, - dbData - ) + val pdfBytes = reportService.createPdfResponse( + "/jasper/StockLedgarReport.jrxml", + parameters, + dbData, + ) - val headers = HttpHeaders().apply { - contentType = MediaType.APPLICATION_PDF - setContentDispositionFormData("attachment", "StockLedgerReport.pdf") - set("filename", "StockLedgerReport.pdf") + val headers = HttpHeaders().apply { + contentType = MediaType.APPLICATION_PDF + setContentDispositionFormData("attachment", "StockLedgerReport.pdf") + set("filename", "StockLedgerReport.pdf") + } + return ResponseEntity(pdfBytes, headers, HttpStatus.OK) } - return ResponseEntity(pdfBytes, headers, HttpStatus.OK) -} + /** FP-MTMS Version Checklist | Functions Ref. No. 65 | v1.0.0 | 2026-08-11 */ @GetMapping("/print-stock-ledger-excel") fun exportStockLedgerReportExcel( @RequestParam(required = false) stockCategory: String?, @@ -119,8 +119,8 @@ fun generateStockLedgerReport( return ResponseEntity(excelBytes, headers, HttpStatus.OK) } - private fun createStyles(workbook: XSSFWorkbook): ExcelStyles { - val titleStyle = (workbook.createCellStyle() as XSSFCellStyle).apply { + private fun createStyles(workbook: Workbook): ExcelStyles { + val titleStyle = workbook.createCellStyle().apply { alignment = HorizontalAlignment.CENTER verticalAlignment = VerticalAlignment.CENTER val font = workbook.createFont().apply { @@ -129,7 +129,7 @@ fun generateStockLedgerReport( } setFont(font) } - val subtitleStyle = (workbook.createCellStyle() as XSSFCellStyle).apply { + val subtitleStyle = workbook.createCellStyle().apply { alignment = HorizontalAlignment.LEFT verticalAlignment = VerticalAlignment.CENTER val font = workbook.createFont().apply { @@ -138,7 +138,7 @@ fun generateStockLedgerReport( } setFont(font) } - val headerStyle = (workbook.createCellStyle() as XSSFCellStyle).apply { + val headerStyle = workbook.createCellStyle().apply { alignment = HorizontalAlignment.CENTER verticalAlignment = VerticalAlignment.CENTER fillForegroundColor = IndexedColors.GREY_25_PERCENT.index @@ -150,7 +150,7 @@ fun generateStockLedgerReport( val font = workbook.createFont().apply { bold = true } setFont(font) } - val textStyle = (workbook.createCellStyle() as XSSFCellStyle).apply { + val textStyle = workbook.createCellStyle().apply { alignment = HorizontalAlignment.LEFT verticalAlignment = VerticalAlignment.CENTER borderTop = BorderStyle.THIN @@ -158,7 +158,7 @@ fun generateStockLedgerReport( borderLeft = BorderStyle.THIN borderRight = BorderStyle.THIN } - val centerStyle = (workbook.createCellStyle() as XSSFCellStyle).apply { + val centerStyle = workbook.createCellStyle().apply { alignment = HorizontalAlignment.CENTER verticalAlignment = VerticalAlignment.CENTER borderTop = BorderStyle.THIN @@ -166,7 +166,7 @@ fun generateStockLedgerReport( borderLeft = BorderStyle.THIN borderRight = BorderStyle.THIN } - val intStyle = (workbook.createCellStyle() as XSSFCellStyle).apply { + val intStyle = workbook.createCellStyle().apply { alignment = HorizontalAlignment.RIGHT verticalAlignment = VerticalAlignment.CENTER borderTop = BorderStyle.THIN @@ -176,7 +176,7 @@ fun generateStockLedgerReport( val df: DataFormat = workbook.createDataFormat() dataFormat = df.getFormat("#,##0") } - val dashStyle = (workbook.createCellStyle() as XSSFCellStyle).apply { + val dashStyle = workbook.createCellStyle().apply { alignment = HorizontalAlignment.RIGHT verticalAlignment = VerticalAlignment.CENTER borderTop = BorderStyle.THIN @@ -184,7 +184,7 @@ fun generateStockLedgerReport( borderLeft = BorderStyle.THIN borderRight = BorderStyle.THIN } - val sumQty = (workbook.createCellStyle() as XSSFCellStyle).apply { + val sumQty = workbook.createCellStyle().apply { alignment = HorizontalAlignment.RIGHT verticalAlignment = VerticalAlignment.CENTER val df: DataFormat = workbook.createDataFormat() @@ -196,7 +196,7 @@ fun generateStockLedgerReport( val font = workbook.createFont().apply { bold = true } setFont(font) } - val sumLabel = (workbook.createCellStyle() as XSSFCellStyle).apply { + val sumLabel = workbook.createCellStyle().apply { alignment = HorizontalAlignment.RIGHT verticalAlignment = VerticalAlignment.CENTER borderTop = BorderStyle.THICK @@ -206,7 +206,7 @@ fun generateStockLedgerReport( val font = workbook.createFont().apply { bold = true } setFont(font) } - val sumEmpty = (workbook.createCellStyle() as XSSFCellStyle).apply { + val sumEmpty = workbook.createCellStyle().apply { alignment = HorizontalAlignment.LEFT verticalAlignment = VerticalAlignment.CENTER borderTop = BorderStyle.THICK @@ -214,7 +214,7 @@ fun generateStockLedgerReport( borderLeft = BorderStyle.THIN borderRight = BorderStyle.THIN } - val sumHidden = (workbook.createCellStyle() as XSSFCellStyle).apply { + val sumHidden = workbook.createCellStyle().apply { alignment = HorizontalAlignment.LEFT verticalAlignment = VerticalAlignment.CENTER borderTop = BorderStyle.THICK @@ -239,7 +239,7 @@ fun generateStockLedgerReport( ) } - private fun setTextCell(row: Row, col: Int, value: Any?, style: XSSFCellStyle) { + private fun setTextCell(row: Row, col: Int, value: Any?, style: CellStyle) { row.createCell(col).apply { setCellValue(value?.toString() ?: "") cellStyle = style @@ -259,8 +259,8 @@ fun generateStockLedgerReport( row: Row, col: Int, value: Any?, - intStyle: XSSFCellStyle, - dashStyle: XSSFCellStyle, + intStyle: CellStyle, + dashStyle: CellStyle, ) { val cell = row.createCell(col) val parsed = parseSignedNumber(value) @@ -279,131 +279,160 @@ fun generateStockLedgerReport( } } + /** + * SXSSF keeps only a sliding window of rows in memory to avoid OOM on large exports. + */ private fun createStockLedgerExcel( dbData: List>, reportPeriodStart: String, reportPeriodEnd: String, ): ByteArray { - val workbook = XSSFWorkbook() - val styles = createStyles(workbook) - val reportTitle = "庫存明細報告" - val sheet = workbook.createSheet(WorkbookUtil.createSafeSheetName(reportTitle)) - - val headers = listOf( - "貨品編號", "貨品名稱", "單位", - "出入賬日期", "類型", "批號", "到期日", - "纍計期初存量", "入庫", "出庫", "纍計存量", - "參考編號", "存貨位置", - ) - val totalColumns = headers.size - var rowIndex = 0 + val workbook = SXSSFWorkbook(100) + workbook.setCompressTempFiles(true) + try { + val styles = createStyles(workbook) + val reportTitle = "庫存明細報告" + val sheet = workbook.createSheet(WorkbookUtil.createSafeSheetName(reportTitle)) - val titleRow = sheet.createRow(rowIndex++) - titleRow.createCell(0).apply { - setCellValue(reportTitle) - cellStyle = styles.title - } - sheet.addMergedRegion(CellRangeAddress(0, 0, 0, totalColumns - 1)) - - val reportDateTime = - LocalDate.now().format(DateTimeFormatter.ofPattern("yyyy-MM-dd")) + - "(" + - LocalTime.now().format(DateTimeFormatter.ofPattern("HH:mm:ss")) + - ")" - val subtitleRow = sheet.createRow(rowIndex++) - subtitleRow.createCell(0).apply { - setCellValue("報告日期:$reportDateTime") - cellStyle = styles.subtitle - } - sheet.addMergedRegion(CellRangeAddress(1, 1, 0, 4)) - subtitleRow.createCell(5).apply { - setCellValue("報告期間:${reportPeriodStart.trim()} 至 ${reportPeriodEnd.trim()}") - cellStyle = styles.subtitle - } - sheet.addMergedRegion(CellRangeAddress(1, 1, 5, totalColumns - 1)) - sheet.createRow(rowIndex++) + val headers = listOf( + "貨品編號", "貨品名稱", "單位", + "出入賬日期", "類型", "批號", "到期日", + "纍計期初存量", "入庫", "出庫", "纍計存量", + "參考編號", "存貨位置", + ) + val totalColumns = headers.size + var rowIndex = 0 - val headerRowIndex = rowIndex - val headerRow = sheet.createRow(rowIndex++) - headers.forEachIndexed { i, h -> - headerRow.createCell(i).apply { - setCellValue(h) - cellStyle = styles.header + val titleRow = sheet.createRow(rowIndex++) + titleRow.createCell(0).apply { + setCellValue(reportTitle) + cellStyle = styles.title } - } + sheet.addMergedRegion(CellRangeAddress(0, 0, 0, totalColumns - 1)) - fun addItemSummaryRow(itemNo: String, itemName: String, uom: String, totalIn: Any?, totalOut: Any?, totalBal: Any?) { - val r = sheet.createRow(rowIndex++) - r.createCell(0).apply { setCellValue(itemNo); cellStyle = styles.sumHidden } - r.createCell(1).apply { setCellValue(itemName); cellStyle = styles.sumHidden } - r.createCell(2).apply { setCellValue(uom); cellStyle = styles.sumHidden } - for (c in 3 until totalColumns) { - r.createCell(c).apply { setCellValue(""); cellStyle = styles.sumEmpty } + val reportDateTime = + LocalDate.now().format(DateTimeFormatter.ofPattern("yyyy-MM-dd")) + + "(" + + LocalTime.now().format(DateTimeFormatter.ofPattern("HH:mm:ss")) + + ")" + val subtitleRow = sheet.createRow(rowIndex++) + subtitleRow.createCell(0).apply { + setCellValue("報告日期:$reportDateTime") + cellStyle = styles.subtitle } - // totals should align with numeric columns (shift right by 1) - r.getCell(7).apply { setCellValue("貨品總量:"); cellStyle = styles.sumLabel } - setIntCellFromFormatted(r, 8, totalIn, styles.sumQty, styles.dash) - setIntCellFromFormatted(r, 9, totalOut, styles.sumQty, styles.dash) - setIntCellFromFormatted(r, 10, totalBal, styles.sumQty, styles.dash) - } - - if (dbData.isEmpty()) { - val r = sheet.createRow(rowIndex++) - for (c in 0 until totalColumns) { - r.createCell(c).apply { setCellValue("-"); cellStyle = styles.text } + sheet.addMergedRegion(CellRangeAddress(1, 1, 0, 4)) + subtitleRow.createCell(5).apply { + setCellValue("報告期間:${reportPeriodStart.trim()} 至 ${reportPeriodEnd.trim()}") + cellStyle = styles.subtitle } - } else { - var currentItemNo: String? = null - var currentItemName = "" - var currentUom = "" - var lastTotals: Triple = Triple(null, null, null) + sheet.addMergedRegion(CellRangeAddress(1, 1, 5, totalColumns - 1)) + sheet.createRow(rowIndex++) - dbData.forEach { m -> - val itemNo = m["itemNo"]?.toString().orEmpty() - val itemName = m["itemName"]?.toString().orEmpty() - val uom = m["unitOfMeasure"]?.toString().orEmpty() + val headerRowIndex = rowIndex + val headerRow = sheet.createRow(rowIndex++) + headers.forEachIndexed { i, h -> + headerRow.createCell(i).apply { + setCellValue(h) + cellStyle = styles.header + } + } - if (currentItemNo != null && itemNo != currentItemNo) { - addItemSummaryRow(currentItemNo!!, currentItemName, currentUom, lastTotals.first, lastTotals.second, lastTotals.third) - sheet.createRow(rowIndex++) + fun addItemSummaryRow( + itemNo: String, + itemName: String, + uom: String, + totalIn: Any?, + totalOut: Any?, + totalBal: Any?, + ) { + val r = sheet.createRow(rowIndex++) + r.createCell(0).apply { setCellValue(itemNo); cellStyle = styles.sumHidden } + r.createCell(1).apply { setCellValue(itemName); cellStyle = styles.sumHidden } + r.createCell(2).apply { setCellValue(uom); cellStyle = styles.sumHidden } + for (c in 3 until totalColumns) { + r.createCell(c).apply { setCellValue(""); cellStyle = styles.sumEmpty } } + r.getCell(7).apply { setCellValue("貨品總量:"); cellStyle = styles.sumLabel } + setIntCellFromFormatted(r, 8, totalIn, styles.sumQty, styles.dash) + setIntCellFromFormatted(r, 9, totalOut, styles.sumQty, styles.dash) + setIntCellFromFormatted(r, 10, totalBal, styles.sumQty, styles.dash) + } + if (dbData.isEmpty()) { val r = sheet.createRow(rowIndex++) - setTextCell(r, 0, itemNo, styles.text) - setTextCell(r, 1, itemName, styles.text) - setTextCell(r, 2, uom, styles.center) - setTextCell(r, 3, m["trnDate"], styles.center) - val typeText = m["trnRefNo"]?.toString()?.trim().orEmpty().let { t -> - if (t.equals("Expiry", ignoreCase = true)) "過期" else t + for (c in 0 until totalColumns) { + r.createCell(c).apply { setCellValue("-"); cellStyle = styles.text } } - setTextCell(r, 4, typeText, styles.center) - setTextCell(r, 5, m["lotNo"], styles.text) - setTextCell(r, 6, m["expiryDate"], styles.center) - setIntCellFromFormatted(r, 7, m["cumOpeningBal"], styles.int, styles.dash) - setIntCellFromFormatted(r, 8, m["stockIn"], styles.int, styles.dash) - setIntCellFromFormatted(r, 9, m["stockOut"], styles.int, styles.dash) - setIntCellFromFormatted(r, 10, m["cumBalance"], styles.int, styles.dash) - setTextCell(r, 11, m["orderRefNo"], styles.text) - setTextCell(r, 12, m["storeLocation"], styles.center) + } else { + var currentItemNo: String? = null + var currentItemName = "" + var currentUom = "" + var lastTotals: Triple = Triple(null, null, null) + + dbData.forEach { m -> + val itemNo = m["itemNo"]?.toString().orEmpty() + val itemName = m["itemName"]?.toString().orEmpty() + val uom = m["unitOfMeasure"]?.toString().orEmpty() - currentItemNo = itemNo - currentItemName = itemName - currentUom = uom - lastTotals = Triple(m["totalStockIn"], m["totalStockOut"], m["totalCumBalance"]) + if (currentItemNo != null && itemNo != currentItemNo) { + addItemSummaryRow( + currentItemNo!!, + currentItemName, + currentUom, + lastTotals.first, + lastTotals.second, + lastTotals.third, + ) + sheet.createRow(rowIndex++) + } + + val r = sheet.createRow(rowIndex++) + setTextCell(r, 0, itemNo, styles.text) + setTextCell(r, 1, itemName, styles.text) + setTextCell(r, 2, uom, styles.center) + setTextCell(r, 3, m["trnDate"], styles.center) + val typeText = m["trnRefNo"]?.toString()?.trim().orEmpty().let { t -> + if (t.equals("Expiry", ignoreCase = true)) "過期" else t + } + setTextCell(r, 4, typeText, styles.center) + setTextCell(r, 5, m["lotNo"], styles.text) + setTextCell(r, 6, m["expiryDate"], styles.center) + setIntCellFromFormatted(r, 7, m["cumOpeningBal"], styles.int, styles.dash) + setIntCellFromFormatted(r, 8, m["stockIn"], styles.int, styles.dash) + setIntCellFromFormatted(r, 9, m["stockOut"], styles.int, styles.dash) + setIntCellFromFormatted(r, 10, m["cumBalance"], styles.int, styles.dash) + setTextCell(r, 11, m["orderRefNo"], styles.text) + setTextCell(r, 12, m["storeLocation"], styles.center) + + currentItemNo = itemNo + currentItemName = itemName + currentUom = uom + lastTotals = Triple(m["totalStockIn"], m["totalStockOut"], m["totalCumBalance"]) + } + + addItemSummaryRow( + currentItemNo ?: "", + currentItemName, + currentUom, + lastTotals.first, + lastTotals.second, + lastTotals.third, + ) } - addItemSummaryRow(currentItemNo ?: "", currentItemName, currentUom, lastTotals.first, lastTotals.second, lastTotals.third) - } + val lastRowIndex = rowIndex - 1 + if (lastRowIndex >= headerRowIndex) { + sheet.setAutoFilter(CellRangeAddress(headerRowIndex, lastRowIndex, 0, 0)) + } + val widths = intArrayOf(14, 26, 10, 12, 10, 16, 12, 14, 10, 10, 12, 18, 12) + widths.forEachIndexed { i, w -> sheet.setColumnWidth(i, w * 256) } - val lastRowIndex = rowIndex - 1 - if (lastRowIndex >= headerRowIndex) { - sheet.setAutoFilter(CellRangeAddress(headerRowIndex, lastRowIndex, 0, 0)) + val out = ByteArrayOutputStream() + workbook.write(out) + return out.toByteArray() + } finally { + workbook.dispose() + workbook.close() } - val widths = intArrayOf(14, 26, 10, 12, 10, 16, 12, 14, 10, 10, 12, 18, 12) - widths.forEachIndexed { i, w -> sheet.setColumnWidth(i, w * 256) } - - val out = ByteArrayOutputStream() - workbook.use { it.write(out) } - return out.toByteArray() } -} \ No newline at end of file +} diff --git a/src/main/java/com/ffii/fpsms/modules/report/web/StockLotOnhandReportController.kt b/src/main/java/com/ffii/fpsms/modules/report/web/StockLotOnhandReportController.kt new file mode 100644 index 0000000..7aac550 --- /dev/null +++ b/src/main/java/com/ffii/fpsms/modules/report/web/StockLotOnhandReportController.kt @@ -0,0 +1,321 @@ +package com.ffii.fpsms.modules.report.web + +import com.ffii.fpsms.modules.report.service.StockLotOnhandReportService +import org.apache.poi.ss.usermodel.BorderStyle +import org.apache.poi.ss.usermodel.CellStyle +import org.apache.poi.ss.usermodel.DataFormat +import org.apache.poi.ss.usermodel.FillPatternType +import org.apache.poi.ss.usermodel.HorizontalAlignment +import org.apache.poi.ss.usermodel.IndexedColors +import org.apache.poi.ss.usermodel.Row +import org.apache.poi.ss.usermodel.VerticalAlignment +import org.apache.poi.ss.usermodel.Workbook +import org.apache.poi.ss.util.CellRangeAddress +import org.apache.poi.ss.util.WorkbookUtil +import org.apache.poi.xssf.streaming.SXSSFWorkbook +import org.springframework.http.HttpHeaders +import org.springframework.http.HttpStatus +import org.springframework.http.MediaType +import org.springframework.http.ResponseEntity +import org.springframework.web.bind.annotation.GetMapping +import org.springframework.web.bind.annotation.RequestMapping +import org.springframework.web.bind.annotation.RequestParam +import org.springframework.web.bind.annotation.RestController +import java.io.ByteArrayOutputStream +import java.time.LocalTime +import java.time.format.DateTimeFormatter + +/** + * 庫存批次現況報告 Stock Balance (Excel only, always today) + * Excel: /report/print-stock-lot-onhand-excel + */ +@RestController +@RequestMapping("/report") +class StockLotOnhandReportController( + private val stockLotOnhandReportService: StockLotOnhandReportService, +) { + private data class ExcelStyles( + val title: CellStyle, + val subtitle: CellStyle, + val header: CellStyle, + val text: CellStyle, + val center: CellStyle, + val number: CellStyle, + ) + + /** FP-MTMS Version Checklist | Functions Ref. No. 68 | v1.0.0 | 2026-08-31 */ + @GetMapping("/print-stock-lot-onhand-excel") + fun exportExcel( + @RequestParam(required = false) itemCode: String?, + @RequestParam(required = false) storeId: String?, + @RequestParam(required = false) warehouse: String?, + @RequestParam(required = false) area: String?, + @RequestParam(required = false) slot: String?, + @RequestParam(required = false) lotNo: String?, + @RequestParam(required = false) stockTakeSectionDescription: String?, + @RequestParam(required = false) lotOrigin: String?, + ): ResponseEntity { + val result = stockLotOnhandReportService.search( + itemCode = itemCode, + storeId = storeId, + warehouse = warehouse, + area = area, + slot = slot, + lotNo = lotNo, + stockTakeSectionDescription = stockTakeSectionDescription, + lotOrigin = lotOrigin, + ) + if (result.rows.isEmpty()) { + return ResponseEntity(HttpStatus.NO_CONTENT) + } + val excelBytes = createExcel( + dbData = result.rows, + reportDate = result.stockDate, + itemCode = itemCode, + storeId = storeId, + warehouse = warehouse, + area = area, + slot = slot, + lotNo = lotNo, + stockTakeSectionDescription = stockTakeSectionDescription, + lotOrigin = lotOrigin, + ) + + val headers = HttpHeaders().apply { + contentType = MediaType.parseMediaType( + "application/vnd.openxmlformats-officedocument.spreadsheetml.sheet", + ) + setContentDispositionFormData("attachment", "StockLotOnhandReport.xlsx") + set("filename", "StockLotOnhandReport.xlsx") + } + return ResponseEntity(excelBytes, headers, HttpStatus.OK) + } + + private fun createStyles(workbook: Workbook): ExcelStyles { + val df: DataFormat = workbook.createDataFormat() + val numberFormat = df.getFormat("#,##0.00;(#,##0.00)") + fun borders(style: CellStyle) { + style.borderTop = BorderStyle.THIN + style.borderBottom = BorderStyle.THIN + style.borderLeft = BorderStyle.THIN + style.borderRight = BorderStyle.THIN + style.verticalAlignment = VerticalAlignment.CENTER + } + fun fill(style: CellStyle, color: IndexedColors) { + style.fillForegroundColor = color.index + style.fillPattern = FillPatternType.SOLID_FOREGROUND + } + val titleStyle = workbook.createCellStyle().apply { + alignment = HorizontalAlignment.CENTER + verticalAlignment = VerticalAlignment.CENTER + val font = workbook.createFont().apply { + bold = true + fontHeightInPoints = 16 + } + setFont(font) + } + val subtitleStyle = workbook.createCellStyle().apply { + alignment = HorizontalAlignment.LEFT + verticalAlignment = VerticalAlignment.CENTER + wrapText = true + val font = workbook.createFont().apply { + fontHeightInPoints = 10 + } + setFont(font) + } + val headerStyle = workbook.createCellStyle().apply { + alignment = HorizontalAlignment.CENTER + wrapText = true + borders(this) + fill(this, IndexedColors.DARK_TEAL) + val font = workbook.createFont().apply { + bold = true + color = IndexedColors.WHITE.index + fontHeightInPoints = 10 + } + setFont(font) + } + val textStyle = workbook.createCellStyle().apply { + alignment = HorizontalAlignment.LEFT + borders(this) + } + val centerStyle = workbook.createCellStyle().apply { + alignment = HorizontalAlignment.CENTER + borders(this) + } + val numberStyle = workbook.createCellStyle().apply { + alignment = HorizontalAlignment.RIGHT + borders(this) + dataFormat = numberFormat + } + return ExcelStyles( + title = titleStyle, + subtitle = subtitleStyle, + header = headerStyle, + text = textStyle, + center = centerStyle, + number = numberStyle, + ) + } + + private fun setTextCell(row: Row, col: Int, value: Any?, style: CellStyle) { + row.createCell(col).apply { + setCellValue(value?.toString() ?: "") + cellStyle = style + } + } + + private fun setNumberCell(row: Row, col: Int, value: Any?, numberStyle: CellStyle) { + val cell = row.createCell(col) + val n = when (value) { + null -> null + is Number -> value.toDouble() + else -> { + val raw = value.toString().trim() + if (raw.isEmpty() || raw == "-") null else raw.replace(",", "").toDoubleOrNull() + } + } + if (n == null) { + cell.setCellValue("") + cell.cellStyle = numberStyle + return + } + cell.setCellValue(n) + cell.cellStyle = numberStyle + } + + private fun displayFilter(raw: String?): String { + val v = raw?.trim().orEmpty() + if (v.isEmpty() || v.equals("All", ignoreCase = true)) return "全部" + if (v.equals("other", ignoreCase = true)) return "其他" + return v + } + + private fun createExcel( + dbData: List>, + reportDate: String, + itemCode: String?, + storeId: String?, + warehouse: String?, + area: String?, + slot: String?, + lotNo: String?, + stockTakeSectionDescription: String?, + lotOrigin: String?, + ): ByteArray { + val workbook = SXSSFWorkbook(100) + workbook.setCompressTempFiles(true) + try { + val styles = createStyles(workbook) + val reportTitle = "庫存批次現況報告" + val sheet = workbook.createSheet(WorkbookUtil.createSafeSheetName(reportTitle)) + val headers = listOf( + "貨品編號", "貨品名稱", "貨品單位結餘", "單位", + "批號", "到期日", + "樓層", "倉庫", "區域", "儲位", + "最後異動日", "最後異動(入庫/出庫)", + "批號結餘", "單位均價", "庫存總價值", + ) + val totalColumns = headers.size + var rowIndex = 0 + + val titleRow = sheet.createRow(rowIndex++) + titleRow.heightInPoints = 24f + titleRow.createCell(0).apply { + setCellValue(reportTitle) + cellStyle = styles.title + } + sheet.addMergedRegion(CellRangeAddress(0, 0, 0, totalColumns - 1)) + + val reportDateTime = + reportDate + + " (" + + LocalTime.now().format(DateTimeFormatter.ofPattern("HH:mm:ss")) + + ")" + val dateRow = sheet.createRow(rowIndex++) + dateRow.heightInPoints = 18f + dateRow.createCell(0).apply { + setCellValue("報告日期(現況):$reportDateTime") + cellStyle = styles.subtitle + } + sheet.addMergedRegion(CellRangeAddress(1, 1, 0, totalColumns - 1)) + + val criteriaText = listOf( + "貨品編號=${displayFilter(itemCode)}", + "樓層=${displayFilter(storeId)}", + "倉庫=${displayFilter(warehouse)}", + "區域=${displayFilter(area)}", + "儲位=${displayFilter(slot)}", + "盤點區域說明=${displayFilter(stockTakeSectionDescription)}", + "批號=${displayFilter(lotNo)}", + "來源=${displayFilter(lotOrigin)}", + ).joinToString(" ") + val criteriaRow = sheet.createRow(rowIndex++) + criteriaRow.heightInPoints = 32f + criteriaRow.createCell(0).apply { + setCellValue("搜尋條件:$criteriaText") + cellStyle = styles.subtitle + } + sheet.addMergedRegion(CellRangeAddress(2, 2, 0, totalColumns - 1)) + + val headerRowIndex = rowIndex + val headerRow = sheet.createRow(rowIndex++) + headerRow.heightInPoints = 22f + headers.forEachIndexed { i, h -> + headerRow.createCell(i).apply { + setCellValue(h) + cellStyle = styles.header + } + } + + if (dbData.isEmpty()) { + val emptyRowIndex = rowIndex + val r = sheet.createRow(rowIndex++) + r.heightInPoints = 22f + r.createCell(0).apply { + setCellValue("查無資料") + cellStyle = styles.center + } + for (c in 1 until totalColumns) { + r.createCell(c).cellStyle = styles.center + } + sheet.addMergedRegion(CellRangeAddress(emptyRowIndex, emptyRowIndex, 0, totalColumns - 1)) + } else { + dbData.forEach { m -> + val r = sheet.createRow(rowIndex++) + r.heightInPoints = 18f + setTextCell(r, 0, m["itemNo"], styles.text) + setTextCell(r, 1, m["itemName"], styles.text) + setNumberCell(r, 2, m["totalQtyRaw"], styles.number) + setTextCell(r, 3, m["unitOfMeasure"], styles.center) + setTextCell(r, 4, m["lotNo"], styles.text) + setTextCell(r, 5, m["expiryDate"], styles.center) + setTextCell(r, 6, m["storeId"], styles.center) + setTextCell(r, 7, m["warehousePart"], styles.center) + setTextCell(r, 8, m["areaPart"], styles.center) + setTextCell(r, 9, m["slotPart"], styles.center) + setTextCell(r, 10, m["lastTrnDate"], styles.center) + setTextCell(r, 11, m["lastTrnType"], styles.center) + setNumberCell(r, 12, m["lotQtyRaw"], styles.number) + setNumberCell(r, 13, m["avgUnitPriceRaw"], styles.number) + setNumberCell(r, 14, m["stockValueRaw"], styles.number) + } + } + + val lastRowIndex = rowIndex - 1 + if (lastRowIndex >= headerRowIndex) { + sheet.setAutoFilter(CellRangeAddress(headerRowIndex, lastRowIndex, 0, totalColumns - 1)) + } + sheet.createFreezePane(0, headerRowIndex + 1) + intArrayOf(14, 28, 14, 8, 20, 12, 10, 12, 10, 10, 12, 20, 12, 12, 14) + .forEachIndexed { i, w -> sheet.setColumnWidth(i, w * 256) } + + val out = ByteArrayOutputStream() + workbook.write(out) + return out.toByteArray() + } finally { + workbook.dispose() + workbook.close() + } + } +} diff --git a/src/main/java/com/ffii/fpsms/modules/stock/service/StockTakeRecordService.kt b/src/main/java/com/ffii/fpsms/modules/stock/service/StockTakeRecordService.kt index 4597177..3332710 100644 --- a/src/main/java/com/ffii/fpsms/modules/stock/service/StockTakeRecordService.kt +++ b/src/main/java/com/ffii/fpsms/modules/stock/service/StockTakeRecordService.kt @@ -1615,6 +1615,9 @@ open class StockTakeRecordService( ) } + /** + * FP-MTMS Version Checklist | Functions Ref. No. 70 | v1.0.0 | 2026-08-31 + */ open fun checkAndUpdateStockTakeStatus(stockTakeId: Long, stockTakeSection: String): Map { try { val stockTake = stockTakeRepository.findByIdAndDeletedIsFalse(stockTakeId) @@ -1648,18 +1651,17 @@ open class StockTakeRecordService( } } - // 輪次預建後:以本 section 本輪「全部記錄是否已完成第一次盤點」為準(不再與即時庫存行逐行對齊) - val allLinesHaveRecords = stockTakeRecords.isNotEmpty() && - stockTakeRecords.all { it.pickerFirstStockTakeQty != null } - + // 以本 section 本輪「列狀態」升單頭。審核員單筆先核(無第一次數量)只要已是 + // pass/completed 即算完成,不再要求每一筆都有 pickerFirstStockTakeQty。 val allRecordsPassed = stockTakeRecords.isNotEmpty() && stockTakeRecords.all { it.status == "pass" || it.status == "completed" } val allRecordsCompleted = stockTakeRecords.isNotEmpty() && stockTakeRecords.all { it.status == "completed" } - // 6. 如果所有记录都已创建且都是 "pass",更新 stock take 状态为 "approving" - if (allLinesHaveRecords && allRecordsCompleted) { + val now = java.time.LocalDateTime.now() + if (allRecordsCompleted) { stockTake.status = StockTakeStatus.COMPLETED - stockTake.planEnd = java.time.LocalDateTime.now() + stockTake.planEnd = now + stockTake.actualEnd = now stockTakeRepository.save(stockTake) println("Stock take $stockTakeId status updated to COMPLETED - all records are completed") return mapOf( @@ -1667,10 +1669,9 @@ open class StockTakeRecordService( "message" to "Stock take status updated to COMPLETED", "updated" to true ) - } else if (allLinesHaveRecords && allRecordsPassed) { - // 如果所有记录都已创建且都是 "pass" 或 "completed",更新 stock take 状态为 "approving" + } else if (allRecordsPassed) { stockTake.status = StockTakeStatus.APPROVING - stockTake.actualEnd = java.time.LocalDateTime.now() + stockTake.actualEnd = now stockTakeRepository.save(stockTake) println("Stock take $stockTakeId status updated to APPROVING - all records are pass") @@ -1684,7 +1685,6 @@ open class StockTakeRecordService( "success" to true, "message" to "Conditions not met for status update", "updated" to false, - "allLinesHaveRecords" to allLinesHaveRecords, "allRecordsPassed" to allRecordsPassed, "allRecordsCompleted" to allRecordsCompleted ) diff --git a/src/main/java/com/ffii/fpsms/modules/user/entity/UserRepository.java b/src/main/java/com/ffii/fpsms/modules/user/entity/UserRepository.java index 51d7b02..d5c0554 100644 --- a/src/main/java/com/ffii/fpsms/modules/user/entity/UserRepository.java +++ b/src/main/java/com/ffii/fpsms/modules/user/entity/UserRepository.java @@ -13,6 +13,8 @@ import com.ffii.fpsms.modules.user.entity.projections.UserCombo; public interface UserRepository extends AbstractRepository { List findByName(@Param("name") String name); + + List findByNameAndDeletedFalse(String name); Optional findByUsernameAndDeletedFalse(String username); @@ -20,6 +22,8 @@ public interface UserRepository extends AbstractRepository { Optional findByStaffNo(@Param("staffNo") String staffNo); + Optional findByStaffNoAndDeletedFalse(String staffNo); + @Modifying @Query(value = """ INSERT INTO user_authority (userID, authId) diff --git a/src/main/java/com/ffii/fpsms/modules/user/service/UserService.java b/src/main/java/com/ffii/fpsms/modules/user/service/UserService.java index 9c57e79..9832457 100644 --- a/src/main/java/com/ffii/fpsms/modules/user/service/UserService.java +++ b/src/main/java/com/ffii/fpsms/modules/user/service/UserService.java @@ -92,6 +92,22 @@ public class UserService extends AbstractBaseEntityService search(SearchUserReq req) { StringBuilder sql = new StringBuilder("SELECT" @@ -199,11 +215,18 @@ public class UserService extends AbstractBaseEntityService - 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/20260813_m18_sync_authority/01_add_m18_sync_authority.sql b/src/main/resources/db/changelog/changes/20260813_m18_sync_authority/01_add_m18_sync_authority.sql new file mode 100644 index 0000000..e330e48 --- /dev/null +++ b/src/main/resources/db/changelog/changes/20260813_m18_sync_authority/01_add_m18_sync_authority.sql @@ -0,0 +1,8 @@ +--liquibase formatted sql + +--changeset fpsms:add_m18_sync_authority +--preconditions onFail:MARK_RAN +--precondition-sql-check expectedResult:0 SELECT COUNT(*) FROM authority WHERE authority = 'M18_SYNC' +--comment: Manual M18 sync page (/m18Syn): ADMIN or M18_SYNC +INSERT IGNORE INTO `authority` (`authority`, `name`, `module`, `description`) +VALUES ('M18_SYNC', 'M18同步', 'M18', 'Allow manual M18 sync by document or item code'); 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/db/changelog/changes/20260822_onpack_expiry_item_code/01_create_onpack_expiry_item_code.sql b/src/main/resources/db/changelog/changes/20260822_onpack_expiry_item_code/01_create_onpack_expiry_item_code.sql new file mode 100644 index 0000000..5290ab3 --- /dev/null +++ b/src/main/resources/db/changelog/changes/20260822_onpack_expiry_item_code/01_create_onpack_expiry_item_code.sql @@ -0,0 +1,18 @@ +--liquibase formatted sql + +--changeset fpsms:create_onpack_expiry_item_code +--comment: UI-managed item codes for 汁水機 OnPack expiry ZIP (dynamic PP1181 template) +CREATE TABLE `onpack_expiry_item_code` +( + `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 DEFAULT 'juice' COMMENT 'juice=汁水機 expiry ZIP', + `itemCode` VARCHAR(50) NOT NULL, + CONSTRAINT pk_onpack_expiry_item_code PRIMARY KEY (`id`), + UNIQUE KEY uk_onpack_expiry_item_code (`machine`, `itemCode`) +); diff --git a/src/main/resources/db/changelog/changes/20260822_onpack_expiry_item_code/02_seed_onpack_expiry_item_code_pp.sql b/src/main/resources/db/changelog/changes/20260822_onpack_expiry_item_code/02_seed_onpack_expiry_item_code_pp.sql new file mode 100644 index 0000000..e2b0df2 --- /dev/null +++ b/src/main/resources/db/changelog/changes/20260822_onpack_expiry_item_code/02_seed_onpack_expiry_item_code_pp.sql @@ -0,0 +1,44 @@ +--liquibase formatted sql + +--changeset fpsms:seed_onpack_expiry_item_code_pp +--comment: Seed juice expiry ZIP list from onpack2030 folder PP*.image +INSERT INTO `onpack_expiry_item_code` +(`created`, `createdBy`, `version`, `modified`, `modifiedBy`, `deleted`, `machine`, `itemCode`) +VALUES +(NOW(), 'system', 0, NOW(), 'system', 0, 'juice', 'PP1041'), +(NOW(), 'system', 0, NOW(), 'system', 0, 'juice', 'PP1074'), +(NOW(), 'system', 0, NOW(), 'system', 0, 'juice', 'PP1078'), +(NOW(), 'system', 0, NOW(), 'system', 0, 'juice', 'PP1080'), +(NOW(), 'system', 0, NOW(), 'system', 0, 'juice', 'PP1082'), +(NOW(), 'system', 0, NOW(), 'system', 0, 'juice', 'PP1088'), +(NOW(), 'system', 0, NOW(), 'system', 0, 'juice', 'PP1117'), +(NOW(), 'system', 0, NOW(), 'system', 0, 'juice', 'PP1118'), +(NOW(), 'system', 0, NOW(), 'system', 0, 'juice', 'PP1126'), +(NOW(), 'system', 0, NOW(), 'system', 0, 'juice', 'PP1136'), +(NOW(), 'system', 0, NOW(), 'system', 0, 'juice', 'PP1137'), +(NOW(), 'system', 0, NOW(), 'system', 0, 'juice', 'PP1144'), +(NOW(), 'system', 0, NOW(), 'system', 0, 'juice', 'PP1148'), +(NOW(), 'system', 0, NOW(), 'system', 0, 'juice', 'PP1152'), +(NOW(), 'system', 0, NOW(), 'system', 0, 'juice', 'PP1156'), +(NOW(), 'system', 0, NOW(), 'system', 0, 'juice', 'PP1178'), +(NOW(), 'system', 0, NOW(), 'system', 0, 'juice', 'PP1180'), +(NOW(), 'system', 0, NOW(), 'system', 0, 'juice', 'PP1181'), +(NOW(), 'system', 0, NOW(), 'system', 0, 'juice', 'PP1185'), +(NOW(), 'system', 0, NOW(), 'system', 0, 'juice', 'PP1209'), +(NOW(), 'system', 0, NOW(), 'system', 0, 'juice', 'PP1213'), +(NOW(), 'system', 0, NOW(), 'system', 0, 'juice', 'PP1214'), +(NOW(), 'system', 0, NOW(), 'system', 0, 'juice', 'PP1216'), +(NOW(), 'system', 0, NOW(), 'system', 0, 'juice', 'PP1217'), +(NOW(), 'system', 0, NOW(), 'system', 0, 'juice', 'PP1234'), +(NOW(), 'system', 0, NOW(), 'system', 0, 'juice', 'PP2211'), +(NOW(), 'system', 0, NOW(), 'system', 0, 'juice', 'PP2214'), +(NOW(), 'system', 0, NOW(), 'system', 0, 'juice', 'PP2215'), +(NOW(), 'system', 0, NOW(), 'system', 0, 'juice', 'PP2243'), +(NOW(), 'system', 0, NOW(), 'system', 0, 'juice', 'PP2248'), +(NOW(), 'system', 0, NOW(), 'system', 0, 'juice', 'PP2250'), +(NOW(), 'system', 0, NOW(), 'system', 0, 'juice', 'PP2262'), +(NOW(), 'system', 0, NOW(), 'system', 0, 'juice', 'PP2282'), +(NOW(), 'system', 0, NOW(), 'system', 0, 'juice', 'PP2317'), +(NOW(), 'system', 0, NOW(), 'system', 0, 'juice', 'PP2331'), +(NOW(), 'system', 0, NOW(), 'system', 0, 'juice', 'PP2341'), +(NOW(), 'system', 0, NOW(), 'system', 0, 'juice', 'PP2404'); diff --git a/src/main/resources/db/changelog/changes/20260822_onpack_expiry_item_code/03_add_print_name.sql b/src/main/resources/db/changelog/changes/20260822_onpack_expiry_item_code/03_add_print_name.sql new file mode 100644 index 0000000..d179594 --- /dev/null +++ b/src/main/resources/db/changelog/changes/20260822_onpack_expiry_item_code/03_add_print_name.sql @@ -0,0 +1,6 @@ +--liquibase formatted sql + +--changeset fpsms:onpack_expiry_item_code_print_name +--comment: Editable OnPack Product BMP name (Chinese name + unit); blank uses items + stock UOM +ALTER TABLE `onpack_expiry_item_code` + ADD COLUMN `printName` VARCHAR(255) NULL COMMENT 'OnPack Product line; overrides items name + unit' AFTER `itemCode`; diff --git a/src/main/resources/excelTemplate/bom_import_blank.xlsx b/src/main/resources/excelTemplate/bom_import_blank.xlsx new file mode 100644 index 0000000..4eb7ab2 Binary files /dev/null and b/src/main/resources/excelTemplate/bom_import_blank.xlsx differ diff --git a/src/main/resources/jasper/StockLedgarReport.jrxml b/src/main/resources/jasper/StockLedgarReport.jrxml index 5bf97d7..a245642 100644 --- a/src/main/resources/jasper/StockLedgarReport.jrxml +++ b/src/main/resources/jasper/StockLedgarReport.jrxml @@ -79,7 +79,7 @@ - + diff --git a/src/main/resources/onpack2030_exp/pp1181.image b/src/main/resources/onpack2030_exp/pp1181.image new file mode 100644 index 0000000..b4dcf4d Binary files /dev/null and b/src/main/resources/onpack2030_exp/pp1181.image differ diff --git a/src/test/kotlin/com/ffii/fpsms/modules/jobOrder/service/LaserBag2PayloadTest.kt b/src/test/kotlin/com/ffii/fpsms/modules/jobOrder/service/LaserBag2PayloadTest.kt new file mode 100644 index 0000000..fe9ce57 --- /dev/null +++ b/src/test/kotlin/com/ffii/fpsms/modules/jobOrder/service/LaserBag2PayloadTest.kt @@ -0,0 +1,66 @@ +package com.ffii.fpsms.modules.jobOrder.service + +import com.fasterxml.jackson.module.kotlin.jacksonObjectMapper +import com.ffii.fpsms.modules.jobOrder.web.model.LaserBag2SendRequest +import org.junit.jupiter.api.Assertions.assertEquals +import org.junit.jupiter.api.Assertions.assertNull +import org.junit.jupiter.api.Test + +class LaserBag2PayloadTest { + + @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 laserAckLooksInvalid_detects_plugin_reject() { + assertEquals(false, PlasticBagPrinterService.laserAckLooksInvalid(null)) + assertEquals(false, PlasticBagPrinterService.laserAckLooksInvalid("receive;;")) + assertEquals(true, PlasticBagPrinterService.laserAckLooksInvalid("errorinvalid data")) + assertEquals(true, PlasticBagPrinterService.laserAckLooksInvalid("INVALID")) + } + + @Test + fun sendRequest_accepts_expiry_as_iso_or_jackson_array() { + val mapper = jacksonObjectMapper() + val fromIso = mapper.readValue( + """{"expiryDate":"2026-08-27","source":"MANUAL"}""", + LaserBag2SendRequest::class.java, + ) + assertEquals("2026-08-27", fromIso.expiryDate) + val fromArray = mapper.readValue( + """{"expiryDate":[2026,8,27],"source":"MANUAL"}""", + LaserBag2SendRequest::class.java, + ) + assertEquals("2026-08-27", fromArray.expiryDate) + val missing = mapper.readValue("{}", LaserBag2SendRequest::class.java) + assertNull(missing.expiryDate) + } + + @Test + fun formatLaserExpiryParam_accepts_iso_compact_and_print_label() { + assertEquals("Expiry Date 20260821", PlasticBagPrinterService.formatLaserExpiryParam("2026-08-21")) + 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/OnPackPp1181MasterTest.kt b/src/test/kotlin/com/ffii/fpsms/modules/jobOrder/service/OnPackPp1181MasterTest.kt new file mode 100644 index 0000000..8b6c9f6 --- /dev/null +++ b/src/test/kotlin/com/ffii/fpsms/modules/jobOrder/service/OnPackPp1181MasterTest.kt @@ -0,0 +1,42 @@ +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 +import org.springframework.core.io.ClassPathResource + +class OnPackPp1181MasterTest { + + @Test + fun rewriteImageXml_swaps_pp1181_filenames() { + val xml = """pp1181Product.bmppp1181qr.bmp""" + val out = OnPackPp1181Master.rewriteImageXml(xml, "PP1041") + assertTrue(out.contains("pp1041Product.bmp")) + assertTrue(out.contains("pp1041qr.bmp")) + assertFalse(out.contains("pp1181")) + } + + @Test + fun rewriteJobXml_points_at_cloned_image() { + val out = OnPackPp1181Master.rewriteJobXml( + "PP1181.image", + "pp1041.image", + ) + assertEquals("pp1041.image", out) + } + + @Test + fun classpath_exp_master_rewrites_all_five_slots() { + val resource = ClassPathResource(OnPackPp1181Master.MASTER_IMAGE) + assertTrue(resource.exists(), "missing ${OnPackPp1181Master.MASTER_IMAGE}") + val bytes = OnPackPp1181Master.rewriteImageBytes(resource.inputStream.use { it.readBytes() }, "PP2404") + val (xml, _) = OnPackImageTemplateCodec.decode(bytes) + assertTrue(xml.contains("pp2404Product.bmp")) + assertTrue(xml.contains("pp2404Code.bmp")) + assertTrue(xml.contains("pp2404Date.bmp")) + assertTrue(xml.contains("pp2404qr.bmp")) + assertTrue(xml.contains("pp2404exp.bmp")) + assertFalse(xml.contains("pp1181", ignoreCase = true)) + } +} 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..98d2cc0 --- /dev/null +++ b/src/test/kotlin/com/ffii/fpsms/modules/master/service/ItemDefaultShelfLifeServiceTest.kt @@ -0,0 +1,67 @@ +package com.ffii.fpsms.modules.master.service + +import com.ffii.fpsms.modules.master.web.ItemDefaultShelfLifeRequest +import org.junit.jupiter.api.Assertions.assertEquals +import org.junit.jupiter.api.Assertions.assertThrows +import org.junit.jupiter.api.Test +import org.springframework.web.server.ResponseStatusException +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)), + ) + } + + @Test + fun normalizeItemCode_trims_and_uppercases() { + assertEquals("F0013", ItemDefaultShelfLifeService.normalizeItemCode(" f0013 ")) + assertEquals("", ItemDefaultShelfLifeService.normalizeItemCode(" ")) + } + + @Test + fun validateRequest_rejects_blank_code_and_negative_days() { + assertThrows(ResponseStatusException::class.java) { + ItemDefaultShelfLifeService.validateRequest(ItemDefaultShelfLifeRequest(), "") + } + assertThrows(ResponseStatusException::class.java) { + ItemDefaultShelfLifeService.validateRequest( + ItemDefaultShelfLifeRequest(itemCode = "F0013", defaultDays = -1), + "F0013", + ) + } + } +} 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) + } }