From cd43da3d44bce448460922bb8b83564648b889d6 Mon Sep 17 00:00:00 2001 From: "DESKTOP-064TTA1\\Fai LUK" Date: Tue, 17 Mar 2026 16:16:12 +0800 Subject: [PATCH] demo for 17Mar job order Bag1 --- python/Bag1.py | 729 +++++++++---------------------------------------- 1 file changed, 125 insertions(+), 604 deletions(-) diff --git a/python/Bag1.py b/python/Bag1.py index 45a1478..bb4d959 100644 --- a/python/Bag1.py +++ b/python/Bag1.py @@ -9,16 +9,13 @@ 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 datetime import date, timedelta from tkinter import messagebox, ttk -from typing import Callable, Optional +from typing import Optional import requests @@ -27,28 +24,6 @@ try: 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): @@ -56,17 +31,15 @@ if getattr(sys, "frozen", False): else: _SETTINGS_DIR = os.path.dirname(os.path.abspath(__file__)) SETTINGS_FILE = os.path.join(_SETTINGS_DIR, "bag1_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", + "laser_ip": "", + "laser_port": "9100", + "label_com": "COM3", } @@ -110,7 +83,7 @@ def try_printer_connection(printer_name: str, sett: dict) -> bool: return False if printer_name == "激光機": ip = (sett.get("laser_ip") or "").strip() - port_str = (sett.get("laser_port") or "45678").strip() + port_str = (sett.get("laser_port") or "9100").strip() if not ip: return False try: @@ -121,26 +94,13 @@ def try_printer_connection(printer_name: str, sett: dict) -> bool: 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 + com = (sett.get("label_com") or "").strip() + if not com: + return False try: - ser = serial.Serial(target, timeout=1) + ser = serial.Serial(com, timeout=1) ser.close() return True except (serial.SerialException, OSError): @@ -151,7 +111,8 @@ def try_printer_connection(printer_name: str, sett: dict) -> bool: 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_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) @@ -174,6 +135,7 @@ 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: @@ -181,235 +143,60 @@ def _zpl_escape(s: str) -> str: 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: """ - Generate ZPL for DataFlex label (53 mm media, 90° rotated). - QR contains {"itemId": xxx, "stockInLineId": xxx} when both present; else QA,{batch_no}. - Label shows lotNo when present, else batch_no. - """ - desc = _zpl_escape((item_name or "—").strip()) - code = _zpl_escape((item_code or "—").strip()) - label_line2 = (lot_no or batch_no or "—").strip() - batch = _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 -^PW420 -^LL780 -^PO N -^CI28 -^FO70,70 -^A@R,60,60,{font_regular}^FD{desc}^FS -^FO220,70 -^A@R,50,50,{font_bold}^FD{code}^FS -^FO310,70 -^A@R,45,45,{font_bold}^FD批次: {batch}^FS -^FO150,420 -^BQN,2,6^FD{qr_data}^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). + 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_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}" + 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 -^PW500 +^PW700 ^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 +^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""" -# 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) @@ -421,206 +208,6 @@ def send_zpl_to_dataflex(ip: str, port: int, zpl: str) -> None: 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: @@ -635,8 +222,9 @@ def format_qty(val) -> str: 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}" + """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: @@ -668,39 +256,33 @@ def set_row_highlight(row_frame: tk.Frame, selected: bool) -> None: 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}', - ) + """Row click handler (highlight already set; no popup).""" -def ask_laser_count(parent: tk.Tk) -> Optional[int]: +def ask_bag_count(parent: tk.Tk) -> Optional[int]: """ - When printer is 激光機, ask how many times to send (like DataFlex). + 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 = [None] + result: list[Optional[int]] = [None] count_ref = [0] continuous_ref = [False] win = tk.Toplevel(parent) - win.title("激光機送出數量") + 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) + 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)") + count_lbl.configure(text="列印數量: 連續 (C)") else: - count_lbl.configure(text=f"數量: {count_ref[0]}") + count_lbl.configure(text=f"列印數量: {count_ref[0]}") def add(n: int): continuous_ref[0] = False @@ -715,19 +297,20 @@ def ask_laser_count(parent: tk.Tk) -> Optional[int]: if continuous_ref[0]: result[0] = -1 elif count_ref[0] < 1: - messagebox.showwarning("激光機", "請先按 +50、+10、+5 或 +1 選擇數量。", parent=win) + 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) + 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_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=8).pack(side=tk.LEFT, padx=4) + 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() @@ -804,8 +387,8 @@ def main() -> None: 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.""" + 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) @@ -813,11 +396,6 @@ def main() -> None: 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) @@ -825,23 +403,31 @@ def main() -> None: 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 @@ -858,6 +444,11 @@ def main() -> None: ) 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)) @@ -911,15 +502,6 @@ def main() -> None: 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) @@ -951,17 +533,9 @@ def main() -> None: ) grid_row[0] += 1 if key_single: - ttk.Label( - f, - text="列印機名稱 (Windows):", - ).grid( - row=grid_row[0], - column=0, - sticky=tk.W, - pady=2, - ) + 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=22, font=get_font(FONT_SIZE), bg="white") + 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 @@ -988,7 +562,7 @@ def main() -> None: 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")) + all_vars.extend(add_section("標簽機 COM 埠", None, None, "label_com")) def on_save(): for key, var in all_vars: @@ -1064,13 +638,15 @@ def main() -> 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") - batch = (lot_no_val or "—").strip() if lot_no_val else "—" + 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: batch+數量 | item code (own column) | item name (≥2× width, wraps in column) + # 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) @@ -1078,7 +654,7 @@ def main() -> None: left.pack(side=tk.LEFT, anchor=tk.NW) batch_lbl = tk.Label( left, - text=batch, + text=display_batch, font=get_font(FONT_SIZE_BUTTONS), bg=BG_ROW, fg="black", @@ -1099,7 +675,7 @@ def main() -> None: code_lbl = tk.Label( row, text=item_code, - font=get_font(FONT_SIZE_ITEM), + font=get_font(FONT_SIZE_ITEM_CODE), bg=BG_ROW, fg="black", wraplength=ITEM_CODE_WRAP, @@ -1108,11 +684,11 @@ def main() -> None: ) code_lbl.pack(side=tk.LEFT, anchor=tk.NW, padx=(12, 8)) - # Column 3: item name only, same bigger font, at least double width, wraps under its own column + # 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), + font=get_font(FONT_SIZE_ITEM_NAME), bg=BG_ROW, fg="black", wraplength=ITEM_NAME_WRAP, @@ -1121,7 +697,7 @@ def main() -> None: ) name_lbl.pack(side=tk.LEFT, fill=tk.BOTH, expand=True, anchor=tk.NW) - def _on_click(e, j=jo, b=batch, r=row): + 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) @@ -1137,97 +713,35 @@ def main() -> None: if not ip: messagebox.showerror("打袋機", "請在設定中填寫打袋機 DataFlex 的 IP。") else: - 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, - ) - try: - send_zpl_to_dataflex(ip, port, zpl) - messagebox.showinfo("打袋機", f"已送出列印:批次 {b}") - except ConnectionRefusedError: - messagebox.showerror("打袋機", f"無法連線至 {ip}:{port},請確認印表機已開機且 IP 正確。") - except socket.timeout: - messagebox.showerror("打袋機", f"連線逾時 ({ip}:{port}),請檢查網路與連接埠。") - except OSError as err: - messagebox.showerror("打袋機", f"列印失敗:{err}") - 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) + 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") - n = 100 if count == "C" else int(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 != "C" 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 "" + zpl = generate_zpl_dataflex(b, item_code, item_name, lot_no=lot_no) 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) + 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): @@ -1250,6 +764,13 @@ def main() -> 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)