diff --git a/python/Bag1.py b/python/Bag1.py deleted file mode 100644 index 31e8511..0000000 --- a/python/Bag1.py +++ /dev/null @@ -1,826 +0,0 @@ -#!/usr/bin/env python3 -""" -Bag1 – GUI to show FPSMS job orders by plan date. -Uses the public API GET /py/job-orders (no login required). -UI tuned for aged users: larger font, Traditional Chinese labels, prev/next date. - -Run: python Bag1.py -""" - -import json -import os -import socket -import sys -import time -import tkinter as tk -from datetime import date, timedelta -from tkinter import messagebox, ttk -from typing import Optional - -import requests - -try: - import serial -except ImportError: - serial = None # type: ignore - -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__)) -SETTINGS_FILE = os.path.join(_SETTINGS_DIR, "bag1_settings.json") - -DEFAULT_SETTINGS = { - "api_ip": "localhost", - "api_port": "8090", - "dabag_ip": "", - "dabag_port": "3008", - "laser_ip": "", - "laser_port": "9100", - "label_com": "COM3", -} - - -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 "9100").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 == "激光機": - ip = (sett.get("laser_ip") or "").strip() - port_str = (sett.get("laser_port") or "9100").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 == "標簽機": - if serial is None: - return False - com = (sett.get("label_com") or "").strip() - if not com: - return False - try: - ser = serial.Serial(com, 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 -FONT_SIZE_QTY = 12 # smaller for 數量 under batch no. -FONT_SIZE_ITEM_CODE = 20 # item code (larger for readability) -FONT_SIZE_ITEM_NAME = 26 # item name (bigger than item code) -FONT_FAMILY = "Microsoft JhengHei UI" # Traditional Chinese; fallback to TkDefaultFont -# Column widths: item code own column; item name at least double, wraps in its column -ITEM_CODE_WRAP = 140 # item code column width (long codes wrap under code only) -ITEM_NAME_WRAP = 640 # item name column (double width), wraps under name only - -# 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 -REFRESH_MS = 60 * 1000 # 60 seconds refresh when connected -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 -DATE_AUTO_RESET_SEC = 5 * 60 # 5 minutes: if no manual date change, auto-set to today - - -def _zpl_escape(s: str) -> str: - """Escape text for ZPL ^FD...^FS (backslash and caret).""" - return s.replace("\\", "\\\\").replace("^", "\\^") - - -def _split_by_word_count(text: str, max_words: int = 8) -> list[str]: - """Split text into segments of at most max_words (words = non-symbol chars; symbols not counted).""" - segments = [] - current = [] - count = 0 - for c in text: - if c.isalnum() or ("\u4e00" <= c <= "\u9fff") or ("\u3400" <= c <= "\u4dbf"): - count += 1 - current.append(c) - if count >= max_words: - segments.append("".join(current)) - current = [] - count = 0 - else: - current.append(c) - if current: - segments.append("".join(current)) - return segments if segments else [""] - - -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, - 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: Batch/lot (left), item code (right). - Label and QR use lotNo from API when present, else batch_no (Bxxxxx). - """ - 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) - # 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_value = _zpl_escape(json.dumps({"itemId": item_id, "stockInLineId": stock_in_line_id})) - else: - qr_value = _zpl_escape(label_line if label_line else batch_no.strip()) - return f"""^XA -^CI28 -^PW700 -^LL500 -^PO N -^FO10,20 -^BQR,4,7^FD{qr_value}^FS -^FO170,20 -^A@R,72,72,{font_regular}^FD{desc}^FS -^FO0,290 -^A@R,72,72,{font_regular}^FD{label_esc}^FS -^FO75,290 -^A@R,88,88,{font_bold}^FD{code}^FS -^XZ""" - - -def send_zpl_to_dataflex(ip: str, port: int, zpl: str) -> None: - """Send ZPL to DataFlex printer via TCP. Raises on connection/send error.""" - sock = socket.socket(socket.AF_INET, socket.SOCK_STREAM) - sock.settimeout(DATAFLEX_SEND_TIMEOUT) - try: - sock.connect((ip, port)) - sock.sendall(zpl.encode("utf-8")) - finally: - sock.close() - - -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 + 2-digit year + jobOrderId zero-padded to 6 digits.""" - short_year = year % 100 - return f"B{short_year:02d}{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 set_row_highlight(row_frame: tk.Frame, selected: bool) -> None: - """Set row and all its child widgets to selected or normal background.""" - bg = BG_ROW_SELECTED if selected else BG_ROW - row_frame.configure(bg=bg) - for w in row_frame.winfo_children(): - if isinstance(w, (tk.Frame, tk.Label)): - w.configure(bg=bg) - for c in w.winfo_children(): - if isinstance(c, tk.Label): - c.configure(bg=bg) - - -def on_job_order_click(jo: dict, batch: str) -> None: - """Row click handler (highlight already set; no popup).""" - - -def ask_bag_count(parent: tk.Tk) -> Optional[int]: - """ - When printer is 打袋機 DataFlex, ask how many bags: +50, +10, +5, +1, C, then 確認送出. - Returns count (>= 1), or -1 for continuous (C), or None if cancelled. - """ - result: list[Optional[int]] = [None] - count_ref = [0] - continuous_ref = [False] - - win = tk.Toplevel(parent) - win.title("打袋列印數量") - win.geometry("420x200") - win.transient(parent) - win.grab_set() - win.configure(bg=BG_TOP) - ttk.Label(win, text="列印多少個袋?", font=get_font(FONT_SIZE)).pack(pady=(12, 4)) - count_lbl = tk.Label(win, text="列印數量: 0", font=get_font(FONT_SIZE), bg=BG_TOP) - count_lbl.pack(pady=4) - - def update_display(): - if continuous_ref[0]: - count_lbl.configure(text="列印數量: 連續 (C)") - else: - count_lbl.configure(text=f"列印數量: {count_ref[0]}") - - def add(n: int): - continuous_ref[0] = False - count_ref[0] = max(0, count_ref[0] + n) - update_display() - - def set_continuous(): - continuous_ref[0] = True - update_display() - - def confirm(): - if continuous_ref[0]: - result[0] = -1 - elif count_ref[0] < 1: - messagebox.showwarning("打袋機", "請先按 +50、+10、+5 或 +1 選擇數量。", parent=win) - return - else: - result[0] = count_ref[0] - 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(btn_row1, text="C", command=set_continuous, width=8).pack(side=tk.LEFT, padx=4) - - ttk.Button(win, text="確認送出", command=confirm, width=14).pack(pady=12) - win.protocol("WM_DELETE_WINDOW", win.destroy) - win.wait_window() - return result[0] - - -def ask_label_count(parent: tk.Tk) -> Optional[str]: - """ - When printer is 標簽機, ask how many labels to print. - Returns "1", "10", "50", "100", "C" (continuous), or None if cancelled. - """ - result = [None] # mutable so inner callback can set it - - win = tk.Toplevel(parent) - win.title("標簽列印數量") - win.geometry("360x180") - win.transient(parent) - win.grab_set() - win.configure(bg=BG_TOP) - ttk.Label(win, text="列印多少張標簽?", font=get_font(FONT_SIZE)).pack(pady=(16, 12)) - btn_frame = tk.Frame(win, bg=BG_TOP) - btn_frame.pack(pady=8) - for label, value in [("1", "1"), ("10", "10"), ("50", "50"), ("100", "100"), ("連續 (C)", "C")]: - def make_cmd(v): - def cmd(): - result[0] = v - win.destroy() - return cmd - ttk.Button(btn_frame, text=label, command=make_cmd(value), width=10).pack(side=tk.LEFT, padx=4) - win.protocol("WM_DELETE_WINDOW", win.destroy) - win.wait_window() - return result[0] - - -def main() -> None: - settings = load_settings() - base_url_ref = [build_base_url(settings["api_ip"], settings["api_port"])] - - root = tk.Tk() - root.title("FP-MTMS Bag v1.1 打袋機") - 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) - 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): - """Show a temporary 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) - - # 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]) - last_manual_date_change_ref = [time.time()] # track when user last changed date manually - - def mark_manual_date_change(): - last_manual_date_change_ref[0] = time.time() - - def go_prev_day() -> None: - try: - d = date.fromisoformat(date_var.get().strip()) - date_var.set((d - timedelta(days=1)).isoformat()) - mark_manual_date_change() - load_job_orders(from_user_date_change=True) - except ValueError: - date_var.set(date.today().isoformat()) - mark_manual_date_change() - 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()) - mark_manual_date_change() - load_job_orders(from_user_date_change=True) - except ValueError: - date_var.set(date.today().isoformat()) - mark_manual_date_change() - 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) - - # Track manual typing in date field as user date change - def on_date_entry_key(event): - mark_manual_date_change() - date_entry.bind("", on_date_entry_key) - - # 後一天 (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) - ) - # 列印機 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)) - printer_combo = ttk.Combobox( - right_frame, - textvariable=printer_var, - values=printer_options, - state="readonly", - width=14, - font=get_font(FONT_SIZE), - ) - 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 - 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() - - 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="COM:").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=14, 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("標簽機 COM 埠", 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) - - # 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: make scroll work when hovering over canvas or the list (inner/buttons) - 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 - 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 - ids_a = [x.get("id") for x in a] - ids_b = [x.get("id") for x in b] - return ids_a == ids_b - - 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") - # Use lotNo from API when present; fall back to generated Bxxxxx batch no. - raw_batch = batch_no(year, jo_id) if jo_id is not None else "—" - lot_no_val = jo.get("lotNo") - display_batch = (lot_no_val or raw_batch or "—").strip() - item_code = jo.get("itemCode") or "—" - item_name = jo.get("itemName") or "—" - req_qty = jo.get("reqQty") - qty_str = format_qty(req_qty) - # Three columns: lotNo/batch+數量 | item code (own column) | item name (≥2× width, wraps in column) - row = tk.Frame(inner, bg=BG_ROW, relief=tk.RAISED, bd=2, cursor="hand2", padx=12, pady=10) - row.pack(fill=tk.X, pady=4) - - left = tk.Frame(row, bg=BG_ROW) - left.pack(side=tk.LEFT, anchor=tk.NW) - batch_lbl = tk.Label( - left, - text=display_batch, - font=get_font(FONT_SIZE_BUTTONS), - bg=BG_ROW, - fg="black", - ) - batch_lbl.pack(anchor=tk.W) - qty_lbl = None - if qty_str != "—": - qty_lbl = tk.Label( - left, - text=f"數量:{qty_str}", - font=get_font(FONT_SIZE_QTY), - bg=BG_ROW, - fg="black", - ) - qty_lbl.pack(anchor=tk.W) - - # Column 2: item code only, bigger font, wraps in its own column - code_lbl = tk.Label( - row, - 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(side=tk.LEFT, anchor=tk.NW, padx=(12, 8)) - - # Column 3: item name only, bigger font, at least double width, wraps under its own column - name_lbl = tk.Label( - row, - 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(side=tk.LEFT, fill=tk.BOTH, expand=True, anchor=tk.NW) - - def _on_click(e, j=jo, b=display_batch, r=row): - 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: - count = ask_bag_count(root) - 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") - 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, - ) - n = 100 if count == -1 else count - label_text = (lot_no or b).strip() - try: - for i in range(n): - send_zpl_to_dataflex(ip, port, zpl) - if i < n - 1: - time.sleep(2) - msg = f"已送出列印:批次 {label_text} x {n} 張" if count != -1 else f"已送出列印:批次 {label_text} x {n} 張 (連續)" - set_status_message(msg, is_error=False) - except ConnectionRefusedError: - set_status_message(f"無法連線至 {ip}:{port},請確認印表機已開機且 IP 正確。", is_error=True) - except socket.timeout: - set_status_message(f"連線逾時 ({ip}:{port}),請檢查網路與連接埠。", is_error=True) - except OSError as err: - set_status_message(f"列印失敗:{err}", is_error=True) - elif printer_var.get() == "標簽機": - count = ask_label_count(root) - if count is not None: - if count == "C": - msg = "已選擇連續列印標簽" - else: - msg = f"將列印 {count} 張標簽" - set_status_message(msg, is_error=False) - on_job_order_click(j, b) - - for w in (row, left, batch_lbl, code_lbl, name_lbl): - w.bind("", _on_click) - w.bind("", _on_mousewheel) - w.bind("", _on_mousewheel) - w.bind("", _on_mousewheel) - if qty_lbl is not None: - qty_lbl.bind("", _on_click) - qty_lbl.bind("", _on_mousewheel) - qty_lbl.bind("", _on_mousewheel) - qty_lbl.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 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 - # Auto-reset date to today if user hasn't manually changed it recently (for 24x7 use) - if not from_user_date_change: - elapsed = time.time() - last_manual_date_change_ref[0] - today_str = date.today().isoformat() - if elapsed > DATE_AUTO_RESET_SEC and date_var.get().strip() != today_str: - date_var.set(today_str) - from_user_date_change = True # treat as date change to reset selection/scroll - 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 - data_changed = not _data_equal(old_data, data) - if data_changed or from_user_date_change: - # 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 - _build_list_from_data(data, plan_start, preserve_selection=preserve) - if from_user_date_change: - canvas.yview_moveto(0) - after_id_ref[0] = root.after(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() - - -if __name__ == "__main__": - main() diff --git a/python/Bag1.spec b/python/Bag1.spec deleted file mode 100644 index 8a784ef..0000000 --- a/python/Bag1.spec +++ /dev/null @@ -1,38 +0,0 @@ -# -*- mode: python ; coding: utf-8 -*- - - -a = Analysis( - ['Bag1.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='Bag1', - debug=False, - bootloader_ignore_signals=False, - strip=False, - upx=True, - 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/Bag2/Bag2.py b/python/Bag2/Bag2.py deleted file mode 100644 index 8cee33d..0000000 --- a/python/Bag2/Bag2.py +++ /dev/null @@ -1,1379 +0,0 @@ -#!/usr/bin/env python3 -""" -Bag1 – GUI to show FPSMS job orders by plan date. -Uses the public API GET /py/job-orders (no login required). -UI tuned for aged users: larger font, Traditional Chinese labels, prev/next date. - -Run: python Bag1.py -""" - -import json -import os -import select -import socket -import sys -import tempfile -import threading -import time -import tkinter as tk -from datetime import date, datetime, timedelta -from tkinter import messagebox, ttk -from typing import Callable, Optional - -import requests - -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 - import qrcode - _HAS_PIL_QR = True -except ImportError: - Image = None # type: ignore[assignment] - ImageDraw = None # type: ignore[assignment] - ImageFont = None # type: ignore[assignment] - qrcode = None # type: ignore[assignment] - _HAS_PIL_QR = False - -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__)) -# Bag2 has its own settings file so it doesn't share with Bag1. -SETTINGS_FILE = os.path.join(_SETTINGS_DIR, "bag2_settings.json") -LASER_COUNTER_FILE = os.path.join(_SETTINGS_DIR, "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 "9100").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 == "激光機": - 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 -FONT_SIZE_QTY = 12 # smaller for 數量 under batch no. -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: item code own column; item name at least double, wraps in its column -ITEM_CODE_WRAP = 140 # item code column width (long codes wrap under code only) -ITEM_NAME_WRAP = 640 # item name column (double width), wraps under name only - -# 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 -REFRESH_MS = 60 * 1000 # 60 seconds refresh when connected -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 _zpl_escape(s: str) -> str: - """Escape text for ZPL ^FD...^FS (backslash and caret).""" - return s.replace("\\", "\\\\").replace("^", "\\^") - - -def generate_zpl_dataflex( - batch_no: str, - item_code: str, - item_name: str, - lot_no: 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: Batch/lot (left), item code (right). - Label and QR use lotNo from API when present, else batch_no (Bxxxxx). - """ - 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) - qr_value = label_line if label_line else batch_no.strip() - return f"""^XA -^CI28 -^PW700 -^LL500 -^PO N -^FO10,20 -^BQR,4,7^FDQA,{qr_value}^FS -^FO170,20 -^A@R,72,72,{font_regular}^FD{desc}^FS -^FO0,290 -^A@R,72,72,{font_regular}^FD{label_esc}^FS -^FO75,290 -^A@R,88,88,{font_bold}^FD{code}^FS -^XZ""" - - -def generate_zpl_label_small( - batch_no: str, - item_code: str, - item_name: str, - item_id: Optional[int] = None, - stock_in_line_id: Optional[int] = None, - lot_no: Optional[str] = None, - font: str = "MingLiUHKSCS", -) -> str: - """ - ZPL for 標簽機. Row 1: item name. Row 2: QR left | item code + lot no (or batch) right. - QR contains {"itemId": xxx, "stockInLineId": xxx} when both present; else batch_no. - Unicode (^CI28); font set for Big-5 (e.g. MingLiUHKSCS). - """ - desc = _zpl_escape((item_name or "—").strip()) - code = _zpl_escape((item_code or "—").strip()) - label_line2 = (lot_no or batch_no or "—").strip() - label_line2_esc = _zpl_escape(label_line2) - if item_id is not None and stock_in_line_id is not None: - qr_data = _zpl_escape(json.dumps({"itemId": item_id, "stockInLineId": stock_in_line_id})) - else: - qr_data = f"QA,{batch_no}" - return f"""^XA -^CI28 -^PW500 -^LL500 -^FO10,15 -^FB480,3,0,L,0 -^A@N,38,38,{font}^FD{desc}^FS -^FO10,110 -^BQN,2,6^FD{qr_data}^FS -^FO150,110 -^A@N,48,48,{font}^FD{code}^FS -^FO150,175 -^A@N,40,40,{font}^FD{label_line2_esc}^FS -^XZ""" - - -# Label image size (pixels) for 標簽機 image printing; words drawn bigger for readability -LABEL_IMAGE_W = 520 -LABEL_IMAGE_H = 520 -LABEL_PADDING = 20 -LABEL_FONT_NAME_SIZE = 38 # item name (bigger) -LABEL_FONT_CODE_SIZE = 44 # item code (bigger) -LABEL_FONT_BATCH_SIZE = 30 # batch/lot line -LABEL_QR_SIZE = 140 # QR module size in pixels - - -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 Traditional Chinese fonts on Windows - for name in ("Microsoft JhengHei UI", "Microsoft JhengHei", "MingLiU", "SimHei", "Microsoft YaHei", "SimSun"): - try: - return ImageFont.truetype(name, size) - except (OSError, IOError): - continue - try: - return ImageFont.load_default() - except Exception: - return None - - -def render_label_to_image( - batch_no: str, - item_code: str, - item_name: str, - item_id: Optional[int] = None, - stock_in_line_id: Optional[int] = None, - lot_no: Optional[str] = None, -) -> "Image.Image": - """ - Render 標簽機 label as a PIL Image (white bg, black text + QR). - Use this image for printing so Chinese displays correctly; words are drawn bigger. - Requires Pillow and qrcode. Raises RuntimeError if not available. - """ - if not _HAS_PIL_QR or Image is None or qrcode is None: - raise RuntimeError("Pillow and qrcode are required for image labels. Run: pip install Pillow qrcode[pil]") - img = Image.new("RGB", (LABEL_IMAGE_W, LABEL_IMAGE_H), "white") - draw = ImageDraw.Draw(img) - # QR payload (same as ZPL) - if item_id is not None and stock_in_line_id is not None: - qr_data = json.dumps({"itemId": item_id, "stockInLineId": stock_in_line_id}) - else: - qr_data = f"QA,{batch_no}" - # Draw QR top-left area - qr = qrcode.QRCode(box_size=4, border=2) - qr.add_data(qr_data) - qr.make(fit=True) - qr_img = qr.make_image(fill_color="black", back_color="white") - _resample = getattr(Image, "Resampling", Image).NEAREST - qr_img = qr_img.resize((LABEL_QR_SIZE, LABEL_QR_SIZE), _resample) - img.paste(qr_img, (LABEL_PADDING, LABEL_PADDING)) - # Fonts (bigger for readability) - font_name = _get_chinese_font(LABEL_FONT_NAME_SIZE) - font_code = _get_chinese_font(LABEL_FONT_CODE_SIZE) - font_batch = _get_chinese_font(LABEL_FONT_BATCH_SIZE) - x_right = LABEL_PADDING + LABEL_QR_SIZE + LABEL_PADDING - y_line = LABEL_PADDING - # Line 1: item name (wrap within remaining width) - name_str = (item_name or "—").strip() - max_name_w = LABEL_IMAGE_W - x_right - LABEL_PADDING - if font_name: - # Wrap by text width (Pillow 8+ textbbox) or by char count - def _wrap_text(text: str, font, max_width: int) -> list: - if hasattr(draw, "textbbox"): - words = list(text) - lines, line = [], [] - for c in words: - line.append(c) - bbox = draw.textbbox((0, 0), "".join(line), font=font) - if bbox[2] - bbox[0] > max_width and len(line) > 1: - lines.append("".join(line[:-1])) - line = [line[-1]] - if line: - lines.append("".join(line)) - return lines - # Fallback: ~12 chars per line for Chinese - chunk = 12 - return [text[i : i + chunk] for i in range(0, len(text), chunk)] - lines = _wrap_text(name_str, font_name, max_name_w) - for i, ln in enumerate(lines): - draw.text((x_right, y_line + i * (LABEL_FONT_NAME_SIZE + 4)), ln, font=font_name, fill="black") - y_line += len(lines) * (LABEL_FONT_NAME_SIZE + 4) + 8 - else: - draw.text((x_right, y_line), name_str[:30], fill="black") - y_line += LABEL_FONT_NAME_SIZE + 12 - # Item code (bigger) - code_str = (item_code or "—").strip() - if font_code: - draw.text((x_right, y_line), code_str, font=font_code, fill="black") - else: - draw.text((x_right, y_line), code_str, fill="black") - y_line += LABEL_FONT_CODE_SIZE + 6 - # Batch/lot line - batch_str = (lot_no or batch_no or "—").strip() - if font_batch: - draw.text((x_right, y_line), batch_str, font=font_batch, fill="black") - else: - draw.text((x_right, y_line), batch_str, fill="black") - return img - - -def 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") - # 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.") - dc = win32ui.CreateDC() - dc.CreatePrinterDC(dest) - dc.StartDoc("FPSMS Label") - dc.StartPage() - try: - mem_dc = win32ui.CreateDCFromHandle(win32gui.CreateCompatibleDC(dc.GetSafeHdc())) - # PyCBitmap.FromHandle works across pywin32 versions - bmp = getattr(win32ui, "CreateBitmapFromHandle", lambda h: win32ui.PyCBitmap.FromHandle(h))(hbm) - mem_dc.SelectObject(bmp) - bmp_w = pil_image.width - bmp_h = pil_image.height - dc.StretchBlt((0, 0), (bmp_w, bmp_h), mem_dc, (0, 0), (bmp_w, bmp_h), win32con.SRCCOPY) - finally: - win32gui.DeleteObject(hbm) - dc.EndPage() - dc.EndDoc() - finally: - try: - os.unlink(tmp_bmp) - except OSError: - pass - - -def send_zpl_to_dataflex(ip: str, port: int, zpl: str) -> None: - """Send ZPL to DataFlex printer via TCP. Raises on connection/send error.""" - sock = socket.socket(socket.AF_INET, socket.SOCK_STREAM) - sock.settimeout(DATAFLEX_SEND_TIMEOUT) - try: - sock.connect((ip, port)) - sock.sendall(zpl.encode("utf-8")) - finally: - sock.close() - - -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 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) - - -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, -) -> tuple[bool, str]: - """ - Send to laser. Standard format: {"itemId": xxx, "stockInLineId": xxx}. - 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; else fallback: 0;item_code;item_name;; - Returns (success, message). - """ - if item_id is not None and stock_in_line_id is not None: - reply = json.dumps({"itemId": item_id, "stockInLineId": stock_in_line_id}) - else: - code_str = (item_code or "").strip().replace(";", ",") - name_str = (item_name or "").strip().replace(";", ",") - reply = f"0;{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, -) -> 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) - 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) - return ok2, msg2 - - -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 set_row_highlight(row_frame: tk.Frame, selected: bool) -> None: - """Set row and all its child widgets to selected or normal background.""" - bg = BG_ROW_SELECTED if selected else BG_ROW - row_frame.configure(bg=bg) - for w in row_frame.winfo_children(): - if isinstance(w, (tk.Frame, tk.Label)): - w.configure(bg=bg) - for c in w.winfo_children(): - if isinstance(c, tk.Label): - c.configure(bg=bg) - - -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_laser_count(parent: tk.Tk) -> Optional[int]: - """ - When printer is 激光機, ask how many times to send (like DataFlex). - Returns count (>= 1), or -1 for continuous (C), or None if cancelled. - """ - result: list = [None] - count_ref = [0] - continuous_ref = [False] - - win = tk.Toplevel(parent) - win.title("激光機送出數量") - win.geometry("580x230") # wider so 連續 (C) button is fully visible - win.transient(parent) - win.grab_set() - win.configure(bg=BG_TOP) - ttk.Label(win, text="送出多少次?", font=get_font(FONT_SIZE)).pack(pady=(12, 4)) - count_lbl = tk.Label(win, text="數量: 0", font=get_font(FONT_SIZE), bg=BG_TOP) - count_lbl.pack(pady=4) - - def update_display(): - if continuous_ref[0]: - count_lbl.configure(text="數量: 連續 (C)") - else: - count_lbl.configure(text=f"數量: {count_ref[0]}") - - def add(n: int): - continuous_ref[0] = False - count_ref[0] = max(0, count_ref[0] + n) - update_display() - - def set_continuous(): - continuous_ref[0] = True - update_display() - - def confirm(): - if continuous_ref[0]: - result[0] = -1 - elif count_ref[0] < 1: - messagebox.showwarning("激光機", "請先按 +50、+10、+5 或 +1 選擇數量。", parent=win) - return - else: - result[0] = count_ref[0] - win.destroy() - - btn_row = tk.Frame(win, bg=BG_TOP) - btn_row.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_row, text=label, command=make_add(value), width=8).pack(side=tk.LEFT, padx=4) - ttk.Button(btn_row, text="連續 (C)", command=set_continuous, width=12).pack(side=tk.LEFT, padx=4) - ttk.Button(win, text="確認送出", command=confirm, width=14).pack(pady=12) - win.protocol("WM_DELETE_WINDOW", win.destroy) - win.wait_window() - return result[0] - - -def ask_label_count(parent: tk.Tk) -> Optional[int]: - """ - When printer is 標簽機, ask how many labels to print (same style as 打袋機): - +50, +10, +5, +1, C (continuous), then 確認送出. - Returns count (>= 1), or -1 for continuous (C), or None if cancelled. - """ - result: list[Optional[int]] = [None] - count_ref = [0] - continuous_ref = [False] - - win = tk.Toplevel(parent) - win.title("標簽列印數量") - # Wider so all buttons (especially 連續) are fully visible - win.geometry("580x230") - win.transient(parent) - win.grab_set() - win.configure(bg=BG_TOP) - ttk.Label(win, text="列印多少張標簽?", font=get_font(FONT_SIZE)).pack(pady=(12, 4)) - count_lbl = tk.Label(win, text="列印數量: 0", font=get_font(FONT_SIZE), bg=BG_TOP) - count_lbl.pack(pady=4) - - def update_display(): - if continuous_ref[0]: - count_lbl.configure(text="列印數量: 連續 (C)") - else: - count_lbl.configure(text=f"列印數量: {count_ref[0]}") - - def add(n: int): - continuous_ref[0] = False - count_ref[0] = max(0, count_ref[0] + n) - update_display() - - def set_continuous(): - continuous_ref[0] = True - update_display() - - def confirm(): - if continuous_ref[0]: - result[0] = -1 - elif count_ref[0] < 1: - messagebox.showwarning("標簽機", "請先按 +50、+10、+5 或 +1 選擇數量。", parent=win) - return - else: - result[0] = count_ref[0] - 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(btn_row1, text="連續 (C)", command=set_continuous, width=12).pack(side=tk.LEFT, padx=4) - - ttk.Button(win, text="確認送出", command=confirm, width=14).pack(pady=12) - win.protocol("WM_DELETE_WINDOW", win.destroy) - win.wait_window() - return result[0] - -def ask_bag_count(parent: tk.Tk) -> Optional[int]: - """ - When printer is 打袋機 DataFlex, ask how many bags: +50, +10, +5, +1, C, then 確認送出. - Returns count (>= 1), or -1 for continuous (C), or None if cancelled. - """ - result: list[Optional[int]] = [None] - count_ref = [0] - continuous_ref = [False] - - win = tk.Toplevel(parent) - win.title("打袋列印數量") - win.geometry("580x230") # wider so 連續 (C) button is fully visible - win.transient(parent) - win.grab_set() - win.configure(bg=BG_TOP) - ttk.Label(win, text="列印多少個袋?", font=get_font(FONT_SIZE)).pack(pady=(12, 4)) - count_lbl = tk.Label(win, text="列印數量: 0", font=get_font(FONT_SIZE), bg=BG_TOP) - count_lbl.pack(pady=4) - - def update_display(): - if continuous_ref[0]: - count_lbl.configure(text="列印數量: 連續 (C)") - else: - count_lbl.configure(text=f"列印數量: {count_ref[0]}") - - def add(n: int): - continuous_ref[0] = False - count_ref[0] = max(0, count_ref[0] + n) - update_display() - - def set_continuous(): - continuous_ref[0] = True - update_display() - - def confirm(): - if continuous_ref[0]: - result[0] = -1 - elif count_ref[0] < 1: - messagebox.showwarning("打袋機", "請先按 +50、+10、+5 或 +1 選擇數量。", parent=win) - return - else: - result[0] = count_ref[0] - 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(btn_row1, text="連續 (C)", command=set_continuous, width=12).pack(side=tk.LEFT, padx=4) - - ttk.Button(win, text="確認送出", command=confirm, width=14).pack(pady=12) - win.protocol("WM_DELETE_WINDOW", win.destroy) - win.wait_window() - return result[0] - - -def main() -> None: - settings = load_settings() - base_url_ref = [build_base_url(settings["api_ip"], settings["api_port"])] - - root = tk.Tk() - root.title("FP-MTMS Bag v1.1 打袋機") - 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) - 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_thread_ref: list = [None] - laser_stop_ref: list = [None] - - # 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) - ) - # 列印機 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)) - printer_combo = ttk.Combobox( - right_frame, - textvariable=printer_var, - values=printer_options, - state="readonly", - width=14, - font=get_font(FONT_SIZE), - ) - 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 - 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_stop_ref[0] is not None: - laser_stop_ref[0].set() - if laser_conn_ref[0] is not None: - try: - laser_conn_ref[0].close() - except Exception: - pass - laser_conn_ref[0] = None - - 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) - - # 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: make scroll work when hovering over canvas or the list (inner/buttons) - 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 - 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 - ids_a = [x.get("id") for x in a] - ids_b = [x.get("id") for x in b] - return ids_a == ids_b - - 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 "—" - item_code = jo.get("itemCode") or "—" - item_name = jo.get("itemName") or "—" - req_qty = jo.get("reqQty") - qty_str = format_qty(req_qty) - # Three columns: lotNo/batch+數量 | item code (own column) | item name (≥2× width, wraps in column) - row = tk.Frame(inner, bg=BG_ROW, relief=tk.RAISED, bd=2, cursor="hand2", padx=12, pady=10) - row.pack(fill=tk.X, pady=4) - - left = tk.Frame(row, bg=BG_ROW) - left.pack(side=tk.LEFT, anchor=tk.NW) - batch_lbl = tk.Label( - left, - text=batch, - font=get_font(FONT_SIZE_BUTTONS), - bg=BG_ROW, - fg="black", - ) - batch_lbl.pack(anchor=tk.W) - qty_lbl = None - if qty_str != "—": - qty_lbl = tk.Label( - left, - text=f"數量:{qty_str}", - font=get_font(FONT_SIZE_QTY), - bg=BG_ROW, - fg="black", - ) - qty_lbl.pack(anchor=tk.W) - - # Column 2: item code only, bigger font, wraps in its own column - code_lbl = tk.Label( - row, - 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(side=tk.LEFT, anchor=tk.NW, padx=(12, 8)) - - # Column 3: item name only, bigger font, at least double width, wraps under its own column - name_lbl = tk.Label( - row, - 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(side=tk.LEFT, fill=tk.BOTH, expand=True, anchor=tk.NW) - - def _on_click(e, j=jo, b=batch, r=row): - 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: - count = ask_bag_count(root) - if count is not None: - item_code = j.get("itemCode") or "—" - item_name = j.get("itemName") or "—" - lot_no = j.get("lotNo") - zpl = generate_zpl_dataflex(b, item_code, item_name, lot_no=lot_no) - n = 100 if count == -1 else count - label_text = (lot_no or b).strip() - try: - for i in range(n): - send_zpl_to_dataflex(ip, port, zpl) - if i < n - 1: - time.sleep(2) - msg = f"已送出列印:批次 {label_text} x {n} 張" if count != -1 else f"已送出列印:批次 {label_text} x {n} 張 (連續)" - set_status_message(msg, is_error=False) - except ConnectionRefusedError: - set_status_message(f"無法連線至 {ip}:{port},請確認印表機已開機且 IP 正確。", is_error=True) - except socket.timeout: - set_status_message(f"連線逾時 ({ip}:{port}),請檢查網路與連接埠。", is_error=True) - except OSError as err: - set_status_message(f"列印失敗:{err}", is_error=True) - 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) - 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 = 100 if count == -1 else count - try: - # Prefer image printing so Chinese displays correctly; words are bigger - if _HAS_PIL_QR and os.name == "nt" and not com.upper().startswith("COM"): - 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, - ) - for i in range(n): - send_image_to_label_printer(com, label_img) - if i < n - 1: - time.sleep(0.5) - else: - zpl = generate_zpl_label_small( - b, item_code, item_name, - item_id=item_id, stock_in_line_id=stock_in_line_id, - lot_no=lot_no, - ) - for i in range(n): - send_zpl_to_label_printer(com, zpl) - if i < n - 1: - time.sleep(0.5) - msg = f"已送出列印:{n} 張標簽" if count != -1 else f"已送出列印:{n} 張標簽 (連續)" - messagebox.showinfo("標簽機", msg) - 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: - count = ask_laser_count(root) - if count is not None: - 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 "" - n = 100 if count == -1 else count - sent = 0 - 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_val, item_name_val, - ) - if ok: - sent += 1 - else: - set_status_message(f"已送出 {sent} 次,第 {sent + 1} 次失敗:{msg}", is_error=True) - break - if i < n - 1: - time.sleep(0.2) - if sent == n: - set_status_message(f"已送出激光機:{sent} 次", is_error=False) - - for w in (row, left, batch_lbl, code_lbl, name_lbl): - w.bind("", _on_click) - w.bind("", _on_mousewheel) - w.bind("", _on_mousewheel) - w.bind("", _on_mousewheel) - if qty_lbl is not None: - qty_lbl.bind("", _on_click) - qty_lbl.bind("", _on_mousewheel) - qty_lbl.bind("", _on_mousewheel) - qty_lbl.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 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 - data_changed = not _data_equal(old_data, data) - if data_changed or from_user_date_change: - # 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 - _build_list_from_data(data, plan_start, preserve_selection=preserve) - if from_user_date_change: - canvas.yview_moveto(0) - after_id_ref[0] = root.after(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() - - -if __name__ == "__main__": - main() \ No newline at end of file diff --git a/python/Bag2/bag2_settings.json b/python/Bag2/bag2_settings.json deleted file mode 100644 index a92329f..0000000 --- a/python/Bag2/bag2_settings.json +++ /dev/null @@ -1,9 +0,0 @@ -{ - "api_ip": "10.10.0.81", - "api_port": "8090", - "dabag_ip": "192.168.17.27", - "dabag_port": "3008", - "laser_ip": "192.168.17.10", - "laser_port": "45678", - "label_com": "TSC TTP-246M Pro" -} \ No newline at end of file diff --git a/python/Bag2/requirements.txt b/python/Bag2/requirements.txt deleted file mode 100644 index 52b3367..0000000 --- a/python/Bag2/requirements.txt +++ /dev/null @@ -1,5 +0,0 @@ -# Python dependencies for FPSMS backend integration -requests>=2.28.0 -pyserial>=3.5 -Pillow>=9.0.0 -qrcode[pil]>=7.0 diff --git a/python/bag1_settings.json b/python/bag1_settings.json deleted file mode 100644 index a580c57..0000000 --- a/python/bag1_settings.json +++ /dev/null @@ -1,9 +0,0 @@ -{ - "api_ip": "127.0.0.1", - "api_port": "8090", - "dabag_ip": "192.168.17.27", - "dabag_port": "3008", - "laser_ip": "192.168.17.10", - "laser_port": "45678", - "label_com": "COM2" -} \ No newline at end of file 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 ad9744b..f81fc8f 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 @@ -299,10 +299,10 @@ open class PlasticBagPrinterService( val addedEntries = linkedSetOf() exportItems.forEach { (codeLower, itemId, stockInLineId) -> val imageTemplate = loadOnPack2030_2ImageTemplateOrNull(codeLower) ?: run { - logger.warn("OnPack text ZIP: missing classpath template onpack2030_2/{}.image", codeLower) + logger.warn("OnPack text ZIP: missing classpath template onpack2030_2/{}.image", codeLower.uppercase()) return@forEach } - val imageFileName = "$codeLower.image" + val imageFileName = "${codeLower.uppercase()}.image" val imageContent = withOnPackStaticQrText(codeLower, imageTemplate, itemId, stockInLineId) if (addedEntries.add(imageFileName)) { addToZip(zos, imageFileName, imageContent) @@ -316,7 +316,7 @@ open class PlasticBagPrinterService( } addToZip(zos, bmpName, bmpBytes) } - val jobFileName = "$codeLower.job" + val jobFileName = "${codeLower.lowercase()}.job" loadOnPack2030_2AssetOrNull(jobFileName)?.let { jobBytes -> if (addedEntries.add(jobFileName)) { addToZip(zos, jobFileName, jobBytes) @@ -361,8 +361,9 @@ open class PlasticBagPrinterService( return resource.inputStream.use { it.readBytes() } } + /** Template files on classpath use uppercase code, e.g. `onpack2030_2/PP1175.image`. */ private fun loadOnPack2030_2ImageTemplateOrNull(codeLower: String): ByteArray? { - val resourcePath = "onpack2030_2/${codeLower}.image" + val resourcePath = "onpack2030_2/${codeLower.uppercase()}.image" val resource = ClassPathResource(resourcePath) if (!resource.exists()) return null return resource.inputStream.use { it.readBytes() }