From 2610db9764d1d337fe7806ff9642af428f8df04d Mon Sep 17 00:00:00 2001 From: Fai Luk Date: Tue, 17 Mar 2026 23:48:02 +0800 Subject: [PATCH] update the bag printer python as Bag2 --- python/Bag2/Bag2.py | 1379 ++++++++++++++++++++++++++++++++ python/Bag2/bag2_settings.json | 9 + python/Bag2/requirements.txt | 5 + python/README.md | 54 -- python/build_exe.bat | 20 - python/fetch_job_orders.py | 80 -- python/label_zpl.py | 97 --- python/requirements-build.txt | 4 - 8 files changed, 1393 insertions(+), 255 deletions(-) create mode 100644 python/Bag2/Bag2.py create mode 100644 python/Bag2/bag2_settings.json create mode 100644 python/Bag2/requirements.txt delete mode 100644 python/README.md delete mode 100644 python/build_exe.bat delete mode 100644 python/fetch_job_orders.py delete mode 100644 python/label_zpl.py delete mode 100644 python/requirements-build.txt diff --git a/python/Bag2/Bag2.py b/python/Bag2/Bag2.py new file mode 100644 index 0000000..8cee33d --- /dev/null +++ b/python/Bag2/Bag2.py @@ -0,0 +1,1379 @@ +#!/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 new file mode 100644 index 0000000..a92329f --- /dev/null +++ b/python/Bag2/bag2_settings.json @@ -0,0 +1,9 @@ +{ + "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 new file mode 100644 index 0000000..52b3367 --- /dev/null +++ b/python/Bag2/requirements.txt @@ -0,0 +1,5 @@ +# 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/README.md b/python/README.md deleted file mode 100644 index 22871e4..0000000 --- a/python/README.md +++ /dev/null @@ -1,54 +0,0 @@ -# Python scripts for FPSMS backend - -This folder holds Python programs that integrate with the FPSMS backend (e.g. calling the public `/py` API). - -## Setup - -```bash -cd python -pip install -r requirements.txt -``` - -## Configuration - -Set the backend base URL (optional, default below): - -- Environment: `FPSMS_BASE_URL` (default: `http://localhost:8090/api` — includes context path `/api`) -- Or edit the default in each script. - -## Scripts - -| Script | Description | -|--------|-------------| -| `Bag1.py` | **GUI**: date selector (default today) and job orders as buttons; click shows "Clicked on Job Order code XXXX item xxxx". Run: `python Bag1.py` | -| `fetch_job_orders.py` | CLI: fetches job orders by plan date from `GET /py/job-orders` | -| `label_zpl.py` | ZPL label generator (90° rotated, UTF-8 Chinese, QR). `generate_zpl(batch_no, item_code, chinese_desc)`, `send_zpl(zpl, host, port)`. Run: `python label_zpl.py` to print one test label. | - -## Building Bag1 as a standalone .exe - -To distribute Bag1 to customer PCs **without giving them source code or requiring Python**: - -1. On your development PC (with Python installed), open a terminal in the `python` folder. -2. Install build dependencies: - - ```bash - pip install -r requirements-build.txt - ``` - -3. Run the build script: - - ```bash - build_exe.bat - ``` - - Or run PyInstaller directly: - - ```bash - pyinstaller --onefile --windowed --name Bag1 Bag1.py - ``` - -4. The executable is created at `dist\Bag1.exe`. Copy **only** `Bag1.exe` to the customer computer and run it; no Python or source code is needed. The app will save its settings (`bag1_settings.json`) in the same folder as the exe. - -## Adding new scripts - -Add new `.py` files here and list them in this README. Use `requirements.txt` for any new dependencies. diff --git a/python/build_exe.bat b/python/build_exe.bat deleted file mode 100644 index 6ccd061..0000000 --- a/python/build_exe.bat +++ /dev/null @@ -1,20 +0,0 @@ -@echo off -REM Build Bag1.exe (single file, no console window). -REM Run from this folder: build_exe.bat -REM Requires: pip install -r requirements-build.txt - -set SCRIPT=Bag1.py -set NAME=Bag1 - -if not exist "%SCRIPT%" ( - echo Error: %SCRIPT% not found. Run from the python folder. - exit /b 1 -) - -pip install -r requirements-build.txt -pyinstaller --onefile --windowed --name "%NAME%" "%SCRIPT%" - -echo. -echo Done. Exe is in: dist\%NAME%.exe -echo Copy dist\%NAME%.exe to the customer PC and run it (no Python needed). -pause diff --git a/python/fetch_job_orders.py b/python/fetch_job_orders.py deleted file mode 100644 index 7703cb5..0000000 --- a/python/fetch_job_orders.py +++ /dev/null @@ -1,80 +0,0 @@ -#!/usr/bin/env python3 -""" -Fetch job orders from FPSMS backend by plan date. -Uses the public API GET /py/job-orders (no login required). - -Usage: - python fetch_job_orders.py # today - python fetch_job_orders.py 2026-02-24 # specific date - python fetch_job_orders.py --date 2026-02-24 -""" - -import argparse -import os -import sys -from datetime import date -from typing import List, Optional - -import requests - -DEFAULT_BASE_URL = os.environ.get("FPSMS_BASE_URL", "http://localhost:8090/api") - - -def fetch_job_orders(base_url: str, plan_start: Optional[date]) -> List[dict]: - """Call GET /py/job-orders and return the JSON list.""" - url = f"{base_url.rstrip('/')}/py/job-orders" - params = {} - if plan_start is not None: - params["planStart"] = plan_start.isoformat() - resp = requests.get(url, params=params, timeout=30) - resp.raise_for_status() - return resp.json() - - -def main() -> None: - parser = argparse.ArgumentParser(description="Fetch job orders from FPSMS by plan date") - parser.add_argument( - "date", - nargs="?", - type=str, - default=None, - help="Plan date (yyyy-MM-dd). Default: today", - ) - parser.add_argument( - "--date", - dest="date_alt", - type=str, - default=None, - help="Plan date (yyyy-MM-dd)", - ) - parser.add_argument( - "--base-url", - type=str, - default=DEFAULT_BASE_URL, - help=f"Backend base URL (default: {DEFAULT_BASE_URL})", - ) - args = parser.parse_args() - - plan_str = args.date_alt or args.date - if plan_str: - try: - plan_start = date.fromisoformat(plan_str) - except ValueError: - print(f"Invalid date: {plan_str}. Use yyyy-MM-dd.", file=sys.stderr) - sys.exit(1) - else: - plan_start = date.today() - - try: - data = fetch_job_orders(args.base_url, plan_start) - except requests.RequestException as e: - print(f"Request failed: {e}", file=sys.stderr) - sys.exit(1) - - print(f"Job orders for planStart={plan_start} ({len(data)} items)") - for jo in data: - print(f" id={jo.get('id')} code={jo.get('code')} itemCode={jo.get('itemCode')} itemName={jo.get('itemName')} reqQty={jo.get('reqQty')}") - - -if __name__ == "__main__": - main() diff --git a/python/label_zpl.py b/python/label_zpl.py deleted file mode 100644 index 0544660..0000000 --- a/python/label_zpl.py +++ /dev/null @@ -1,97 +0,0 @@ -#!/usr/bin/env python3 -""" -ZPL label generator and TCP send for Zebra label printer (標簽機). -- Rotated 90° clockwise for ~53 mm media width -- UTF-8 (^CI28) for Chinese -- Large fonts, QR code mag 6 - -Standalone: python label_zpl.py -Or import generate_zpl() / send_zpl() and call from Bag1. -""" - -import socket - -# Default printer (override via args or Bag1 settings) -DEFAULT_PRINTER_IP = "192.168.17.27" -DEFAULT_PRINTER_PORT = 3008 -SOCKET_TIMEOUT = 10 - - -def generate_zpl( - batch_no: str, - item_code: str = "PP2238-02", - chinese_desc: str = "(餐廳用)凍咖啡底P+10(0.91L包)", -) -> str: - """ - Generates ZPL label: - - Rotated 90° clockwise to fit ~53 mm media width - - UTF-8 mode (^CI28) for correct Chinese display - - Larger fonts - - Bigger QR code (mag 6) - """ - return f"""^XA -^PW420 ^# Fits ~53 mm width (~420 dots @ 203 dpi) -^LL780 ^# Taller label after rotation + bigger elements -^PO N ^# Normal — change to ^POI if upside-down - -^CI28 ^# Enable UTF-8 / Unicode for Chinese (critical fix for boxes) - -^FO70,70 -^A@R,60,60,E:SIMSUN.FNT^FD{chinese_desc}^FS - ^# Very large Chinese text, rotated - -^FO220,70 -^A0R,50,50^FD{item_code}^FS - ^# Larger item code - -^FO310,70 -^A0R,45,45^FDBatch: {batch_no}^FS - ^# Larger batch text - -^FO150,420 -^BQN,2,6^FDQA,{batch_no}^FS - ^# Bigger QR code (magnification 6), lower position for space - -^XZ""" - - -def send_zpl( - zpl: str, - host: str = DEFAULT_PRINTER_IP, - port: int = DEFAULT_PRINTER_PORT, - timeout: float = SOCKET_TIMEOUT, -) -> None: - """Send ZPL to printer over TCP. Raises on connection/send error.""" - sock = socket.socket(socket.AF_INET, socket.SOCK_STREAM) - sock.settimeout(timeout) - sock.connect((host, port)) - sock.sendall(zpl.encode("utf-8")) - sock.close() - - -# ──────────────────────────────────────────────── -# Example usage — prints one label -# ──────────────────────────────────────────────── - -if __name__ == "__main__": - test_batch = "2025121209" - zpl = generate_zpl(test_batch) - - print("Sending ZPL (90° rotated, UTF-8 Chinese, bigger QR):") - print("-" * 90) - print(zpl) - print("-" * 90) - - try: - send_zpl(zpl) - print("Label sent successfully!") - print("→ Check Chinese — should show real characters (not 口口 or symbols)") - print("→ QR is now bigger (mag 6) — test scan with phone") - print("→ If upside-down: edit ^PO N → ^POI") - print("→ If still boxes: SimSun font may be missing — reinstall via Zebra Setup Utilities") - except ConnectionRefusedError: - print(f"Cannot connect to {DEFAULT_PRINTER_IP}:{DEFAULT_PRINTER_PORT} — printer off or wrong IP?") - except socket.timeout: - print("Connection timeout — check printer/network/port") - except Exception as e: - print(f"Error: {e}") diff --git a/python/requirements-build.txt b/python/requirements-build.txt deleted file mode 100644 index f3bfc9b..0000000 --- a/python/requirements-build.txt +++ /dev/null @@ -1,4 +0,0 @@ -# Install with: pip install -r requirements-build.txt -# Used only for building the .exe (PyInstaller). --r requirements.txt -pyinstaller>=6.0.0