|
- #!/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,
- 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 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 run_dataflex_continuous_print(
- root: tk.Tk,
- ip: str,
- port: int,
- zpl: str,
- label_text: str,
- set_status_message: Callable[[str, bool], None],
- ) -> None:
- """
- Run DataFlex continuous print (up to 100) in a background thread.
- Shows a small window with "已列印: N" and a 停止 button; user can stop anytime.
- """
- stop_event = threading.Event()
- win = tk.Toplevel(root)
- win.title("打袋機 連續列印")
- win.geometry("320x140")
- win.transient(root)
- win.configure(bg=BG_TOP)
- tk.Label(win, text="連續列印中,按「停止」結束", font=get_font(FONT_SIZE), bg=BG_TOP).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 on_stop():
- stop_event.set()
-
- ttk.Button(win, text="停止", command=on_stop, width=12).pack(pady=8)
- win.protocol("WM_DELETE_WINDOW", lambda: (stop_event.set(), win.destroy()))
-
- def worker():
- sent = 0
- try:
- for _ in range(100):
- if stop_event.is_set():
- break
- send_zpl_to_dataflex(ip, port, zpl)
- sent += 1
- root.after(0, lambda s=sent: count_lbl.configure(text=f"已列印: {s}"))
- if stop_event.is_set():
- break
- time.sleep(2)
- except ConnectionRefusedError:
- root.after(0, lambda: set_status_message(f"無法連線至 {ip}:{port},請確認印表機已開機且 IP 正確。", is_error=True))
- except socket.timeout:
- root.after(0, lambda: set_status_message(f"連線逾時 ({ip}:{port}),請檢查網路與連接埠。", is_error=True))
- except OSError as err:
- root.after(0, lambda e=err: set_status_message(f"列印失敗:{e}", is_error=True))
- else:
- if stop_event.is_set():
- root.after(0, lambda: set_status_message(f"已送出列印:批次 {label_text} x {sent} 張 (已停止)", is_error=False))
- else:
- root.after(0, lambda: set_status_message(f"已送出列印:批次 {label_text} x {sent} 張 (連續完成)", is_error=False))
- root.after(0, win.destroy)
-
- threading.Thread(target=worker, daemon=True).start()
-
-
- 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("<KeyPress>", 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("<Configure>", _on_inner_configure)
- canvas.bind("<Configure>", _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("<MouseWheel>", _on_mousewheel)
- inner.bind("<MouseWheel>", _on_mousewheel)
- canvas.bind("<Button-4>", _on_mousewheel)
- canvas.bind("<Button-5>", _on_mousewheel)
- inner.bind("<Button-4>", _on_mousewheel)
- inner.bind("<Button-5>", _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 "—"
- 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,
- )
- label_text = (lot_no or b).strip()
- if count == -1:
- run_dataflex_continuous_print(root, ip, port, zpl, label_text, set_status_message)
- else:
- n = count
- try:
- for i in range(n):
- send_zpl_to_dataflex(ip, port, zpl)
- if i < n - 1:
- time.sleep(2)
- set_status_message(f"已送出列印:批次 {label_text} x {n} 張", 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("<Button-1>", _on_click)
- w.bind("<MouseWheel>", _on_mousewheel)
- w.bind("<Button-4>", _on_mousewheel)
- w.bind("<Button-5>", _on_mousewheel)
- if qty_lbl is not None:
- qty_lbl.bind("<Button-1>", _on_click)
- qty_lbl.bind("<MouseWheel>", _on_mousewheel)
- qty_lbl.bind("<Button-4>", _on_mousewheel)
- qty_lbl.bind("<Button-5>", _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()
|