|
|
|
@@ -1,826 +0,0 @@ |
|
|
|
#!/usr/bin/env python3 |
|
|
|
""" |
|
|
|
Bag1 – GUI to show FPSMS job orders by plan date. |
|
|
|
Uses the public API GET /py/job-orders (no login required). |
|
|
|
UI tuned for aged users: larger font, Traditional Chinese labels, prev/next date. |
|
|
|
|
|
|
|
Run: python Bag1.py |
|
|
|
""" |
|
|
|
|
|
|
|
import json |
|
|
|
import os |
|
|
|
import socket |
|
|
|
import sys |
|
|
|
import time |
|
|
|
import tkinter as tk |
|
|
|
from datetime import date, timedelta |
|
|
|
from tkinter import messagebox, ttk |
|
|
|
from typing import Optional |
|
|
|
|
|
|
|
import requests |
|
|
|
|
|
|
|
try: |
|
|
|
import serial |
|
|
|
except ImportError: |
|
|
|
serial = None # type: ignore |
|
|
|
|
|
|
|
DEFAULT_BASE_URL = os.environ.get("FPSMS_BASE_URL", "http://localhost:8090/api") |
|
|
|
# When run as PyInstaller exe, save settings next to the exe; otherwise next to script |
|
|
|
if getattr(sys, "frozen", False): |
|
|
|
_SETTINGS_DIR = os.path.dirname(sys.executable) |
|
|
|
else: |
|
|
|
_SETTINGS_DIR = os.path.dirname(os.path.abspath(__file__)) |
|
|
|
SETTINGS_FILE = os.path.join(_SETTINGS_DIR, "bag1_settings.json") |
|
|
|
|
|
|
|
DEFAULT_SETTINGS = { |
|
|
|
"api_ip": "localhost", |
|
|
|
"api_port": "8090", |
|
|
|
"dabag_ip": "", |
|
|
|
"dabag_port": "3008", |
|
|
|
"laser_ip": "", |
|
|
|
"laser_port": "9100", |
|
|
|
"label_com": "COM3", |
|
|
|
} |
|
|
|
|
|
|
|
|
|
|
|
def load_settings() -> dict: |
|
|
|
"""Load settings from JSON file; return defaults if missing or invalid.""" |
|
|
|
try: |
|
|
|
if os.path.isfile(SETTINGS_FILE): |
|
|
|
with open(SETTINGS_FILE, "r", encoding="utf-8") as f: |
|
|
|
data = json.load(f) |
|
|
|
return {**DEFAULT_SETTINGS, **data} |
|
|
|
except Exception: |
|
|
|
pass |
|
|
|
return dict(DEFAULT_SETTINGS) |
|
|
|
|
|
|
|
|
|
|
|
def save_settings(settings: dict) -> None: |
|
|
|
"""Save settings to JSON file.""" |
|
|
|
with open(SETTINGS_FILE, "w", encoding="utf-8") as f: |
|
|
|
json.dump(settings, f, indent=2, ensure_ascii=False) |
|
|
|
|
|
|
|
|
|
|
|
def build_base_url(api_ip: str, api_port: str) -> str: |
|
|
|
ip = (api_ip or "localhost").strip() |
|
|
|
port = (api_port or "8090").strip() |
|
|
|
return f"http://{ip}:{port}/api" |
|
|
|
|
|
|
|
|
|
|
|
def try_printer_connection(printer_name: str, sett: dict) -> bool: |
|
|
|
"""Try to connect to the selected printer (TCP IP:port or COM). Returns True if OK.""" |
|
|
|
if printer_name == "打袋機 DataFlex": |
|
|
|
ip = (sett.get("dabag_ip") or "").strip() |
|
|
|
port_str = (sett.get("dabag_port") or "9100").strip() |
|
|
|
if not ip: |
|
|
|
return False |
|
|
|
try: |
|
|
|
port = int(port_str) |
|
|
|
s = socket.create_connection((ip, port), timeout=PRINTER_SOCKET_TIMEOUT) |
|
|
|
s.close() |
|
|
|
return True |
|
|
|
except (socket.error, ValueError, OSError): |
|
|
|
return False |
|
|
|
if printer_name == "激光機": |
|
|
|
ip = (sett.get("laser_ip") or "").strip() |
|
|
|
port_str = (sett.get("laser_port") or "9100").strip() |
|
|
|
if not ip: |
|
|
|
return False |
|
|
|
try: |
|
|
|
port = int(port_str) |
|
|
|
s = socket.create_connection((ip, port), timeout=PRINTER_SOCKET_TIMEOUT) |
|
|
|
s.close() |
|
|
|
return True |
|
|
|
except (socket.error, ValueError, OSError): |
|
|
|
return False |
|
|
|
if printer_name == "標簽機": |
|
|
|
if serial is None: |
|
|
|
return False |
|
|
|
com = (sett.get("label_com") or "").strip() |
|
|
|
if not com: |
|
|
|
return False |
|
|
|
try: |
|
|
|
ser = serial.Serial(com, timeout=1) |
|
|
|
ser.close() |
|
|
|
return True |
|
|
|
except (serial.SerialException, OSError): |
|
|
|
return False |
|
|
|
return False |
|
|
|
|
|
|
|
# Larger font for aged users (point size) |
|
|
|
FONT_SIZE = 16 |
|
|
|
FONT_SIZE_BUTTONS = 15 |
|
|
|
FONT_SIZE_QTY = 12 # smaller for 數量 under batch no. |
|
|
|
FONT_SIZE_ITEM_CODE = 20 # item code (larger for readability) |
|
|
|
FONT_SIZE_ITEM_NAME = 26 # item name (bigger than item code) |
|
|
|
FONT_FAMILY = "Microsoft JhengHei UI" # Traditional Chinese; fallback to TkDefaultFont |
|
|
|
# Column widths: item code own column; item name at least double, wraps in its column |
|
|
|
ITEM_CODE_WRAP = 140 # item code column width (long codes wrap under code only) |
|
|
|
ITEM_NAME_WRAP = 640 # item name column (double width), wraps under name only |
|
|
|
|
|
|
|
# Light blue theme (softer than pure grey) |
|
|
|
BG_TOP = "#E8F4FC" |
|
|
|
BG_LIST = "#D4E8F7" |
|
|
|
BG_ROOT = "#E1F0FF" |
|
|
|
BG_ROW = "#C5E1F5" |
|
|
|
BG_ROW_SELECTED = "#6BB5FF" # highlighted when selected (for printing) |
|
|
|
# Connection status bar |
|
|
|
BG_STATUS_ERROR = "#FFCCCB" # red background when disconnected |
|
|
|
FG_STATUS_ERROR = "#B22222" # red text |
|
|
|
BG_STATUS_OK = "#90EE90" # light green when connected |
|
|
|
FG_STATUS_OK = "#006400" # green text |
|
|
|
RETRY_MS = 30 * 1000 # 30 seconds reconnect |
|
|
|
REFRESH_MS = 60 * 1000 # 60 seconds refresh when connected |
|
|
|
PRINTER_CHECK_MS = 60 * 1000 # 1 minute when printer OK |
|
|
|
PRINTER_RETRY_MS = 30 * 1000 # 30 seconds when printer failed |
|
|
|
PRINTER_SOCKET_TIMEOUT = 3 |
|
|
|
DATAFLEX_SEND_TIMEOUT = 10 # seconds when sending ZPL to DataFlex |
|
|
|
DATE_AUTO_RESET_SEC = 5 * 60 # 5 minutes: if no manual date change, auto-set to today |
|
|
|
|
|
|
|
|
|
|
|
def _zpl_escape(s: str) -> str: |
|
|
|
"""Escape text for ZPL ^FD...^FS (backslash and caret).""" |
|
|
|
return s.replace("\\", "\\\\").replace("^", "\\^") |
|
|
|
|
|
|
|
|
|
|
|
def _split_by_word_count(text: str, max_words: int = 8) -> list[str]: |
|
|
|
"""Split text into segments of at most max_words (words = non-symbol chars; symbols not counted).""" |
|
|
|
segments = [] |
|
|
|
current = [] |
|
|
|
count = 0 |
|
|
|
for c in text: |
|
|
|
if c.isalnum() or ("\u4e00" <= c <= "\u9fff") or ("\u3400" <= c <= "\u4dbf"): |
|
|
|
count += 1 |
|
|
|
current.append(c) |
|
|
|
if count >= max_words: |
|
|
|
segments.append("".join(current)) |
|
|
|
current = [] |
|
|
|
count = 0 |
|
|
|
else: |
|
|
|
current.append(c) |
|
|
|
if current: |
|
|
|
segments.append("".join(current)) |
|
|
|
return segments if segments else [""] |
|
|
|
|
|
|
|
|
|
|
|
def generate_zpl_dataflex( |
|
|
|
batch_no: str, |
|
|
|
item_code: str, |
|
|
|
item_name: str, |
|
|
|
item_id: Optional[int] = None, |
|
|
|
stock_in_line_id: Optional[int] = None, |
|
|
|
lot_no: Optional[str] = None, |
|
|
|
font_regular: str = "E:STXihei.ttf", |
|
|
|
font_bold: str = "E:STXihei.ttf", |
|
|
|
) -> str: |
|
|
|
""" |
|
|
|
Row 1 (from zero): QR code, then item name (rotated 90°). |
|
|
|
Row 2: Batch/lot (left), item code (right). |
|
|
|
Label and QR use lotNo from API when present, else batch_no (Bxxxxx). |
|
|
|
""" |
|
|
|
desc = _zpl_escape((item_name or "—").strip()) |
|
|
|
code = _zpl_escape((item_code or "—").strip()) |
|
|
|
label_line = (lot_no or batch_no or "").strip() |
|
|
|
label_esc = _zpl_escape(label_line) |
|
|
|
# QR payload: prefer JSON {"itemId":..., "stockInLineId":...} when both present; else fall back to lot/batch text |
|
|
|
if item_id is not None and stock_in_line_id is not None: |
|
|
|
qr_value = _zpl_escape(json.dumps({"itemId": item_id, "stockInLineId": stock_in_line_id})) |
|
|
|
else: |
|
|
|
qr_value = _zpl_escape(label_line if label_line else batch_no.strip()) |
|
|
|
return f"""^XA |
|
|
|
^CI28 |
|
|
|
^PW700 |
|
|
|
^LL500 |
|
|
|
^PO N |
|
|
|
^FO10,20 |
|
|
|
^BQR,4,7^FD{qr_value}^FS |
|
|
|
^FO170,20 |
|
|
|
^A@R,72,72,{font_regular}^FD{desc}^FS |
|
|
|
^FO0,290 |
|
|
|
^A@R,72,72,{font_regular}^FD{label_esc}^FS |
|
|
|
^FO75,290 |
|
|
|
^A@R,88,88,{font_bold}^FD{code}^FS |
|
|
|
^XZ""" |
|
|
|
|
|
|
|
|
|
|
|
def send_zpl_to_dataflex(ip: str, port: int, zpl: str) -> None: |
|
|
|
"""Send ZPL to DataFlex printer via TCP. Raises on connection/send error.""" |
|
|
|
sock = socket.socket(socket.AF_INET, socket.SOCK_STREAM) |
|
|
|
sock.settimeout(DATAFLEX_SEND_TIMEOUT) |
|
|
|
try: |
|
|
|
sock.connect((ip, port)) |
|
|
|
sock.sendall(zpl.encode("utf-8")) |
|
|
|
finally: |
|
|
|
sock.close() |
|
|
|
|
|
|
|
|
|
|
|
def format_qty(val) -> str: |
|
|
|
"""Format quantity: integer without .0, with thousand separator.""" |
|
|
|
if val is None: |
|
|
|
return "—" |
|
|
|
try: |
|
|
|
n = float(val) |
|
|
|
if n == int(n): |
|
|
|
return f"{int(n):,}" |
|
|
|
return f"{n:,.2f}".rstrip("0").rstrip(".") |
|
|
|
except (TypeError, ValueError): |
|
|
|
return str(val) |
|
|
|
|
|
|
|
|
|
|
|
def batch_no(year: int, job_order_id: int) -> str: |
|
|
|
"""Batch no.: B + 2-digit year + jobOrderId zero-padded to 6 digits.""" |
|
|
|
short_year = year % 100 |
|
|
|
return f"B{short_year:02d}{job_order_id:06d}" |
|
|
|
|
|
|
|
|
|
|
|
def get_font(size: int = FONT_SIZE, bold: bool = False) -> tuple: |
|
|
|
try: |
|
|
|
return (FONT_FAMILY, size, "bold" if bold else "normal") |
|
|
|
except Exception: |
|
|
|
return ("TkDefaultFont", size, "bold" if bold else "normal") |
|
|
|
|
|
|
|
|
|
|
|
def fetch_job_orders(base_url: str, plan_start: date) -> list: |
|
|
|
"""Call GET /py/job-orders and return the JSON list.""" |
|
|
|
url = f"{base_url.rstrip('/')}/py/job-orders" |
|
|
|
params = {"planStart": plan_start.isoformat()} |
|
|
|
resp = requests.get(url, params=params, timeout=30) |
|
|
|
resp.raise_for_status() |
|
|
|
return resp.json() |
|
|
|
|
|
|
|
|
|
|
|
def set_row_highlight(row_frame: tk.Frame, selected: bool) -> None: |
|
|
|
"""Set row and all its child widgets to selected or normal background.""" |
|
|
|
bg = BG_ROW_SELECTED if selected else BG_ROW |
|
|
|
row_frame.configure(bg=bg) |
|
|
|
for w in row_frame.winfo_children(): |
|
|
|
if isinstance(w, (tk.Frame, tk.Label)): |
|
|
|
w.configure(bg=bg) |
|
|
|
for c in w.winfo_children(): |
|
|
|
if isinstance(c, tk.Label): |
|
|
|
c.configure(bg=bg) |
|
|
|
|
|
|
|
|
|
|
|
def on_job_order_click(jo: dict, batch: str) -> None: |
|
|
|
"""Row click handler (highlight already set; no popup).""" |
|
|
|
|
|
|
|
|
|
|
|
def ask_bag_count(parent: tk.Tk) -> Optional[int]: |
|
|
|
""" |
|
|
|
When printer is 打袋機 DataFlex, ask how many bags: +50, +10, +5, +1, C, then 確認送出. |
|
|
|
Returns count (>= 1), or -1 for continuous (C), or None if cancelled. |
|
|
|
""" |
|
|
|
result: list[Optional[int]] = [None] |
|
|
|
count_ref = [0] |
|
|
|
continuous_ref = [False] |
|
|
|
|
|
|
|
win = tk.Toplevel(parent) |
|
|
|
win.title("打袋列印數量") |
|
|
|
win.geometry("420x200") |
|
|
|
win.transient(parent) |
|
|
|
win.grab_set() |
|
|
|
win.configure(bg=BG_TOP) |
|
|
|
ttk.Label(win, text="列印多少個袋?", font=get_font(FONT_SIZE)).pack(pady=(12, 4)) |
|
|
|
count_lbl = tk.Label(win, text="列印數量: 0", font=get_font(FONT_SIZE), bg=BG_TOP) |
|
|
|
count_lbl.pack(pady=4) |
|
|
|
|
|
|
|
def update_display(): |
|
|
|
if continuous_ref[0]: |
|
|
|
count_lbl.configure(text="列印數量: 連續 (C)") |
|
|
|
else: |
|
|
|
count_lbl.configure(text=f"列印數量: {count_ref[0]}") |
|
|
|
|
|
|
|
def add(n: int): |
|
|
|
continuous_ref[0] = False |
|
|
|
count_ref[0] = max(0, count_ref[0] + n) |
|
|
|
update_display() |
|
|
|
|
|
|
|
def set_continuous(): |
|
|
|
continuous_ref[0] = True |
|
|
|
update_display() |
|
|
|
|
|
|
|
def confirm(): |
|
|
|
if continuous_ref[0]: |
|
|
|
result[0] = -1 |
|
|
|
elif count_ref[0] < 1: |
|
|
|
messagebox.showwarning("打袋機", "請先按 +50、+10、+5 或 +1 選擇數量。", parent=win) |
|
|
|
return |
|
|
|
else: |
|
|
|
result[0] = count_ref[0] |
|
|
|
win.destroy() |
|
|
|
|
|
|
|
btn_row1 = tk.Frame(win, bg=BG_TOP) |
|
|
|
btn_row1.pack(pady=8) |
|
|
|
for label, value in [("+50", 50), ("+10", 10), ("+5", 5), ("+1", 1)]: |
|
|
|
def make_add(v: int): |
|
|
|
return lambda: add(v) |
|
|
|
ttk.Button(btn_row1, text=label, command=make_add(value), width=8).pack(side=tk.LEFT, padx=4) |
|
|
|
ttk.Button(btn_row1, text="C", command=set_continuous, width=8).pack(side=tk.LEFT, padx=4) |
|
|
|
|
|
|
|
ttk.Button(win, text="確認送出", command=confirm, width=14).pack(pady=12) |
|
|
|
win.protocol("WM_DELETE_WINDOW", win.destroy) |
|
|
|
win.wait_window() |
|
|
|
return result[0] |
|
|
|
|
|
|
|
|
|
|
|
def ask_label_count(parent: tk.Tk) -> Optional[str]: |
|
|
|
""" |
|
|
|
When printer is 標簽機, ask how many labels to print. |
|
|
|
Returns "1", "10", "50", "100", "C" (continuous), or None if cancelled. |
|
|
|
""" |
|
|
|
result = [None] # mutable so inner callback can set it |
|
|
|
|
|
|
|
win = tk.Toplevel(parent) |
|
|
|
win.title("標簽列印數量") |
|
|
|
win.geometry("360x180") |
|
|
|
win.transient(parent) |
|
|
|
win.grab_set() |
|
|
|
win.configure(bg=BG_TOP) |
|
|
|
ttk.Label(win, text="列印多少張標簽?", font=get_font(FONT_SIZE)).pack(pady=(16, 12)) |
|
|
|
btn_frame = tk.Frame(win, bg=BG_TOP) |
|
|
|
btn_frame.pack(pady=8) |
|
|
|
for label, value in [("1", "1"), ("10", "10"), ("50", "50"), ("100", "100"), ("連續 (C)", "C")]: |
|
|
|
def make_cmd(v): |
|
|
|
def cmd(): |
|
|
|
result[0] = v |
|
|
|
win.destroy() |
|
|
|
return cmd |
|
|
|
ttk.Button(btn_frame, text=label, command=make_cmd(value), width=10).pack(side=tk.LEFT, padx=4) |
|
|
|
win.protocol("WM_DELETE_WINDOW", win.destroy) |
|
|
|
win.wait_window() |
|
|
|
return result[0] |
|
|
|
|
|
|
|
|
|
|
|
def main() -> None: |
|
|
|
settings = load_settings() |
|
|
|
base_url_ref = [build_base_url(settings["api_ip"], settings["api_port"])] |
|
|
|
|
|
|
|
root = tk.Tk() |
|
|
|
root.title("FP-MTMS Bag v1.1 打袋機") |
|
|
|
root.geometry("1120x960") |
|
|
|
root.minsize(480, 360) |
|
|
|
root.configure(bg=BG_ROOT) |
|
|
|
|
|
|
|
# Style: larger font for aged users; light blue theme |
|
|
|
style = ttk.Style() |
|
|
|
try: |
|
|
|
style.configure(".", font=get_font(FONT_SIZE), background=BG_TOP) |
|
|
|
style.configure("TButton", font=get_font(FONT_SIZE_BUTTONS), background=BG_TOP) |
|
|
|
style.configure("TLabel", font=get_font(FONT_SIZE), background=BG_TOP) |
|
|
|
style.configure("TEntry", font=get_font(FONT_SIZE)) |
|
|
|
style.configure("TFrame", background=BG_TOP) |
|
|
|
except tk.TclError: |
|
|
|
pass |
|
|
|
|
|
|
|
# Status bar at top: connection state (no popup on error) |
|
|
|
status_frame = tk.Frame(root, bg=BG_STATUS_ERROR, padx=12, pady=6) |
|
|
|
status_frame.pack(fill=tk.X) |
|
|
|
status_lbl = tk.Label( |
|
|
|
status_frame, |
|
|
|
text="連接不到服務器", |
|
|
|
font=get_font(FONT_SIZE_BUTTONS), |
|
|
|
bg=BG_STATUS_ERROR, |
|
|
|
fg=FG_STATUS_ERROR, |
|
|
|
anchor=tk.CENTER, |
|
|
|
) |
|
|
|
status_lbl.pack(fill=tk.X) |
|
|
|
|
|
|
|
def set_status_ok(): |
|
|
|
status_frame.configure(bg=BG_STATUS_OK) |
|
|
|
status_lbl.configure(text="連接正常", bg=BG_STATUS_OK, fg=FG_STATUS_OK) |
|
|
|
|
|
|
|
def set_status_error(): |
|
|
|
status_frame.configure(bg=BG_STATUS_ERROR) |
|
|
|
status_lbl.configure(text="連接不到服務器", bg=BG_STATUS_ERROR, fg=FG_STATUS_ERROR) |
|
|
|
|
|
|
|
def set_status_message(msg: str, is_error: bool = False): |
|
|
|
"""Show a temporary message on the status bar.""" |
|
|
|
if is_error: |
|
|
|
status_frame.configure(bg=BG_STATUS_ERROR) |
|
|
|
status_lbl.configure(text=msg, bg=BG_STATUS_ERROR, fg=FG_STATUS_ERROR) |
|
|
|
else: |
|
|
|
status_frame.configure(bg=BG_STATUS_OK) |
|
|
|
status_lbl.configure(text=msg, bg=BG_STATUS_OK, fg=FG_STATUS_OK) |
|
|
|
|
|
|
|
# Top: left [前一天] [date] [後一天] | right [printer dropdown] |
|
|
|
top = tk.Frame(root, padx=12, pady=12, bg=BG_TOP) |
|
|
|
top.pack(fill=tk.X) |
|
|
|
|
|
|
|
date_var = tk.StringVar(value=date.today().isoformat()) |
|
|
|
printer_options = ["打袋機 DataFlex", "標簽機", "激光機"] |
|
|
|
printer_var = tk.StringVar(value=printer_options[0]) |
|
|
|
last_manual_date_change_ref = [time.time()] # track when user last changed date manually |
|
|
|
|
|
|
|
def mark_manual_date_change(): |
|
|
|
last_manual_date_change_ref[0] = time.time() |
|
|
|
|
|
|
|
def go_prev_day() -> None: |
|
|
|
try: |
|
|
|
d = date.fromisoformat(date_var.get().strip()) |
|
|
|
date_var.set((d - timedelta(days=1)).isoformat()) |
|
|
|
mark_manual_date_change() |
|
|
|
load_job_orders(from_user_date_change=True) |
|
|
|
except ValueError: |
|
|
|
date_var.set(date.today().isoformat()) |
|
|
|
mark_manual_date_change() |
|
|
|
load_job_orders(from_user_date_change=True) |
|
|
|
|
|
|
|
def go_next_day() -> None: |
|
|
|
try: |
|
|
|
d = date.fromisoformat(date_var.get().strip()) |
|
|
|
date_var.set((d + timedelta(days=1)).isoformat()) |
|
|
|
mark_manual_date_change() |
|
|
|
load_job_orders(from_user_date_change=True) |
|
|
|
except ValueError: |
|
|
|
date_var.set(date.today().isoformat()) |
|
|
|
mark_manual_date_change() |
|
|
|
load_job_orders(from_user_date_change=True) |
|
|
|
|
|
|
|
# 前一天 (previous day) with left arrow icon |
|
|
|
btn_prev = ttk.Button(top, text="◀ 前一天", command=go_prev_day) |
|
|
|
btn_prev.pack(side=tk.LEFT, padx=(0, 8)) |
|
|
|
|
|
|
|
# Date field (no "日期:" label); shorter width |
|
|
|
date_entry = tk.Entry( |
|
|
|
top, |
|
|
|
textvariable=date_var, |
|
|
|
font=get_font(FONT_SIZE), |
|
|
|
width=10, |
|
|
|
bg="white", |
|
|
|
) |
|
|
|
date_entry.pack(side=tk.LEFT, padx=(0, 8), ipady=4) |
|
|
|
|
|
|
|
# Track manual typing in date field as user date change |
|
|
|
def on_date_entry_key(event): |
|
|
|
mark_manual_date_change() |
|
|
|
date_entry.bind("<Key>", on_date_entry_key) |
|
|
|
|
|
|
|
# 後一天 (next day) with right arrow icon |
|
|
|
btn_next = ttk.Button(top, text="後一天 ▶", command=go_next_day) |
|
|
|
btn_next.pack(side=tk.LEFT, padx=(0, 8)) |
|
|
|
|
|
|
|
# Top right: Setup button + printer selection |
|
|
|
right_frame = tk.Frame(top, bg=BG_TOP) |
|
|
|
right_frame.pack(side=tk.RIGHT) |
|
|
|
ttk.Button(right_frame, text="設定", command=lambda: open_setup_window(root, settings, base_url_ref)).pack( |
|
|
|
side=tk.LEFT, padx=(0, 12) |
|
|
|
) |
|
|
|
# 列印機 label: green when printer connected, red when not (checked periodically) |
|
|
|
printer_status_lbl = tk.Label( |
|
|
|
right_frame, |
|
|
|
text="列印機:", |
|
|
|
font=get_font(FONT_SIZE), |
|
|
|
bg=BG_STATUS_ERROR, |
|
|
|
fg="black", |
|
|
|
padx=6, |
|
|
|
pady=2, |
|
|
|
) |
|
|
|
printer_status_lbl.pack(side=tk.LEFT, padx=(0, 4)) |
|
|
|
printer_combo = ttk.Combobox( |
|
|
|
right_frame, |
|
|
|
textvariable=printer_var, |
|
|
|
values=printer_options, |
|
|
|
state="readonly", |
|
|
|
width=14, |
|
|
|
font=get_font(FONT_SIZE), |
|
|
|
) |
|
|
|
printer_combo.pack(side=tk.LEFT) |
|
|
|
|
|
|
|
printer_after_ref = [None] |
|
|
|
|
|
|
|
def set_printer_status_ok(): |
|
|
|
printer_status_lbl.configure(bg=BG_STATUS_OK, fg=FG_STATUS_OK) |
|
|
|
|
|
|
|
def set_printer_status_error(): |
|
|
|
printer_status_lbl.configure(bg=BG_STATUS_ERROR, fg=FG_STATUS_ERROR) |
|
|
|
|
|
|
|
def check_printer() -> None: |
|
|
|
if printer_after_ref[0] is not None: |
|
|
|
root.after_cancel(printer_after_ref[0]) |
|
|
|
printer_after_ref[0] = None |
|
|
|
ok = try_printer_connection(printer_var.get(), settings) |
|
|
|
if ok: |
|
|
|
set_printer_status_ok() |
|
|
|
printer_after_ref[0] = root.after(PRINTER_CHECK_MS, check_printer) |
|
|
|
else: |
|
|
|
set_printer_status_error() |
|
|
|
printer_after_ref[0] = root.after(PRINTER_RETRY_MS, check_printer) |
|
|
|
|
|
|
|
def on_printer_selection_changed(*args) -> None: |
|
|
|
check_printer() |
|
|
|
|
|
|
|
printer_var.trace_add("write", on_printer_selection_changed) |
|
|
|
|
|
|
|
def open_setup_window(parent_win: tk.Tk, sett: dict, base_url_ref_list: list) -> None: |
|
|
|
"""Modal setup: API IP/port, 打袋機/激光機 IP+port, 標簽機 COM port.""" |
|
|
|
d = tk.Toplevel(parent_win) |
|
|
|
d.title("設定") |
|
|
|
d.geometry("440x520") |
|
|
|
d.transient(parent_win) |
|
|
|
d.grab_set() |
|
|
|
d.configure(bg=BG_TOP) |
|
|
|
f = tk.Frame(d, padx=16, pady=16, bg=BG_TOP) |
|
|
|
f.pack(fill=tk.BOTH, expand=True) |
|
|
|
grid_row = [0] # use list so inner function can update |
|
|
|
|
|
|
|
def _ensure_dot_in_entry(entry: tk.Entry) -> None: |
|
|
|
"""Allow typing dot (.) in Entry when IME or layout blocks it (e.g. 192.168.17.27).""" |
|
|
|
def on_key(event): |
|
|
|
if event.keysym in ("period", "decimal"): |
|
|
|
pos = entry.index(tk.INSERT) |
|
|
|
entry.insert(tk.INSERT, ".") |
|
|
|
return "break" |
|
|
|
entry.bind("<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="COM:").grid(row=grid_row[0], column=0, sticky=tk.W, pady=2) |
|
|
|
var = tk.StringVar(value=sett.get(key_single, "")) |
|
|
|
e = tk.Entry(f, textvariable=var, width=14, font=get_font(FONT_SIZE), bg="white") |
|
|
|
e.grid(row=grid_row[0], column=1, sticky=tk.W, pady=2) |
|
|
|
_ensure_dot_in_entry(e) |
|
|
|
grid_row[0] += 1 |
|
|
|
return [(key_single, var)] |
|
|
|
if key_ip: |
|
|
|
ttk.Label(f, text="IP:").grid(row=grid_row[0], column=0, sticky=tk.W, pady=2) |
|
|
|
var_ip = tk.StringVar(value=sett.get(key_ip, "")) |
|
|
|
e_ip = tk.Entry(f, textvariable=var_ip, width=22, font=get_font(FONT_SIZE), bg="white") |
|
|
|
e_ip.grid(row=grid_row[0], column=1, sticky=tk.W, pady=2) |
|
|
|
_ensure_dot_in_entry(e_ip) |
|
|
|
grid_row[0] += 1 |
|
|
|
out.append((key_ip, var_ip)) |
|
|
|
if key_port: |
|
|
|
ttk.Label(f, text="Port:").grid(row=grid_row[0], column=0, sticky=tk.W, pady=2) |
|
|
|
var_port = tk.StringVar(value=sett.get(key_port, "")) |
|
|
|
e_port = tk.Entry(f, textvariable=var_port, width=12, font=get_font(FONT_SIZE), bg="white") |
|
|
|
e_port.grid(row=grid_row[0], column=1, sticky=tk.W, pady=2) |
|
|
|
_ensure_dot_in_entry(e_port) |
|
|
|
grid_row[0] += 1 |
|
|
|
out.append((key_port, var_port)) |
|
|
|
return out |
|
|
|
|
|
|
|
all_vars = [] |
|
|
|
all_vars.extend(add_section("API 伺服器", "api_ip", "api_port", None)) |
|
|
|
all_vars.extend(add_section("打袋機 DataFlex", "dabag_ip", "dabag_port", None)) |
|
|
|
all_vars.extend(add_section("激光機", "laser_ip", "laser_port", None)) |
|
|
|
all_vars.extend(add_section("標簽機 COM 埠", None, None, "label_com")) |
|
|
|
|
|
|
|
def on_save(): |
|
|
|
for key, var in all_vars: |
|
|
|
sett[key] = var.get().strip() |
|
|
|
save_settings(sett) |
|
|
|
base_url_ref_list[0] = build_base_url(sett["api_ip"], sett["api_port"]) |
|
|
|
d.destroy() |
|
|
|
|
|
|
|
btn_f = tk.Frame(d, bg=BG_TOP) |
|
|
|
btn_f.pack(pady=12) |
|
|
|
ttk.Button(btn_f, text="儲存", command=on_save).pack(side=tk.LEFT, padx=4) |
|
|
|
ttk.Button(btn_f, text="取消", command=d.destroy).pack(side=tk.LEFT, padx=4) |
|
|
|
d.wait_window() |
|
|
|
|
|
|
|
job_orders_frame = tk.Frame(root, bg=BG_LIST) |
|
|
|
job_orders_frame.pack(fill=tk.BOTH, expand=True, padx=12, pady=12) |
|
|
|
|
|
|
|
# Scrollable area for buttons |
|
|
|
canvas = tk.Canvas(job_orders_frame, highlightthickness=0, bg=BG_LIST) |
|
|
|
scrollbar = ttk.Scrollbar(job_orders_frame, orient=tk.VERTICAL, command=canvas.yview) |
|
|
|
inner = tk.Frame(canvas, bg=BG_LIST) |
|
|
|
|
|
|
|
win_id = canvas.create_window((0, 0), window=inner, anchor=tk.NW) |
|
|
|
canvas.configure(yscrollcommand=scrollbar.set) |
|
|
|
|
|
|
|
def _on_inner_configure(event): |
|
|
|
canvas.configure(scrollregion=canvas.bbox("all")) |
|
|
|
|
|
|
|
def _on_canvas_configure(event): |
|
|
|
canvas.itemconfig(win_id, width=event.width) |
|
|
|
|
|
|
|
inner.bind("<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") |
|
|
|
# Use lotNo from API when present; fall back to generated Bxxxxx batch no. |
|
|
|
raw_batch = batch_no(year, jo_id) if jo_id is not None else "—" |
|
|
|
lot_no_val = jo.get("lotNo") |
|
|
|
display_batch = (lot_no_val or raw_batch or "—").strip() |
|
|
|
item_code = jo.get("itemCode") or "—" |
|
|
|
item_name = jo.get("itemName") or "—" |
|
|
|
req_qty = jo.get("reqQty") |
|
|
|
qty_str = format_qty(req_qty) |
|
|
|
# Three columns: lotNo/batch+數量 | item code (own column) | item name (≥2× width, wraps in column) |
|
|
|
row = tk.Frame(inner, bg=BG_ROW, relief=tk.RAISED, bd=2, cursor="hand2", padx=12, pady=10) |
|
|
|
row.pack(fill=tk.X, pady=4) |
|
|
|
|
|
|
|
left = tk.Frame(row, bg=BG_ROW) |
|
|
|
left.pack(side=tk.LEFT, anchor=tk.NW) |
|
|
|
batch_lbl = tk.Label( |
|
|
|
left, |
|
|
|
text=display_batch, |
|
|
|
font=get_font(FONT_SIZE_BUTTONS), |
|
|
|
bg=BG_ROW, |
|
|
|
fg="black", |
|
|
|
) |
|
|
|
batch_lbl.pack(anchor=tk.W) |
|
|
|
qty_lbl = None |
|
|
|
if qty_str != "—": |
|
|
|
qty_lbl = tk.Label( |
|
|
|
left, |
|
|
|
text=f"數量:{qty_str}", |
|
|
|
font=get_font(FONT_SIZE_QTY), |
|
|
|
bg=BG_ROW, |
|
|
|
fg="black", |
|
|
|
) |
|
|
|
qty_lbl.pack(anchor=tk.W) |
|
|
|
|
|
|
|
# Column 2: item code only, bigger font, wraps in its own column |
|
|
|
code_lbl = tk.Label( |
|
|
|
row, |
|
|
|
text=item_code, |
|
|
|
font=get_font(FONT_SIZE_ITEM_CODE), |
|
|
|
bg=BG_ROW, |
|
|
|
fg="black", |
|
|
|
wraplength=ITEM_CODE_WRAP, |
|
|
|
justify=tk.LEFT, |
|
|
|
anchor=tk.NW, |
|
|
|
) |
|
|
|
code_lbl.pack(side=tk.LEFT, anchor=tk.NW, padx=(12, 8)) |
|
|
|
|
|
|
|
# Column 3: item name only, bigger font, at least double width, wraps under its own column |
|
|
|
name_lbl = tk.Label( |
|
|
|
row, |
|
|
|
text=item_name or "—", |
|
|
|
font=get_font(FONT_SIZE_ITEM_NAME), |
|
|
|
bg=BG_ROW, |
|
|
|
fg="black", |
|
|
|
wraplength=ITEM_NAME_WRAP, |
|
|
|
justify=tk.LEFT, |
|
|
|
anchor=tk.NW, |
|
|
|
) |
|
|
|
name_lbl.pack(side=tk.LEFT, fill=tk.BOTH, expand=True, anchor=tk.NW) |
|
|
|
|
|
|
|
def _on_click(e, j=jo, b=display_batch, r=row): |
|
|
|
if selected_row_holder[0] is not None: |
|
|
|
set_row_highlight(selected_row_holder[0], False) |
|
|
|
set_row_highlight(r, True) |
|
|
|
selected_row_holder[0] = r |
|
|
|
selected_jo_id_ref[0] = j.get("id") |
|
|
|
if printer_var.get() == "打袋機 DataFlex": |
|
|
|
ip = (settings.get("dabag_ip") or "").strip() |
|
|
|
port_str = (settings.get("dabag_port") or "3008").strip() |
|
|
|
try: |
|
|
|
port = int(port_str) |
|
|
|
except ValueError: |
|
|
|
port = 3008 |
|
|
|
if not ip: |
|
|
|
messagebox.showerror("打袋機", "請在設定中填寫打袋機 DataFlex 的 IP。") |
|
|
|
else: |
|
|
|
count = ask_bag_count(root) |
|
|
|
if count is not None: |
|
|
|
item_code = j.get("itemCode") or "—" |
|
|
|
item_name = j.get("itemName") or "—" |
|
|
|
item_id = j.get("itemId") |
|
|
|
stock_in_line_id = j.get("stockInLineId") |
|
|
|
lot_no = j.get("lotNo") |
|
|
|
zpl = generate_zpl_dataflex( |
|
|
|
b, |
|
|
|
item_code, |
|
|
|
item_name, |
|
|
|
item_id=item_id, |
|
|
|
stock_in_line_id=stock_in_line_id, |
|
|
|
lot_no=lot_no, |
|
|
|
) |
|
|
|
n = 100 if count == -1 else count |
|
|
|
label_text = (lot_no or b).strip() |
|
|
|
try: |
|
|
|
for i in range(n): |
|
|
|
send_zpl_to_dataflex(ip, port, zpl) |
|
|
|
if i < n - 1: |
|
|
|
time.sleep(2) |
|
|
|
msg = f"已送出列印:批次 {label_text} x {n} 張" if count != -1 else f"已送出列印:批次 {label_text} x {n} 張 (連續)" |
|
|
|
set_status_message(msg, is_error=False) |
|
|
|
except ConnectionRefusedError: |
|
|
|
set_status_message(f"無法連線至 {ip}:{port},請確認印表機已開機且 IP 正確。", is_error=True) |
|
|
|
except socket.timeout: |
|
|
|
set_status_message(f"連線逾時 ({ip}:{port}),請檢查網路與連接埠。", is_error=True) |
|
|
|
except OSError as err: |
|
|
|
set_status_message(f"列印失敗:{err}", is_error=True) |
|
|
|
elif printer_var.get() == "標簽機": |
|
|
|
count = ask_label_count(root) |
|
|
|
if count is not None: |
|
|
|
if count == "C": |
|
|
|
msg = "已選擇連續列印標簽" |
|
|
|
else: |
|
|
|
msg = f"將列印 {count} 張標簽" |
|
|
|
set_status_message(msg, is_error=False) |
|
|
|
on_job_order_click(j, b) |
|
|
|
|
|
|
|
for w in (row, left, batch_lbl, code_lbl, name_lbl): |
|
|
|
w.bind("<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 |
|
|
|
# Auto-reset date to today if user hasn't manually changed it recently (for 24x7 use) |
|
|
|
if not from_user_date_change: |
|
|
|
elapsed = time.time() - last_manual_date_change_ref[0] |
|
|
|
today_str = date.today().isoformat() |
|
|
|
if elapsed > DATE_AUTO_RESET_SEC and date_var.get().strip() != today_str: |
|
|
|
date_var.set(today_str) |
|
|
|
from_user_date_change = True # treat as date change to reset selection/scroll |
|
|
|
date_str = date_var.get().strip() |
|
|
|
try: |
|
|
|
plan_start = date.fromisoformat(date_str) |
|
|
|
except ValueError: |
|
|
|
messagebox.showerror("日期錯誤", f"請使用 yyyy-MM-dd 格式。目前:{date_str}") |
|
|
|
return |
|
|
|
if from_user_date_change: |
|
|
|
selected_row_holder[0] = None |
|
|
|
selected_jo_id_ref[0] = None |
|
|
|
try: |
|
|
|
data = fetch_job_orders(base_url_ref[0], plan_start) |
|
|
|
except requests.RequestException: |
|
|
|
set_status_error() |
|
|
|
after_id_ref[0] = root.after(RETRY_MS, lambda: load_job_orders(from_user_date_change=False)) |
|
|
|
return |
|
|
|
set_status_ok() |
|
|
|
old_data = last_data_ref[0] |
|
|
|
last_data_ref[0] = data |
|
|
|
data_changed = not _data_equal(old_data, data) |
|
|
|
if data_changed or from_user_date_change: |
|
|
|
# Rebuild list: clear and rebuild from current data (last_data_ref already updated) |
|
|
|
for w in inner.winfo_children(): |
|
|
|
w.destroy() |
|
|
|
preserve = not from_user_date_change |
|
|
|
_build_list_from_data(data, plan_start, preserve_selection=preserve) |
|
|
|
if from_user_date_change: |
|
|
|
canvas.yview_moveto(0) |
|
|
|
after_id_ref[0] = root.after(REFRESH_MS, lambda: load_job_orders(from_user_date_change=False)) |
|
|
|
|
|
|
|
# Load default (today) on start; then start printer connection check |
|
|
|
root.after(100, lambda: load_job_orders(from_user_date_change=True)) |
|
|
|
root.after(300, check_printer) |
|
|
|
|
|
|
|
root.mainloop() |
|
|
|
|
|
|
|
|
|
|
|
if __name__ == "__main__": |
|
|
|
main() |