From ea1a2686a9bb2688353e7ea4b471d703de04ff27 Mon Sep 17 00:00:00 2001 From: "DESKTOP-064TTA1\\Fai LUK" Date: Thu, 26 Mar 2026 22:51:43 +0800 Subject: [PATCH] no message --- python/Bag2.py | 799 ++++---- python/Bag3.py | 1718 +++++++++++++++++ python/__pycache__/Bag2.cpython-313.pyc | Bin 88353 -> 79794 bytes python/bag3_settings.json | 9 + python/installAndExe.txt | 1 + .../service/PlasticBagPrinterService.kt | 15 +- .../com/ffii/fpsms/py/PrintedQtyByChannel.kt | 8 + .../java/com/ffii/fpsms/py/PyController.kt | 37 +- .../com/ffii/fpsms/py/PyJobOrderListItem.kt | 6 + .../py/PyJobOrderPrintSubmitRepository.kt | 35 + .../fpsms/py/PyJobOrderPrintSubmitRequest.kt | 11 + .../fpsms/py/PyJobOrderPrintSubmitResponse.kt | 9 + .../fpsms/py/PyJobOrderPrintSubmitService.kt | 72 + .../java/com/ffii/fpsms/py/PyPrintChannel.kt | 8 + .../fpsms/py/entity/PyJobOrderPrintSubmit.kt | 35 + .../01_create_py_job_order_print_submit.sql | 22 + 16 files changed, 2376 insertions(+), 409 deletions(-) create mode 100644 python/Bag3.py create mode 100644 python/bag3_settings.json create mode 100644 src/main/java/com/ffii/fpsms/py/PrintedQtyByChannel.kt create mode 100644 src/main/java/com/ffii/fpsms/py/PyJobOrderPrintSubmitRepository.kt create mode 100644 src/main/java/com/ffii/fpsms/py/PyJobOrderPrintSubmitRequest.kt create mode 100644 src/main/java/com/ffii/fpsms/py/PyJobOrderPrintSubmitResponse.kt create mode 100644 src/main/java/com/ffii/fpsms/py/PyJobOrderPrintSubmitService.kt create mode 100644 src/main/java/com/ffii/fpsms/py/PyPrintChannel.kt create mode 100644 src/main/java/com/ffii/fpsms/py/entity/PyJobOrderPrintSubmit.kt create mode 100644 src/main/resources/db/changelog/changes/20260326_fai/01_create_py_job_order_print_submit.sql diff --git a/python/Bag2.py b/python/Bag2.py index 2ffab22..5c4afb9 100644 --- a/python/Bag2.py +++ b/python/Bag2.py @@ -1,10 +1,10 @@ #!/usr/bin/env python3 """ -Bag1 – GUI to show FPSMS job orders by plan date. +Bag2 – 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 +Run: python Bag2.py """ import json @@ -157,14 +157,21 @@ def try_printer_connection(printer_name: str, sett: dict) -> bool: # 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_QTY = 12 # smaller for 需求數量 under batch no. +FONT_SIZE_META = 11 # single-line 需求/已印 (compact list) +# Less vertical padding so ~30 rows fit more comfortably +LIST_ROW_PADY = 2 +LIST_ROW_IPADY = 5 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 +# Column widths: fixed frame widths so 品號/品名 columns line up across rows +LEFT_COL_WIDTH_PX = 300 # 工單 + 需求/已印 block +ITEM_CODE_WRAP = 140 # Label wraplength (px) +# Narrower than wrap+padding so short codes sit closer to 品名 (still aligned across rows) +CODE_COL_WIDTH_PX = ITEM_CODE_WRAP + 6 +ITEM_NAME_WRAP = 640 # item name wraps in remaining space # Light blue theme (softer than pure grey) BG_TOP = "#E8F4FC" @@ -534,62 +541,6 @@ def send_image_to_label_printer(printer_name: str, pil_image: "Image.Image") -> dc.EndDoc() -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) @@ -665,6 +616,9 @@ def save_laser_last_count(date_str: str, count: int) -> None: LASER_PUSH_INTERVAL = 2 # seconds between pushes (like sample script) +# Click row with 激光機 selected: send this many times, delay between sends (not after last). +LASER_ROW_SEND_COUNT = 3 +LASER_ROW_SEND_DELAY_SEC = 3 def laser_push_loop( @@ -730,130 +684,6 @@ def laser_push_loop( pass -def run_laser_continuous_print( - root: tk.Tk, - laser_conn_ref: list, - laser_thread_ref: list, - laser_stop_ref: list, - ip: str, - port: int, - item_id: Optional[int], - stock_in_line_id: Optional[int], - item_code: str, - item_name: str, - set_status_message: Callable[[str, bool], None], -) -> None: - """ - Laser continuous send (連續 C) in a background thread. - User can stop via Stop button or keyboard: `Esc` / `Q`. - """ - # Stop any previous continuous run. - if laser_stop_ref[0] is not None: - try: - laser_stop_ref[0].set() - except Exception: - pass - - stop_event = threading.Event() - laser_stop_ref[0] = stop_event - - win = tk.Toplevel(root) - win.title("激光機 連續列印") - win.geometry("360x170") - win.transient(root) - win.configure(bg=BG_TOP) - - ttk.Label( - win, - text="連續送出中,按「停止」或 Esc / Q 結束", - font=get_font(FONT_SIZE), - background=BG_TOP, - ).pack(pady=(14, 8)) - - count_lbl = tk.Label(win, text="已送出:0 次", font=get_font(FONT_SIZE), bg=BG_TOP) - count_lbl.pack(pady=6) - - # Ensure stop keys work even if focus is on the main window. - def _unbind_stop_keys() -> None: - try: - root.unbind_all("") - root.unbind_all("") - root.unbind_all("") - except Exception: - pass - - def on_stop() -> None: - if stop_event.is_set(): - return - stop_event.set() - _unbind_stop_keys() - try: - win.destroy() - except Exception: - pass - - def _key_stop(_e: tk.Event) -> str: - on_stop() - return "break" - - root.bind_all("", _key_stop) - root.bind_all("", _key_stop) - root.bind_all("", _key_stop) - - ttk.Button(win, text="停止", command=on_stop, width=12).pack(pady=10) - win.protocol("WM_DELETE_WINDOW", on_stop) - - def worker() -> None: - sent = 0 - try: - while not stop_event.is_set(): - ok, msg = send_job_to_laser_with_retry( - laser_conn_ref, - ip, - port, - item_id, - stock_in_line_id, - item_code, - item_name, - ) - if not ok: - root.after(0, lambda m=msg: set_status_message(f"連續送出失敗:{m}", is_error=True)) - break - - sent += 1 - root.after(0, lambda s=sent: count_lbl.configure(text=f"已送出:{s} 次")) - - # Small delay between sends; check stop frequently. - for _ in range(4): # 4 * 0.05 = 0.20 sec - if stop_event.is_set(): - break - time.sleep(0.05) - except Exception as e: - root.after(0, lambda err=str(e): set_status_message(f"連續送出意外錯誤:{err}", is_error=True)) - finally: - _unbind_stop_keys() - if not stop_event.is_set(): - # Failure path ended the worker; keep window close. - try: - win.destroy() - except Exception: - pass - else: - # Stopped intentionally. - root.after(0, lambda s=sent: set_status_message(f"已停止激光機連續送出:{s} 次", is_error=False)) - - laser_stop_ref[0] = None - laser_thread_ref[0] = None - try: - win.destroy() - except Exception: - pass - - t = threading.Thread(target=worker, daemon=True) - laser_thread_ref[0] = t - t.start() - - def send_job_to_laser( conn_ref: list, ip: str, @@ -932,6 +762,107 @@ def send_job_to_laser_with_retry( return ok2, msg2 +def run_laser_row_send_thread( + root: tk.Tk, + laser_conn_ref: list, + laser_busy_ref: list, + ip: str, + port: int, + item_id: Optional[int], + stock_in_line_id: Optional[int], + item_code: str, + item_name: str, + set_status_message: Callable[[str, bool], None], + base_url: Optional[str] = None, + job_order_id: Optional[int] = None, + on_recorded: Optional[Callable[[], None]] = None, +) -> None: + """ + On row click with 激光機: send LASER_ROW_SEND_COUNT times with LASER_ROW_SEND_DELAY_SEC between sends. + UI updates on main thread; work runs in background so the window does not freeze. + After success, POST LASER qty to API when job_order_id and base_url are set. + """ + if laser_busy_ref[0]: + return + laser_busy_ref[0] = True + + def worker() -> None: + try: + n = LASER_ROW_SEND_COUNT + 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, + item_name, + ) + if not ok: + root.after( + 0, + lambda m=msg: messagebox.showwarning("激光機", m), + ) + return + if i < n - 1: + time.sleep(LASER_ROW_SEND_DELAY_SEC) + posted = False + if base_url and job_order_id is not None: + try: + submit_job_order_print_submit(base_url, int(job_order_id), n, "LASER") + posted = True + except requests.RequestException as ex: + root.after( + 0, + lambda err=str(ex): messagebox.showwarning( + "激光機", + f"已發送,但伺服器記錄失敗:{err}", + ), + ) + root.after( + 0, + lambda: set_status_message("已發送", is_error=False), + ) + if on_recorded is not None and posted: + root.after(0, on_recorded) + except Exception as e: + root.after( + 0, + lambda err=str(e): messagebox.showwarning("激光機", f"送出失敗:{err}"), + ) + finally: + laser_busy_ref[0] = False + + threading.Thread(target=worker, daemon=True).start() + + +def _printed_qty_int(raw) -> int: + """Parse API printed qty field (may be float JSON) to int.""" + try: + return int(float(raw)) if raw is not None else 0 + except (TypeError, ValueError): + return 0 + + +def _filter_job_orders_by_search(data: list, needle: str) -> list: + """Substring match on item code, job order code, item name, lot (case-insensitive).""" + n = needle.strip().lower() + if not n: + return data + out: list = [] + for jo in data: + parts = [ + str(jo.get("itemCode") or ""), + str(jo.get("code") or ""), + str(jo.get("itemName") or ""), + str(jo.get("lotNo") or ""), + ] + if any(n in p.lower() for p in parts): + out.append(jo) + return out + + def format_qty(val) -> str: """Format quantity: integer without .0, with thousand separator.""" if val is None: @@ -966,16 +897,33 @@ def fetch_job_orders(base_url: str, plan_start: date) -> list: return resp.json() +def submit_job_order_print_submit( + base_url: str, + job_order_id: int, + qty: int, + print_channel: str = "LABEL", +) -> None: + """POST /py/job-order-print-submit — one row per submit for DB wastage/stock tracking.""" + url = f"{base_url.rstrip('/')}/py/job-order-print-submit" + resp = requests.post( + url, + json={"jobOrderId": job_order_id, "qty": qty, "printChannel": print_channel}, + timeout=30, + ) + resp.raise_for_status() + + def set_row_highlight(row_frame: tk.Frame, selected: bool) -> None: - """Set row and all its child widgets to selected or normal background.""" + """Set row and all nested Frame/Label children 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(): + + def _paint(w: tk.Misc) -> None: 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) + _paint(c) + + _paint(row_frame) def on_job_order_click(jo: dict, batch: str) -> None: @@ -988,107 +936,59 @@ def on_job_order_click(jo: dict, batch: str) -> None: ) -def ask_laser_count(parent: tk.Tk) -> Optional[int]: +def ask_label_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. + When printer is 標簽機, ask how many labels to print: + optional direct qty in text field, +50/+10/+5/+1, 重置, then 確認送出. + Returns count (>= 1), or None if cancelled. """ - result: list = [None] - count_ref = [0] - continuous_ref = [False] + result: list[Optional[int]] = [None] + qty_var = tk.StringVar(value="0") win = tk.Toplevel(parent) - win.title("激光機送出數量") - win.geometry("580x230") # wider so 連續 (C) button is fully visible + win.title("標簽印數") + win.geometry("580x280") 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() + ttk.Label(win, text="印多少個?", font=get_font(FONT_SIZE)).pack(pady=(12, 4)) + + entry_row = tk.Frame(win, bg=BG_TOP) + entry_row.pack(pady=8) + tk.Label(entry_row, text="需求數量:", font=get_font(FONT_SIZE), bg=BG_TOP).pack(side=tk.LEFT, padx=(0, 6)) + qty_entry = tk.Entry( + entry_row, + textvariable=qty_var, + width=12, + font=get_font(FONT_SIZE), + bg="white", + justify=tk.RIGHT, + ) + qty_entry.pack(side=tk.LEFT, padx=4) - 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 current_qty() -> int: + s = (qty_var.get() or "").strip().replace(",", "") + if not s: + return 0 + try: + return max(0, int(s)) + except ValueError: + return 0 + def reset_qty() -> None: + qty_var.set("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] + ttk.Button(entry_row, text="重置", command=reset_qty, width=8).pack(side=tk.LEFT, padx=8) - 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 add(n: int) -> None: + qty_var.set(str(current_qty() + n)) - 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) + def confirm() -> None: + q = current_qty() + if q < 1: + messagebox.showwarning("標簽機", "請輸入需求數量或按 +50、+10、+5、+1。", parent=win) return - else: - result[0] = count_ref[0] + result[0] = q win.destroy() btn_row1 = tk.Frame(win, bg=BG_TOP) @@ -1097,55 +997,66 @@ def ask_label_count(parent: tk.Tk) -> Optional[int]: 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) + qty_entry.bind("", lambda e: confirm()) 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. + When printer is 打袋機 DataFlex, ask how many bags: + optional direct qty in text field, +50/+10/+5/+1, 重置, then 確認送出. + Returns count (>= 1), or None if cancelled. """ result: list[Optional[int]] = [None] - count_ref = [0] - continuous_ref = [False] + qty_var = tk.StringVar(value="0") win = tk.Toplevel(parent) win.title("打袋列印數量") - win.geometry("580x230") # wider so 連續 (C) button is fully visible + win.geometry("580x280") 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) + entry_row = tk.Frame(win, bg=BG_TOP) + entry_row.pack(pady=8) + tk.Label(entry_row, text="需求數量:", font=get_font(FONT_SIZE), bg=BG_TOP).pack(side=tk.LEFT, padx=(0, 6)) + qty_entry = tk.Entry( + entry_row, + textvariable=qty_var, + width=12, + font=get_font(FONT_SIZE), + bg="white", + justify=tk.RIGHT, + ) + qty_entry.pack(side=tk.LEFT, padx=4) + + def current_qty() -> int: + s = (qty_var.get() or "").strip().replace(",", "") + if not s: + return 0 + try: + return max(0, int(s)) + except ValueError: + return 0 + + def reset_qty() -> None: + qty_var.set("0") + + ttk.Button(entry_row, text="重置", command=reset_qty, width=8).pack(side=tk.LEFT, padx=8) + + def add(n: int) -> None: + qty_var.set(str(current_qty() + n)) + + def confirm() -> None: + q = current_qty() + if q < 1: + messagebox.showwarning("打袋機", "請輸入需求數量或按 +50、+10、+5、+1。", parent=win) return - else: - result[0] = count_ref[0] + result[0] = q win.destroy() btn_row1 = tk.Frame(win, bg=BG_TOP) @@ -1154,9 +1065,9 @@ def ask_bag_count(parent: tk.Tk) -> Optional[int]: 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) + qty_entry.bind("", lambda e: confirm()) win.protocol("WM_DELETE_WINDOW", win.destroy) win.wait_window() return result[0] @@ -1215,8 +1126,7 @@ def main() -> None: # 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] + laser_send_busy_ref: list = [False] # Top: left [前一天] [date] [後一天] | right [printer dropdown] top = tk.Frame(root, padx=12, pady=12, bg=BG_TOP) @@ -1312,8 +1222,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() @@ -1406,6 +1314,25 @@ def main() -> None: job_orders_frame = tk.Frame(root, bg=BG_LIST) job_orders_frame.pack(fill=tk.BOTH, expand=True, padx=12, pady=12) + search_var = tk.StringVar() + search_frame = tk.Frame(job_orders_frame, bg=BG_LIST) + search_frame.pack(fill=tk.X, pady=(0, 6)) + tk.Label( + search_frame, + text="搜尋品號/工單/批號:", + font=get_font(FONT_SIZE_QTY), + bg=BG_LIST, + fg="black", + ).pack(side=tk.LEFT, padx=(0, 6)) + search_entry = tk.Entry( + search_frame, + textvariable=search_var, + width=32, + font=get_font(FONT_SIZE_QTY), + bg="white", + ) + search_entry.pack(side=tk.LEFT, fill=tk.X, expand=True, padx=(0, 8)) + # 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) @@ -1423,7 +1350,7 @@ def main() -> None: inner.bind("", _on_inner_configure) canvas.bind("", _on_canvas_configure) - # Mouse wheel: make scroll work when hovering over canvas or the list (inner/buttons) + # Mouse wheel: default Tk scroll speed (one unit per notch) def _on_mousewheel(event): if getattr(event, "delta", None) is not None: canvas.yview_scroll(int(-1 * (event.delta / 120)), "units") @@ -1446,6 +1373,7 @@ def main() -> None: 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 + last_plan_start_ref = [date.today()] # plan date for the current list (search filter uses same) after_id_ref = [None] # [str | None] root.after id to cancel retry/refresh def _data_equal(a: Optional[list], b: Optional[list]) -> bool: @@ -1453,9 +1381,13 @@ def main() -> 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 + for x, y in zip(a, b): + if x.get("id") != y.get("id"): + return False + for k in ("bagPrintedQty", "labelPrintedQty", "laserPrintedQty"): + if x.get(k) != y.get(k): + return False + return True def _build_list_from_data(data: list, plan_start: date, preserve_selection: bool) -> None: selected_row_holder[0] = None @@ -1467,38 +1399,64 @@ def main() -> None: 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 "—" + jo_no_display = (jo.get("code") or "").strip() + if not jo_no_display and jo_id is not None: + jo_no_display = raw_batch + elif not jo_no_display: + jo_no_display = "—" + # Line 1: job order no.; line 2: 需求 + 已印(袋/標/激)on one row for compact scrolling + head_line = f"工單:{jo_no_display}" 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) + bag_pq = _printed_qty_int(jo.get("bagPrintedQty")) + label_pq = _printed_qty_int(jo.get("labelPrintedQty")) + laser_pq = _printed_qty_int(jo.get("laserPrintedQty")) + meta_line = ( + f"需求:{qty_str} " + f"已印 袋{bag_pq:,} 標{label_pq:,} 激{laser_pq:,}" + ) + # Columns: fixed-width left | fixed-width 品號 | 品名 (expand) + row = tk.Frame( + inner, + bg=BG_ROW, + relief=tk.RAISED, + bd=2, + cursor="hand2", + padx=10, + pady=LIST_ROW_IPADY, + ) + row.pack(fill=tk.X, pady=LIST_ROW_PADY) - left = tk.Frame(row, bg=BG_ROW) - left.pack(side=tk.LEFT, anchor=tk.NW) + left = tk.Frame(row, bg=BG_ROW, width=LEFT_COL_WIDTH_PX) + left.pack_propagate(False) + left.pack(side=tk.LEFT, anchor=tk.NW, fill=tk.Y) batch_lbl = tk.Label( left, - text=batch, + text=head_line, 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) + meta_lbl = tk.Label( + left, + text=meta_line, + font=get_font(FONT_SIZE_META), + bg=BG_ROW, + fg="#222222", + anchor=tk.W, + justify=tk.LEFT, + wraplength=LEFT_COL_WIDTH_PX - 8, + ) + meta_lbl.pack(anchor=tk.W) - # Column 2: item code only, bigger font, wraps in its own column + code_col = tk.Frame(row, bg=BG_ROW, width=CODE_COL_WIDTH_PX) + code_col.pack_propagate(False) + code_col.pack(side=tk.LEFT, anchor=tk.NW, fill=tk.Y, padx=(6, 2)) code_lbl = tk.Label( - row, + code_col, text=item_code, font=get_font(FONT_SIZE_ITEM_CODE), bg=BG_ROW, @@ -1507,11 +1465,12 @@ def main() -> None: justify=tk.LEFT, anchor=tk.NW, ) - code_lbl.pack(side=tk.LEFT, anchor=tk.NW, padx=(12, 8)) + code_lbl.pack(anchor=tk.NW) - # Column 3: item name only, bigger font, at least double width, wraps under its own column + name_col = tk.Frame(row, bg=BG_ROW) + name_col.pack(side=tk.LEFT, fill=tk.BOTH, expand=True, anchor=tk.NW) name_lbl = tk.Label( - row, + name_col, text=item_name or "—", font=get_font(FONT_SIZE_ITEM_NAME), bg=BG_ROW, @@ -1520,7 +1479,7 @@ def main() -> None: justify=tk.LEFT, anchor=tk.NW, ) - name_lbl.pack(side=tk.LEFT, fill=tk.BOTH, expand=True, anchor=tk.NW) + name_lbl.pack(anchor=tk.NW) def _on_click(e, j=jo, b=batch, r=row): if selected_row_holder[0] is not None: @@ -1554,22 +1513,31 @@ def main() -> None: 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) + 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) + jo_id = j.get("id") + if jo_id is not None: + try: + submit_job_order_print_submit( + base_url_ref[0], int(jo_id), n, "DATAFLEX" + ) + load_job_orders(from_user_date_change=False) + except requests.RequestException as ex: + messagebox.showwarning( + "打袋機", + f"已送出 {n} 張,但伺服器記錄失敗:{ex}", + ) + 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: @@ -1582,7 +1550,7 @@ def main() -> None: item_id = j.get("itemId") stock_in_line_id = j.get("stockInLineId") lot_no = j.get("lotNo") - n = 100 if count == -1 else count + n = count try: # Always render to image (Chinese OK), then send as ZPL graphic (^GFA). # This is more reliable than Windows GDI and works for both Windows printer name and COM. @@ -1598,8 +1566,27 @@ def main() -> None: send_zpl_to_label_printer(com, zpl_img) if i < n - 1: time.sleep(0.5) - msg = f"已送出列印:{n} 張標簽" if count != -1 else f"已送出列印:{n} 張標簽 (連續)" - messagebox.showinfo("標簽機", msg) + jo_id = j.get("id") + if jo_id is not None: + try: + submit_job_order_print_submit( + base_url_ref[0], int(jo_id), n, "LABEL" + ) + load_job_orders(from_user_date_change=False) + messagebox.showinfo( + "標籤機", + f"已送出列印:{n} 張標籤(已記錄)", + ) + except requests.RequestException as ex: + messagebox.showwarning( + "標籤機", + f"標籤已列印 {n} 張,但伺服器記錄失敗:{ex}", + ) + else: + messagebox.showwarning( + "標籤機", + f"已送出列印:{n} 張標籤(無工單 id,無法寫入伺服器記錄)", + ) except Exception as err: messagebox.showerror("標簽機", f"列印失敗:{err}") elif printer_var.get() == "激光機": @@ -1612,61 +1599,60 @@ def main() -> None: 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 "" - if count == -1: - run_laser_continuous_print( - root=root, - laser_conn_ref=laser_conn_ref, - laser_thread_ref=laser_thread_ref, - laser_stop_ref=laser_stop_ref, - ip=ip, - port=port, - item_id=item_id, - stock_in_line_id=stock_in_line_id, - item_code=item_code_val, - item_name=item_name_val, - set_status_message=set_status_message, - ) - else: - n = 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): + 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 "" + run_laser_row_send_thread( + root=root, + laser_conn_ref=laser_conn_ref, + laser_busy_ref=laser_send_busy_ref, + ip=ip, + port=port, + item_id=item_id, + stock_in_line_id=stock_in_line_id, + item_code=item_code_val, + item_name=item_name_val, + set_status_message=set_status_message, + base_url=base_url_ref[0], + job_order_id=j.get("id"), + on_recorded=lambda: load_job_orders(from_user_date_change=False), + ) + + for w in ( + row, + left, + batch_lbl, + meta_lbl, + code_col, + code_lbl, + name_col, + name_lbl, + ): w.bind("", _on_click) w.bind("", _on_mousewheel) w.bind("", _on_mousewheel) w.bind("", _on_mousewheel) - if qty_lbl is not None: - qty_lbl.bind("", _on_click) - qty_lbl.bind("", _on_mousewheel) - qty_lbl.bind("", _on_mousewheel) - qty_lbl.bind("", _on_mousewheel) if preserve_selection and selected_id is not None and jo.get("id") == selected_id: found_row = row if found_row is not None: set_row_highlight(found_row, True) selected_row_holder[0] = found_row + def refresh_visible_list() -> None: + """Re-apply search filter to last fetched rows without hitting the API.""" + raw = last_data_ref[0] + if raw is None: + return + ps = last_plan_start_ref[0] + needle = search_var.get().strip() + shown = _filter_job_orders_by_search(raw, needle) if needle else raw + for w in inner.winfo_children(): + w.destroy() + _build_list_from_data(shown, ps, preserve_selection=True) + + search_entry.bind("", lambda e: refresh_visible_list()) + 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]) @@ -1689,13 +1675,16 @@ def main() -> None: set_status_ok() old_data = last_data_ref[0] last_data_ref[0] = data + last_plan_start_ref[0] = plan_start 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) + needle = search_var.get().strip() + shown = _filter_job_orders_by_search(data, needle) if needle else data + _build_list_from_data(shown, 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)) diff --git a/python/Bag3.py b/python/Bag3.py new file mode 100644 index 0000000..364fc36 --- /dev/null +++ b/python/Bag3.py @@ -0,0 +1,1718 @@ +#!/usr/bin/env python3 +""" +Bag3 v3.1 – FPSMS job orders by plan date (this file is the maintained version). + +Uses the public API GET /py/job-orders (no login required). +UI tuned for aged users: larger font, Traditional Chinese labels, prev/next date. + +Bag2 is kept as a separate legacy v2.x line; do not assume Bag2 matches Bag3. + +Run: python Bag3.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, ImageOps + try: + from PIL import ImageWin # type: ignore + except Exception: + ImageWin = None # type: ignore[assignment] + import qrcode + _HAS_PIL_QR = True +except ImportError: + Image = None # type: ignore[assignment] + ImageDraw = None # type: ignore[assignment] + ImageFont = None # type: ignore[assignment] + ImageOps = None # type: ignore[assignment] + ImageWin = None # type: ignore[assignment] + qrcode = None # type: ignore[assignment] + _HAS_PIL_QR = False + +APP_VERSION = "3.1" + +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__)) +# Bag3 has its own settings file so it doesn't share with Bag1/Bag2. +SETTINGS_FILE = os.path.join(_SETTINGS_DIR, "bag3_settings.json") +LASER_COUNTER_FILE = os.path.join(_SETTINGS_DIR, "bag3_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 +# Printer selector: field + dropdown (use tk.OptionMenu so menu font is respected on Windows) +FONT_SIZE_COMBO = 18 +FONT_SIZE_QTY = 12 # smaller for 需求數量 under batch no. +FONT_SIZE_META = 11 # single-line 需求/已印 (compact list) +# Less vertical padding so ~30 rows fit more comfortably +LIST_ROW_PADY = 2 +LIST_ROW_IPADY = 5 +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: fixed frame widths so 品號/品名 columns line up across rows +LEFT_COL_WIDTH_PX = 300 # 工單 + 需求/已印 block +ITEM_CODE_WRAP = 140 # Label wraplength (px) +# Narrower than wrap+padding so short codes sit closer to 品名 (still aligned across rows) +CODE_COL_WIDTH_PX = ITEM_CODE_WRAP + 6 +ITEM_NAME_WRAP = 640 # item name wraps in remaining space + +# 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_payload = json.dumps({"itemId": item_id, "stockInLineId": stock_in_line_id}) + else: + qr_payload = label_line if label_line else batch_no.strip() + qr_value = _zpl_escape(qr_payload) + return f"""^XA +^CI28 +^PW700 +^LL500 +^PO N +^FO10,20 +^BQN,2,4^FDQA,{qr_value}^FS +^FO170,20 +^A@R,72,72,{font_regular}^FD{desc}^FS +^FO0,200 +^A@R,72,72,{font_regular}^FD{label_esc}^FS +^FO55,200 +^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. +# Enlarged for readability (approx +90% scale). +LABEL_IMAGE_W = 720 +LABEL_IMAGE_H = 530 +LABEL_PADDING = 23 +LABEL_FONT_NAME_SIZE = 42 +LABEL_FONT_CODE_SIZE = 49 +LABEL_FONT_BATCH_SIZE = 34 +LABEL_QR_SIZE = 210 + + +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 real font files on Windows (font *names* may fail and silently fallback). + if os.name == "nt": + fonts_dir = os.path.join(os.environ.get("WINDIR", r"C:\Windows"), "Fonts") + for rel in ( + "msjh.ttc", # Microsoft JhengHei + "msjhl.ttc", # Microsoft JhengHei Light + "msjhbd.ttc", # Microsoft JhengHei Bold + "mingliu.ttc", + "mingliub.ttc", + "kaiu.ttf", + "msyh.ttc", # Microsoft YaHei + "msyhbd.ttc", + "simhei.ttf", + "simsun.ttc", + ): + p = os.path.join(fonts_dir, rel) + try: + if os.path.exists(p): + return ImageFont.truetype(p, size) + except (OSError, IOError): + continue + # Fallback: try common font names (may still work depending on Pillow build) + for name in ( + "Microsoft JhengHei UI", + "Microsoft JhengHei", + "MingLiU", + "MingLiU_HKSCS", + "Microsoft YaHei", + "SimHei", + "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 rule: after 7 "words" (excl. parentheses). ()() not counted; +=*/. and A–Z/a–z count as 0.5. + def _wrap_text(text: str, font, max_width: int) -> list: + ignore = set("()()") + half = set("+=*/.abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ") + max_count = 6.5 + lines: list[str] = [] + current: list[str] = [] + count = 0.0 + + for ch in text: + if ch == "\n": + lines.append("".join(current).strip()) + current = [] + count = 0.0 + continue + + ch_count = 0.0 if ch in ignore else (0.5 if ch in half else 1.0) + if count + ch_count > max_count and current: + lines.append("".join(current).strip()) + current = [] + count = 0.0 + + current.append(ch) + count += ch_count + + if current: + lines.append("".join(current).strip()) + + # Max 2 rows for item name. If still long, keep everything in row 2. + if len(lines) > 2: + lines = [lines[0], "".join(lines[1:]).strip()] + + # Safety: if any line still exceeds pixel width, wrap by width as well. + if hasattr(draw, "textbbox"): + out: list[str] = [] + for ln in lines: + buf: list[str] = [] + for ch in ln: + buf.append(ch) + bbox = draw.textbbox((0, 0), "".join(buf), font=font) + if bbox[2] - bbox[0] > max_width and len(buf) > 1: + out.append("".join(buf[:-1]).strip()) + buf = [buf[-1]] + if buf: + out.append("".join(buf).strip()) + out = [x for x in out if x] + if len(out) > 2: + out = [out[0], "".join(out[1:]).strip()] + return out + + lines = [x for x in lines if x] + if len(lines) > 2: + lines = [lines[0], "".join(lines[1:]).strip()] + return lines + 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 _image_to_zpl_gfa(pil_image: "Image.Image") -> str: + """ + Convert a PIL image into ZPL ^GFA (ASCII hex) so we can print Chinese reliably + on ZPL printers (USB/Windows printer or COM) without relying on GDI drivers. + """ + if Image is None or ImageOps is None: + raise RuntimeError("Pillow is required for image-to-ZPL conversion.") + # Convert to 1-bit monochrome bitmap. Invert so '1' bits represent black in ZPL. + img_bw = ImageOps.invert(pil_image.convert("L")).convert("1") + w, h = img_bw.size + bytes_per_row = (w + 7) // 8 + raw = img_bw.tobytes() + total = bytes_per_row * h + # Ensure length matches expected (Pillow should already pack per row). + if len(raw) != total: + raw = raw[:total].ljust(total, b"\x00") + hex_data = raw.hex().upper() + return f"""^XA +^PW{w} +^LL{h} +^FO0,0 +^GFA,{total},{total},{bytes_per_row},{hex_data} +^FS +^XZ""" + + +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") + dc = win32ui.CreateDC() + dc.CreatePrinterDC(dest) + dc.StartDoc("FPSMS Label") + dc.StartPage() + try: + bmp_w = pil_image.width + bmp_h = pil_image.height + # Scale-to-fit printable area (important for smaller physical labels). + try: + page_w = int(dc.GetDeviceCaps(win32con.HORZRES)) + page_h = int(dc.GetDeviceCaps(win32con.VERTRES)) + except Exception: + page_w, page_h = bmp_w, bmp_h + if page_w <= 0 or page_h <= 0: + page_w, page_h = bmp_w, bmp_h + scale = min(page_w / max(1, bmp_w), page_h / max(1, bmp_h)) + out_w = max(1, int(bmp_w * scale)) + out_h = max(1, int(bmp_h * scale)) + x0 = max(0, (page_w - out_w) // 2) + y0 = max(0, (page_h - out_h) // 2) + + # Most reliable: render via Pillow ImageWin directly to printer DC. + if ImageWin is not None: + dib = ImageWin.Dib(pil_image.convert("RGB")) + dib.draw(dc.GetHandleOutput(), (x0, y0, x0 + out_w, y0 + out_h)) + else: + # Fallback: 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.") + try: + mem_dc = win32ui.CreateDCFromHandle(win32gui.CreateCompatibleDC(dc.GetSafeHdc())) + bmp = getattr(win32ui, "CreateBitmapFromHandle", lambda h: win32ui.PyCBitmap.FromHandle(h))(hbm) + mem_dc.SelectObject(bmp) + dc.StretchBlt((x0, y0), (out_w, out_h), mem_dc, (0, 0), (bmp_w, bmp_h), win32con.SRCCOPY) + finally: + win32gui.DeleteObject(hbm) + finally: + try: + os.unlink(tmp_bmp) + except OSError: + pass + finally: + dc.EndPage() + dc.EndDoc() + + +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) +# Click row with 激光機 selected: send this many times, delay between sends (not after last). +LASER_ROW_SEND_COUNT = 3 +LASER_ROW_SEND_DELAY_SEC = 3 + + +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 using `;` separated 3 params: + {"itemID": itemId, "stockInLineId": stockInLineId} ; itemCode ; itemName ;; + 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 first param; else fallback: 0;item_code;item_name;; + Returns (success, message). + """ + code_str = (item_code or "").strip().replace(";", ",") + name_str = (item_name or "").strip().replace(";", ",") + + if item_id is not None and stock_in_line_id is not None: + # Use compact JSON so device-side parser doesn't get spaces. + json_part = json.dumps( + {"itemID": item_id, "stockInLineId": stock_in_line_id}, + separators=(",", ":"), + ) + reply = f"{json_part};{code_str};{name_str};;" + else: + 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 run_laser_row_send_thread( + root: tk.Tk, + laser_conn_ref: list, + laser_busy_ref: list, + ip: str, + port: int, + item_id: Optional[int], + stock_in_line_id: Optional[int], + item_code: str, + item_name: str, + set_status_message: Callable[[str, bool], None], + base_url: Optional[str] = None, + job_order_id: Optional[int] = None, + on_recorded: Optional[Callable[[], None]] = None, +) -> None: + """ + On row click with 激光機: send LASER_ROW_SEND_COUNT times with LASER_ROW_SEND_DELAY_SEC between sends. + UI updates on main thread; work runs in background so the window does not freeze. + After success, POST LASER qty to API when job_order_id and base_url are set. + """ + if laser_busy_ref[0]: + return + laser_busy_ref[0] = True + + def worker() -> None: + try: + n = LASER_ROW_SEND_COUNT + 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, + item_name, + ) + if not ok: + root.after( + 0, + lambda m=msg: messagebox.showwarning("激光機", m), + ) + return + if i < n - 1: + time.sleep(LASER_ROW_SEND_DELAY_SEC) + posted = False + if base_url and job_order_id is not None: + try: + submit_job_order_print_submit(base_url, int(job_order_id), n, "LASER") + posted = True + except requests.RequestException as ex: + root.after( + 0, + lambda err=str(ex): messagebox.showwarning( + "激光機", + f"已發送,但伺服器記錄失敗:{err}", + ), + ) + root.after( + 0, + lambda: set_status_message("已發送", is_error=False), + ) + if on_recorded is not None and posted: + root.after(0, on_recorded) + except Exception as e: + root.after( + 0, + lambda err=str(e): messagebox.showwarning("激光機", f"送出失敗:{err}"), + ) + finally: + laser_busy_ref[0] = False + + threading.Thread(target=worker, daemon=True).start() + + +def _printed_qty_int(raw) -> int: + """Parse API printed qty field (may be float JSON) to int.""" + try: + return int(float(raw)) if raw is not None else 0 + except (TypeError, ValueError): + return 0 + + +def _filter_job_orders_by_search(data: list, needle: str) -> list: + """Substring match on item code, job order code, item name, lot (case-insensitive).""" + n = needle.strip().lower() + if not n: + return data + out: list = [] + for jo in data: + parts = [ + str(jo.get("itemCode") or ""), + str(jo.get("code") or ""), + str(jo.get("itemName") or ""), + str(jo.get("lotNo") or ""), + ] + if any(n in p.lower() for p in parts): + out.append(jo) + return out + + +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 submit_job_order_print_submit( + base_url: str, + job_order_id: int, + qty: int, + print_channel: str = "LABEL", +) -> None: + """POST /py/job-order-print-submit — one row per submit for DB wastage/stock tracking.""" + url = f"{base_url.rstrip('/')}/py/job-order-print-submit" + resp = requests.post( + url, + json={"jobOrderId": job_order_id, "qty": qty, "printChannel": print_channel}, + timeout=30, + ) + resp.raise_for_status() + + +def set_row_highlight(row_frame: tk.Frame, selected: bool) -> None: + """Set row and all nested Frame/Label children to selected or normal background.""" + bg = BG_ROW_SELECTED if selected else BG_ROW + + def _paint(w: tk.Misc) -> None: + if isinstance(w, (tk.Frame, tk.Label)): + w.configure(bg=bg) + for c in w.winfo_children(): + _paint(c) + + _paint(row_frame) + + +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_label_count(parent: tk.Tk) -> Optional[int]: + """ + When printer is 標簽機, ask how many labels to print: + optional direct qty in text field, +50/+10/+5/+1, 重置, then 確認送出. + Returns count (>= 1), or None if cancelled. + """ + result: list[Optional[int]] = [None] + qty_var = tk.StringVar(value="0") + + win = tk.Toplevel(parent) + win.title("標簽印數") + win.geometry("580x280") + win.transient(parent) + win.grab_set() + win.configure(bg=BG_TOP) + ttk.Label(win, text="印多少個?", font=get_font(FONT_SIZE)).pack(pady=(12, 4)) + + entry_row = tk.Frame(win, bg=BG_TOP) + entry_row.pack(pady=8) + tk.Label(entry_row, text="需求數量:", font=get_font(FONT_SIZE), bg=BG_TOP).pack(side=tk.LEFT, padx=(0, 6)) + qty_entry = tk.Entry( + entry_row, + textvariable=qty_var, + width=12, + font=get_font(FONT_SIZE), + bg="white", + justify=tk.RIGHT, + ) + qty_entry.pack(side=tk.LEFT, padx=4) + + def current_qty() -> int: + s = (qty_var.get() or "").strip().replace(",", "") + if not s: + return 0 + try: + return max(0, int(s)) + except ValueError: + return 0 + + def reset_qty() -> None: + qty_var.set("0") + + ttk.Button(entry_row, text="重置", command=reset_qty, width=8).pack(side=tk.LEFT, padx=8) + + def add(n: int) -> None: + qty_var.set(str(current_qty() + n)) + + def confirm() -> None: + q = current_qty() + if q < 1: + messagebox.showwarning("標簽機", "請輸入需求數量或按 +50、+10、+5、+1。", parent=win) + return + result[0] = q + 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(win, text="確認送出", command=confirm, width=14).pack(pady=12) + qty_entry.bind("", lambda e: confirm()) + 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: + optional direct qty in text field, +50/+10/+5/+1, 重置, then 確認送出. + Returns count (>= 1), or None if cancelled. + """ + result: list[Optional[int]] = [None] + qty_var = tk.StringVar(value="0") + + win = tk.Toplevel(parent) + win.title("打袋列印數量") + win.geometry("580x280") + win.transient(parent) + win.grab_set() + win.configure(bg=BG_TOP) + ttk.Label(win, text="列印多少個袋?", font=get_font(FONT_SIZE)).pack(pady=(12, 4)) + + entry_row = tk.Frame(win, bg=BG_TOP) + entry_row.pack(pady=8) + tk.Label(entry_row, text="需求數量:", font=get_font(FONT_SIZE), bg=BG_TOP).pack(side=tk.LEFT, padx=(0, 6)) + qty_entry = tk.Entry( + entry_row, + textvariable=qty_var, + width=12, + font=get_font(FONT_SIZE), + bg="white", + justify=tk.RIGHT, + ) + qty_entry.pack(side=tk.LEFT, padx=4) + + def current_qty() -> int: + s = (qty_var.get() or "").strip().replace(",", "") + if not s: + return 0 + try: + return max(0, int(s)) + except ValueError: + return 0 + + def reset_qty() -> None: + qty_var.set("0") + + ttk.Button(entry_row, text="重置", command=reset_qty, width=8).pack(side=tk.LEFT, padx=8) + + def add(n: int) -> None: + qty_var.set(str(current_qty() + n)) + + def confirm() -> None: + q = current_qty() + if q < 1: + messagebox.showwarning("打袋機", "請輸入需求數量或按 +50、+10、+5、+1。", parent=win) + return + result[0] = q + 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(win, text="確認送出", command=confirm, width=14).pack(pady=12) + qty_entry.bind("", lambda e: confirm()) + 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(f"FP-MTMS Bag3 v{APP_VERSION} 打袋機") + 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) + # TCombobox field (if other combos use ttk later) + style.configure("TCombobox", font=get_font(FONT_SIZE_COMBO)) + 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_send_busy_ref: list = [False] + + # 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)) + # tk.OptionMenu (not ttk.Combobox): on Windows the ttk dropdown uses OS font and stays tiny; + # OptionMenu's menu supports font= for the open list. + printer_combo = tk.OptionMenu(right_frame, printer_var, *printer_options) + _combo_font = get_font(FONT_SIZE_COMBO) + printer_combo.configure( + font=_combo_font, + bg=BG_TOP, + fg="black", + activebackground=BG_TOP, + activeforeground="black", + width=14, + anchor="w", + highlightthickness=0, + bd=1, + relief=tk.GROOVE, + ) + printer_combo["menu"].configure(font=_combo_font, tearoff=0) + 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_conn_ref[0] is not None: + try: + laser_conn_ref[0].close() + except Exception: + pass + laser_conn_ref[0] = None + + printer_var.trace_add("write", on_printer_selection_changed) + + def open_setup_window(parent_win: tk.Tk, sett: dict, base_url_ref_list: list) -> None: + """Modal setup: API IP/port, 打袋機/激光機 IP+port, 標簽機 COM port.""" + d = tk.Toplevel(parent_win) + d.title("設定") + d.geometry("440x520") + d.transient(parent_win) + d.grab_set() + d.configure(bg=BG_TOP) + f = tk.Frame(d, padx=16, pady=16, bg=BG_TOP) + f.pack(fill=tk.BOTH, expand=True) + grid_row = [0] # use list so inner function can update + + def _ensure_dot_in_entry(entry: tk.Entry) -> None: + """Allow typing dot (.) in Entry when IME or layout blocks it (e.g. 192.168.17.27).""" + def on_key(event): + if event.keysym in ("period", "decimal"): + pos = entry.index(tk.INSERT) + entry.insert(tk.INSERT, ".") + return "break" + entry.bind("", on_key) + + def add_section(label_text: str, key_ip: str | None, key_port: str | None, key_single: str | None): + out = [] + ttk.Label(f, text=label_text, font=get_font(FONT_SIZE_BUTTONS)).grid( + row=grid_row[0], column=0, columnspan=2, sticky=tk.W, pady=(8, 2) + ) + grid_row[0] += 1 + if key_single: + ttk.Label( + f, + text="列印機名稱 (Windows):", + ).grid( + row=grid_row[0], + column=0, + sticky=tk.W, + pady=2, + ) + var = tk.StringVar(value=sett.get(key_single, "")) + e = tk.Entry(f, textvariable=var, width=22, font=get_font(FONT_SIZE), bg="white") + e.grid(row=grid_row[0], column=1, sticky=tk.W, pady=2) + _ensure_dot_in_entry(e) + grid_row[0] += 1 + return [(key_single, var)] + if key_ip: + ttk.Label(f, text="IP:").grid(row=grid_row[0], column=0, sticky=tk.W, pady=2) + var_ip = tk.StringVar(value=sett.get(key_ip, "")) + e_ip = tk.Entry(f, textvariable=var_ip, width=22, font=get_font(FONT_SIZE), bg="white") + e_ip.grid(row=grid_row[0], column=1, sticky=tk.W, pady=2) + _ensure_dot_in_entry(e_ip) + grid_row[0] += 1 + out.append((key_ip, var_ip)) + if key_port: + ttk.Label(f, text="Port:").grid(row=grid_row[0], column=0, sticky=tk.W, pady=2) + var_port = tk.StringVar(value=sett.get(key_port, "")) + e_port = tk.Entry(f, textvariable=var_port, width=12, font=get_font(FONT_SIZE), bg="white") + e_port.grid(row=grid_row[0], column=1, sticky=tk.W, pady=2) + _ensure_dot_in_entry(e_port) + grid_row[0] += 1 + out.append((key_port, var_port)) + return out + + all_vars = [] + all_vars.extend(add_section("API 伺服器", "api_ip", "api_port", None)) + all_vars.extend(add_section("打袋機 DataFlex", "dabag_ip", "dabag_port", None)) + all_vars.extend(add_section("激光機", "laser_ip", "laser_port", None)) + all_vars.extend(add_section("標簽機 (USB)", None, None, "label_com")) + + def on_save(): + for key, var in all_vars: + sett[key] = var.get().strip() + save_settings(sett) + base_url_ref_list[0] = build_base_url(sett["api_ip"], sett["api_port"]) + d.destroy() + + btn_f = tk.Frame(d, bg=BG_TOP) + btn_f.pack(pady=12) + ttk.Button(btn_f, text="儲存", command=on_save).pack(side=tk.LEFT, padx=4) + ttk.Button(btn_f, text="取消", command=d.destroy).pack(side=tk.LEFT, padx=4) + d.wait_window() + + job_orders_frame = tk.Frame(root, bg=BG_LIST) + job_orders_frame.pack(fill=tk.BOTH, expand=True, padx=12, pady=12) + + search_var = tk.StringVar() + search_frame = tk.Frame(job_orders_frame, bg=BG_LIST) + search_frame.pack(fill=tk.X, pady=(0, 6)) + tk.Label( + search_frame, + text="搜尋品號/工單/批號:", + font=get_font(FONT_SIZE_QTY), + bg=BG_LIST, + fg="black", + ).pack(side=tk.LEFT, padx=(0, 6)) + search_entry = tk.Entry( + search_frame, + textvariable=search_var, + width=32, + font=get_font(FONT_SIZE_QTY), + bg="white", + ) + search_entry.pack(side=tk.LEFT, fill=tk.X, expand=True, padx=(0, 8)) + + # Scrollable area for buttons + canvas = tk.Canvas(job_orders_frame, highlightthickness=0, bg=BG_LIST) + scrollbar = ttk.Scrollbar(job_orders_frame, orient=tk.VERTICAL, command=canvas.yview) + inner = tk.Frame(canvas, bg=BG_LIST) + + win_id = canvas.create_window((0, 0), window=inner, anchor=tk.NW) + canvas.configure(yscrollcommand=scrollbar.set) + + def _on_inner_configure(event): + canvas.configure(scrollregion=canvas.bbox("all")) + + def _on_canvas_configure(event): + canvas.itemconfig(win_id, width=event.width) + + inner.bind("", _on_inner_configure) + canvas.bind("", _on_canvas_configure) + + # Mouse wheel: default Tk scroll speed (one unit per notch) + def _on_mousewheel(event): + if getattr(event, "delta", None) is not None: + canvas.yview_scroll(int(-1 * (event.delta / 120)), "units") + elif event.num == 5: + canvas.yview_scroll(1, "units") + elif event.num == 4: + canvas.yview_scroll(-1, "units") + + canvas.bind("", _on_mousewheel) + inner.bind("", _on_mousewheel) + canvas.bind("", _on_mousewheel) + canvas.bind("", _on_mousewheel) + inner.bind("", _on_mousewheel) + inner.bind("", _on_mousewheel) + + scrollbar.pack(side=tk.RIGHT, fill=tk.Y) + canvas.pack(side=tk.LEFT, fill=tk.BOTH, expand=True) + + # Track which row is highlighted (selected for printing) and which job id + selected_row_holder = [None] # [tk.Frame | None] + selected_jo_id_ref = [None] # [int | None] job order id for selection preservation + last_data_ref = [None] # [list | None] last successful fetch for current date + last_plan_start_ref = [date.today()] # plan date for the current list (search filter uses same) + 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 + for x, y in zip(a, b): + if x.get("id") != y.get("id"): + return False + for k in ("bagPrintedQty", "labelPrintedQty", "laserPrintedQty"): + if x.get(k) != y.get(k): + return False + return True + + 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 "—" + jo_no_display = (jo.get("code") or "").strip() + if not jo_no_display and jo_id is not None: + jo_no_display = raw_batch + elif not jo_no_display: + jo_no_display = "—" + # Line 1: job order no.; line 2: 需求 + 已印(袋/標/激)on one row for compact scrolling + head_line = f"工單:{jo_no_display}" + item_code = jo.get("itemCode") or "—" + item_name = jo.get("itemName") or "—" + req_qty = jo.get("reqQty") + qty_str = format_qty(req_qty) + bag_pq = _printed_qty_int(jo.get("bagPrintedQty")) + label_pq = _printed_qty_int(jo.get("labelPrintedQty")) + laser_pq = _printed_qty_int(jo.get("laserPrintedQty")) + meta_line = ( + f"需求:{qty_str} " + f"已印 袋{bag_pq:,} 標{label_pq:,} 激{laser_pq:,}" + ) + # Columns: fixed-width left | fixed-width 品號 | 品名 (expand) + row = tk.Frame( + inner, + bg=BG_ROW, + relief=tk.RAISED, + bd=2, + cursor="hand2", + padx=10, + pady=LIST_ROW_IPADY, + ) + row.pack(fill=tk.X, pady=LIST_ROW_PADY) + + left = tk.Frame(row, bg=BG_ROW, width=LEFT_COL_WIDTH_PX) + left.pack_propagate(False) + left.pack(side=tk.LEFT, anchor=tk.NW, fill=tk.Y) + batch_lbl = tk.Label( + left, + text=head_line, + font=get_font(FONT_SIZE_BUTTONS), + bg=BG_ROW, + fg="black", + ) + batch_lbl.pack(anchor=tk.W) + meta_lbl = tk.Label( + left, + text=meta_line, + font=get_font(FONT_SIZE_META), + bg=BG_ROW, + fg="#222222", + anchor=tk.W, + justify=tk.LEFT, + wraplength=LEFT_COL_WIDTH_PX - 8, + ) + meta_lbl.pack(anchor=tk.W) + + code_col = tk.Frame(row, bg=BG_ROW, width=CODE_COL_WIDTH_PX) + code_col.pack_propagate(False) + code_col.pack(side=tk.LEFT, anchor=tk.NW, fill=tk.Y, padx=(6, 2)) + code_lbl = tk.Label( + code_col, + 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(anchor=tk.NW) + + name_col = tk.Frame(row, bg=BG_ROW) + name_col.pack(side=tk.LEFT, fill=tk.BOTH, expand=True, anchor=tk.NW) + name_lbl = tk.Label( + name_col, + 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(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() + 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) + jo_id = j.get("id") + if jo_id is not None: + try: + submit_job_order_print_submit( + base_url_ref[0], int(jo_id), n, "DATAFLEX" + ) + load_job_orders(from_user_date_change=False) + except requests.RequestException as ex: + messagebox.showwarning( + "打袋機", + f"已送出 {n} 張,但伺服器記錄失敗:{ex}", + ) + 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 = count + try: + # Always render to image (Chinese OK), then send as ZPL graphic (^GFA). + # This is more reliable than Windows GDI and works for both Windows printer name and COM. + if not _HAS_PIL_QR: + raise RuntimeError("請先安裝 Pillow + qrcode(pip install Pillow qrcode[pil])。") + 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, + ) + zpl_img = _image_to_zpl_gfa(label_img) + for i in range(n): + send_zpl_to_label_printer(com, zpl_img) + if i < n - 1: + time.sleep(0.5) + jo_id = j.get("id") + if jo_id is not None: + try: + submit_job_order_print_submit( + base_url_ref[0], int(jo_id), n, "LABEL" + ) + load_job_orders(from_user_date_change=False) + messagebox.showinfo( + "標籤機", + f"已送出列印:{n} 張標籤(已記錄)", + ) + except requests.RequestException as ex: + messagebox.showwarning( + "標籤機", + f"標籤已列印 {n} 張,但伺服器記錄失敗:{ex}", + ) + else: + messagebox.showwarning( + "標籤機", + f"已送出列印:{n} 張標籤(無工單 id,無法寫入伺服器記錄)", + ) + 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: + 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 "" + run_laser_row_send_thread( + root=root, + laser_conn_ref=laser_conn_ref, + laser_busy_ref=laser_send_busy_ref, + ip=ip, + port=port, + item_id=item_id, + stock_in_line_id=stock_in_line_id, + item_code=item_code_val, + item_name=item_name_val, + set_status_message=set_status_message, + base_url=base_url_ref[0], + job_order_id=j.get("id"), + on_recorded=lambda: load_job_orders(from_user_date_change=False), + ) + + for w in ( + row, + left, + batch_lbl, + meta_lbl, + code_col, + code_lbl, + name_col, + name_lbl, + ): + w.bind("", _on_click) + w.bind("", _on_mousewheel) + w.bind("", _on_mousewheel) + w.bind("", _on_mousewheel) + if preserve_selection and selected_id is not None and jo.get("id") == selected_id: + found_row = row + if found_row is not None: + set_row_highlight(found_row, True) + selected_row_holder[0] = found_row + + def refresh_visible_list() -> None: + """Re-apply search filter to last fetched rows without hitting the API.""" + raw = last_data_ref[0] + if raw is None: + return + ps = last_plan_start_ref[0] + needle = search_var.get().strip() + shown = _filter_job_orders_by_search(raw, needle) if needle else raw + for w in inner.winfo_children(): + w.destroy() + _build_list_from_data(shown, ps, preserve_selection=True) + + search_entry.bind("", lambda e: refresh_visible_list()) + + 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 + last_plan_start_ref[0] = plan_start + 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 + needle = search_var.get().strip() + shown = _filter_job_orders_by_search(data, needle) if needle else data + _build_list_from_data(shown, plan_start, preserve_selection=preserve) + if from_user_date_change: + canvas.yview_moveto(0) + after_id_ref[0] = root.after(REFRESH_MS, lambda: load_job_orders(from_user_date_change=False)) + + # Load default (today) on start; then start printer connection check + root.after(100, lambda: load_job_orders(from_user_date_change=True)) + root.after(300, check_printer) + + root.mainloop() + + +if __name__ == "__main__": + main() \ No newline at end of file diff --git a/python/__pycache__/Bag2.cpython-313.pyc b/python/__pycache__/Bag2.cpython-313.pyc index 64e5842fd13f13b89ea67cef34c26b0aa28bca0c..01c2f881be2f66e291ba99153ea2f7b519254e9d 100644 GIT binary patch delta 20859 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 D`Z3c2$8ft?KmU1kU2@ULPt8jfTBxpo_utZHP z+97NbH-`$cH>m2~KvESUqO+Qj$Y})9OpvG(T3G5WVU=rbXcrvtw;BF|gkP%%1bfG| zE1xK|v7EOGTfSpHSZU~!o|v4J78#?9GseRj8Da&I2S=a0KcNOyK}qC5+#c8M(@qaAiHToMu%cQyq+H1m(j{A zzlX>-(oZv&5)UoUTBq;9h`j(Tx?r+o?d=$O3xZqeD_Ql5UI>0Xn3H{@oNS>F4cm$$T1`k`R)XEzX`40L~$vX1A!HVfyl#4NskNyFVMdjyR4!2)v-@e`9Y!&ZA zR3zHZcQ~AW)fT(U?skjf35c>7#6kSb2zZ+Q`|M+6$KX%PkIE7;#xLL8-qG<9u#EdC z9l%i{`e@_4ZRBS9uk+f-BXs)wHa$!KB7C(Z1uK*U6ts7?i3jNG^IN0dfN$bU0Q{s~ ze4Wl+kQDJdcylEI@YY6?MB2R|UGW9HEg$S#utY(I=(~$6$SBQN5|?)v)2ZBD&AZ(W zmx#sq6&+iD1$m$DsoO}tq-W~}6ssY6bns>&OJ47n zi(OrAaUQ0b55Ujw+1}yTbl9669Zhb>F1J4hnBC-Z+uhx+rcQ^;Wp8t|C;>D0L_mOt zze`WcIIfXvB#p90zER$&p#T1MKGiQ%%Q_?IC(oFotlSJJ7oSdzRYUg<#Cg-xiH_IQ zY3T>!Mq0l`BZK&ZzcGfPnmC9$a>7WTH)_-ZUrtP1y_`N?pRTuR1bKZN%MU<(JpI_4 z!Yj(zGOqe+TqWRks;#uRATC~rkkD-rWkk@xkWxF9^~v<$&eW((AVARC zl!8u(3}zlBVFP6!Y&Dh%(Y6Si1|||cCY%9Wz{kYd)HY(1+hjJ*ra%lEUlt)4Smr8P z@u)dMCBz7^<+8dg`axl`$HcW6Epgp_kXBRI?KCa1yX{Lm9J@>{T~2qqv%9O?W!f&b zJKd)I?(HUbm#L(<*kWpTn(U@#d&{;qv8&tJYI1K89ro5Djj*Nb1{ZvCb=o^ROgFSU zTf1(6ANLkh=K14Ko!{p@f9J6|rs_r|a4mi8r?VXMyO?Ny{cVR#M%L6~WaP)rDM?DYz`MEo!%>1g->BYNB%8HK?zpS~f zI}b3!zkhh*u}@Ckap8{rf9kp8{9Y{VPd#`3`Io&HetQ2=9!6;mbU-#tt)#$WOlA-nzRx3SL6Ae{%93Q!)GA0@0xdnI8*(mvZUjLuvHbM>>xf4;fmBcoL#5 z#IL~`H92-VoNgC(#~yJSK)7VQnVN}X&>M~wXP9C>4NmnDNCL^Q=a=_W@$>T=)*iLmaj9$*n)$0OP z;#8bH1?w5iN^VoKvec8a%HXOi=)tM!n#uUZFmFis5nsV~*2jbuk_DV4QXz|N1lXi5 z3(R2QY|7vSK&v3thos-lBqR!Gt3~WVAGHDySJ7iPCy{*m_y0)q2*k!kait*LmXmDG z!dG`=H=i^BVZp@Z5k_bP43y37ZQY^+hHewYVNAEv-PFBMWCx>bV8dwP!lA;2XCi%T*ZFMgeM>j^Fk&O?o?;nbUAvE(;L8I35Ogc(8N>>e z5@c2akS|pTazRlJ!_1UmP)g!JBrOy7ho# zI)Cz)rhH@s8`_qoP=9s1tH~jXUE)y=MtKd40>9h^Q;fIg?i?1bnQSh=b2 z2`V>~JKHzc>a#WZtebqBT70crd@b!hPy23P`JQo(TPj;6`;_DIR?EI&ESeyz?@&*% z3(6L^1L(#fEY&73Q$K#kg?+u2rQ!}maS;zdHw3??0;|5Y0_`{Y%D)fvuaH$U`fL6< z`irmKP}m56zV*n3!9z&)@q7Mq+n`CD1Cdv}JG5lF#~P#`x+Xs%+I&-6EW)Z3LKfH- znPt9N%g)G7xzCiGv7T89^b4dy20t?wA*_Vk> zFJ8F&W>Y?^3f;9k&lXk@anIMc!xAJuCq(?Qn@%;KvYs)VDLa$vTO;~rxPWL6S|Q#^@C9f2`Ha7+XTI>=Wz7Ub1eqT#!=uAhJC$qR?PFb=|qvB>)A_@AQa7zoB4B2G@YqEv)DIxjjz7xt^7^Ug+bD^X#EP&?rd}Tl`wWtpf|#RX>9`W zv&r2RT7mrv`)0R8^vh8I@he;%4###HwI|6_4cUuJ5m*s41A8nwKWXywFb>6T?7$re z=HXWykW9Q4t1yIvlC7X(3x=uzxNzn+nHIt@l^#hfP~OlbZiC_rub#0y0g>!#u|UGd z1`epJsO)k6r1~wG3lH>yT5M0 zIN%!5myYR;edJ(7uN(qi%|T6HNx%E;^o=9>`iXR&iG+ajW578adC|bdo5oF?K6y0S zG!$(bjm{j3&J1PpSB}eAExW>ThAP?n!JIL&hDENCjj449H2XEZCA}@)Z3nlVRcC&F zIf2vXUgAiEARE)^53Jn3vUdk0NvZS5;3ra$or{h;aMS*q5T$Z5$`XcA2^jJCwV8x6 z=r4UcdDE!QKBTjMInLv!FI?Hc{(o6S7Ek3~nwnXe&%Yc!72&)HfUhW$Dzo@kvJ?=u z!uqlaaLs;YtKHGr<*c^E_#-5#x82$1S5%^8=a;#+QSqT%kGK^Y!LMAjtjSul#;;h7 zYK>pr1{%#~kgEL=OKYmFO~Q(el_+DjY!gwi0dyt>`_I&KNdi_N+Xo(H)q2`_$gnVNFWU;xW1EwiSC<_@Z)00`X=AFKOfeOaX^^It9>o>Mhaw|+>!p=ZUIJo2{cz14lBr+P@9d__(a zNtczJLVerHn^*30^(79>I*~afpFb+kUo<3NbcXok@VgS}SiN_(&yaOCr+P$Q^O-zB zk#&UwaCtg?{I-n2U#=^WX~kQRVF+pmWgAb+Ni==AaW&CXLz5u;8KKd&2}-8rj?!D2 zddNH~-?UOO2rJ%2N8naOIfMpa5_w${uHt{5cCyPV$i_To~CN(<~az`KoFft%%;>`%y z6xWZTBM2Tq@DQ~-6LTNN;Li{sJ$|Lj(cx%ugD&rA*(uf{HG3hR9&skm6n}td|AK(c zmRKe6R($;k%hQLgkF1-r3GyWUt1}N+lirmTE$&0)B23l;A-cM&KrsY?`v$+?H7F~M z>ac^Zh>DW4Npx)X>!o*TM6#Fw2GhF$qeV;Y&lqKY{gEp}54!n;O(Dh_gSG=Y?RX@K0h2)- z9j}PU2MIi;&H!0n^-LsW7_oHdWNZ|aDm@?+5|j6L3Sw4TV+3V=3N1O25;^f%1(pC% z{WXGGh+t+0wb!R0>4>b6kPQ>aTCg)jTca^SF<22;0wK-DfuJ6R(*0I>XB50g*$lRr zGG2%d#OVcny_#NpCe-NXX0xTjfrzl>_qu7$XfaHf@+%6pW3%R&0_n zJhnbQn0H+^H53?iP>DFJUWgYGq+05PM2TqNWFD4Po@CWel*LGo28BfpDoX^IJa|y6 z6kznw*i@`a(L%CRC8v(=c-E9?(+Md;YPrlAX^R9^4_lM7Mb=FXT0vx>N8w;%59md5 zSeUsCZW*cmZAfcL?*^5U)2zn67j7Uy5yF%&NwcQKUvD0PG;}n3*vxP0 z5>4A2yPLc0V(T2!rb<`KCet+2rrJ$K8aB+UB|FHkdd~k~|26C&GrK#|$L=*Em;wuTOnR$b0P~!t+u01(rY0~uNz*4`i)Fa2#n2SM zVN(Jy+U1{tOJHawk=8$t>uTZZE9oLYvvMtfn!&0#E>lDpP{yd)U(>g8M3)x~SC8nX zhQeocQ$If!orn^N;T@eG#Sg<5E);ifDV@a~*Hp;)(>wv#!qMK}8x1k`+fTP3tECo5`!e<>ftWs|gul zlD3GVgn#Naxbqd}1PoFJWJx6y@p;en+?Y0QRGT`aO&!&y4{6iKbTI)N)rdOr9d*)( zI%QnOBiEu2tlGco&Z=v$2D{fWeFfo86HP@Fe>#!?^vjwYbj4Gt9!Z0k;6WR)$g2H{ zW-vl-3#@$NAF+B^kMNbxAJGI2FPYq!aD7$df2DdM^c1Wh%2Ckf_3x;o?}!+Sj+blz zek|$t2+kq69s#o>He#p)`MwE4Ef5GjXuf6&n+E2Y1<`28a$-})P{aL3G{!-@N0bkM zg&Et0Jyxw%WsR~%z+$AfYG$C!2v0Z6S=(c51h-D`X-(!hYxJS_iE#>OSZKk*iCe~| z!Y~U~joSv62sC$rg^Z!kL4^#^m4N;ggI9f^G|)*yMlLY06+?1<63yzb7{!!fMzQGu zqu8WcCO{3Upr%$uc(yh1)<*t*IX z2aSh|NVPpUr{Q-Tq$#0U&iu_4&kV14E>zrd?ILQ@f<^p~8KX7{AflJNR*>(im0EEvS%*-u* z46(D3hzyB{kbO|+v#s@6*ZJ1f`<6B^6XErY7CqZ8XuIcbe2!iRvbyKZB+u6yLAt?! zJBf`KZar}Rp~D~D@x)&qedua~h`1jqMeXm}lr9WsSK)QOMc4Zp?Y`@qBYX>6d`|JL zLKm~kz?^ZFT}EP;AZL~tMRm6sFhaJ+3qcViytRQe@!x@vkQop44`!782KrVZeJMD1 zVS6HhV1#t|S~mMOZ}lzQ=G*zbw+e4$Y_vqcGDc6`WcDyEVlUQvLdRh01!@+OMaIZ1 zF@9OsHt{7yWV%EThS;JvVWDwhl5PZU1aBeA2!de*Ov#=w-Rwk^jj{Np1%L}>Z4*8{ z-d-~?+EDfOPx&u>|vILjF)f{%FFKp@b>#B&MA+ zCVE%?cx9jaZDaOD6_=bjp2GoBu&Cj9%f;zjWcr9MW5V3StUcp$4{t~sGbVUf9b9#& zYFxt^;wJ1vH;(AP|Gp`EUv*!}kS=X3GuNj}?W-NqWxx_288@bn^=c1l4@G`{Ig8UL zq5RW5VUc-A7%)7z_U&ZXsLnm4b2F< z-q9N&E;8=Riy2=-ZaB4{pGz=>XO;BDxcZ-_--8u67n?e%{J*;%g!cbIg?dR22jWTP z0`7EF#dLUiDMMeG&b@5ZR~B(E=V&TZ_?M?;R>t$M7!-i?O1uJMUP<9GoUW;y%D*y& zAS}{U&gEa3Ll7=VTQ1{YP1P?gl)qX)mV4l(P_uN7>eX2?{7_CXJSP{zubK7BrpsR| zAsC*nS$3W3wRwp1+5&>%>v#xT#XF#rfdwlK2(Grovt2zV1&Tj{58?p?y$B8>_%Q;C z;4TDU%JOjHAq=7rARfk$7eOC_yAj~X^Q%Jw_&peRFM^*S_$dHOtYo}mBa96$Hq7Q> zj%=8*LC8iJ8Prmp0#AC%%1$md-U zL0o9)_{Tp$qR-~k@xAsTRpO8;n+d8*#&pT7dyneOLpt+_F6**7+!i*LnAua^TQVe% z`|#_nVsz8fM$cD`VjrUA?B*3?YVBC$qH~dPceM{kn$E>0U5rpDl%Huhm3D7yud1hY zNSXNIgjsCn$@MR{56@pSVyhq0H(YHN`%I}+7(Uah6^4r(fN`Ttk#JYDcgsP~mT?Zk z50!p~KQIy$5on+5ozmAnBF`U_tJzyozhy*TF!>G1yxPRZENn7nVOup}Ve4NvA}{<* zUZ9Bkj0125!Q}?RMZ}D%S0%osPV83>xQEnbJr!fgDL%Qew{{2(g^Jsj?OpbR6`x;T z!3QP^gFmDwp*GMLyKD;g+7!*Q+5Bs>$g2K`Uj?sjypSR&p8n6T_% zG~3W%TknqTcG2$c5?$gch~FbCp3C&PcV$&Fi-z{QxzS&*)aI-ZBvQlghgo7{+R_h z{?XXD)864Hl5c>?Bw3j?o4dqLySr(Jd$*_u0v;I@_a~5&7a>DGUp%ulsID2{n-~K? z5@e**%=L+Zr|EOIAC<+)sN;vlro+gCxT|s5oP^-n!RLN>4@rDWB1(yk-|1qUwkSoN zjCR~P(~ykCr2z2r-C~EBA)|wLPEq7Q#3}m6JMHxPUNgPJ5g|umgC6M3l{>+q2DPdP z8`zG}_-3O+v(jQg2?AS*ldq3}(%568wSUN<+lDpto)3(vWwM~jDx{!68C7Lqd*ZLk zZts;eBV<>KT1Z%Sa@zYrI&AjqHLaKub}VdiOnF_9kg6GyR#kO}^xG!0@Ou^u4!21J z=Nsl|1HL%zZkMTLOM6EvxZ||KPK1jV9n4Y*P`zr4nx@d?Dwyawe-!w3Z0>4en4-fe z5-5|c4SvN^V55Ug{}zRqi}85~tdL9uqQMcZ#f4T>6F#(&Ce12>@2{hfvmc^+xC^P- zcV_$|w`UO;@kiweFzoxL`|vk?EHZvHGG{0<=k3V6eT4q*#|gr)wrETjiS~iM>HAg< z>C(sI;z#3hhT?MGj?3#M=VH_P><`5}l6WL>GX6#-PNQ=2O9AWB;5tHiEdhTC|6Mik!R_yLp$hAkrBDc zpc6Ez8r6*vU@g`*>KY?aOP@wMwSo+wM&JReY5yNgG)1eCcSc$xv0dmV6&g7hX(GXg zB#u6RC4%-JR?u4x$I?w}G!RZ?X62U;$J3LeT9uO7arNk0mJKSZD%@QR2xCuB`xMmW zJi3|DW^XJ#ULQ9d5@^3EK|R{+*dyq`8XN)6D3E-k5GiO_4^}e|GG4L=cj|;_@Cgn) zW#FR%rc@nRhM9jESc_E6QiRCgAR|UH-=zAljrQ27@HA0e&2+3Ec&;(ORnCNsDDWt%-N)2M_AQ9u0 zHBvHoE7Bm84MvDq`uI*27$V>`4{Z_~AP5};2LPy;>D_4f)U4a$a7x~DU@vZWnL>(F zfywULW&&q96L@6pHsLO_3!UpELvly^Hb=-2&qDw4Sc<2x!~_#*xXn3#v84bl#?@U; zhpBxty3MsXIyxNSg(vyeEM*FLs7T2WeBscEk8V5QSIwARylZ-Cz*-E(70@`D|H(u5 zoIiFDEV-YYydMO{LHNLI!DewSjumG4MI)G$4XAwJ4QInIKc^r3B`Kv9J~8hD7%0ez z;GAdIb9CtO1lZ1%d@0-T%_GRs4v%=u;fHxyws97PM1D7_R})3_ZwJcqz~mj`bs?@AB2r+|ADqS`pzTnw%-7>k%zKHQMj z2Hq&uOPRm}j#K%N%TYNiI9{BKli)lA%;O2>J8-I?wf)95(%#x67T8-`KZkl>0xKD- zcdB_`RPT-+e_rIB^+(E9e0Fm!JRe{15hld#Y*bjN(7_?44S|;0zP)%}gyZ zeYE$)1$vt_3jWk{Gc+JJHnQ;rk&Hp zkLoht(q;C~^%dKFTRY#WcbE3<_{mJE z5EpiN8OuXlOqV`r20wlgQY3n!f+_yi!@V18_UX)03bflFm4Pc=I|%CN1-$DDPB&pw z5SsU|pb_!U4?OpmNAHv-PjL+C-h_#bU#m}p8;EFcM$aSBgA&)bXx7Miu>g61O0{3U zx^k&iQmvcuU3-JQ-Q6VlS;L75>>MIG_=y4lzpB{{yW?FqfU?0nP!u4acf+nZA^=}u z0%E;IGKXPg9ReI~l7S1w2PVxiuaOCLIy%I77=fi2crH9RA>zsm()A6 zXX%(Brk4lxG(M$wT~BpTJKj93Ndun|9jdwV2FK6Ul5x)ls*Nh=6g0|Fi>H}o|eTW}@@C?A!B zURyh|Zo}xh#-VkMKF4Ms#UW{FmU&u zkG&wdY)Y$?I18r_)GNDjn~FTPr%NHOvrry*JaD2 zLD>@ZoN%g9k`0^QnS`mo29C5dGzVy(BucV5Gbo1O5hl9r{%Fr+p|KDbn>mo@wS~qC zCKxUX35~wIStlY+nV;1iyUCZclzm(#txn%nXl(wUNsZz{Y@rGSi?F^xK1heb1sIoQ ze=4=#pJV{RuoOOum52&*Er-J1l5ZFG#ATQOS7&iK765--Fne6X{CSvtLbwRm?V61x zp$GW?GjZ*@uZn9ZjEhxJs924l20<+V#uH$PBveUjt_g`Y2ZX12NUaUgkuQ?>%vyY1 zPn!mlXwy?G%^_Jwtj8STFT{joT;WTp{XsL0elW(e5;MC#lo?}OV=&F801ddd)20Wr zJSz~}9>Qj>2+jEFK!Ea#xEVtsnMG`a*P~yNA=CjGqD1UK^iBjp0pc6v2Lls2!vzTH zc{pwHUPzt@l(~)t1O*+Jo!_7PErLQ&PRJaQXT1;3rRCeEpGzPXJN zumvpT|GU6L_hQfgF7W@u1^#Dr_hWO6s2yItisQmv_un!Lv4xwRrVL*K0}D4@`*?bC z_~H!XeLD{N|E`&*{Mu$75%v6UTuyUn?XiTb7SYW(tGO{Hn^3>&QuyCr3A0e6|CXhY z>YvPr>Oji=uP=d&2UjhDJFukxr%T}CuPlLAP_GV&mEUD8q)|_0C3hpK|FiY41bYFU zKd8nvF!vVB@LShFY97q?+>EII^a3b)pmVvx*DX|ggZs#lsQ5P9+X8BDFW?GLOlURJ zOsr$HUV9aO7#dgHV4{1gG4=})B5r=Fe3Pqj`Bd{1tgVw+kflQsrA^KH^w z0&Wv@<_gyfiS`3TFHw5He9x6B1K26jrovXznI~|tcaz8Cxw>YBO!T zkX6n*6*dLbqm<7Ua%kMiN=2@aC*;$nljRZFf<>5uc0>B)^I3|iHo1^RUq4ySF#moc zn_6C2MhfVT7xLl?ZLzjAo7rZ84VNM;4|B#`71M;AavAMx37J1XiEPDFI@(l&JE5z85Zp(mOnAwcIIV@bx z!r4&4I9s~S1cYSUa)20>Fh{CW})yTT$b*B@Oma&a6BUE-d5)6j}jJ5)3 z(G|4obQ)Pn?>wEAw9*zeLAVWZS21Q7sNvTMWHtTZbQZtbYM=*Bo9X+fqv$=So40 zSEZ8MqHTIx4D>i-Hgxd>TZ%0eS{xJS+VbJDA-qxuPLRM*O0}BnT9CC#8(}CAcCAp@ zZ4|+;h6Zcg1QQ|FftzOFf?J}!6m(p*G|_*1`MM^w9f%qk^W%WMbxs^bJxFB@9FJ34 zrQ>u;Yb977cZ<&f3Y?T%y^jgzLFLNJJ_MUEzwG! zf8~eZEe$3MH0gpJ;KGj{zVO(C=TDpfOFP43ne;&>5V4hRf7L~vpdY^aR1Blli<#dW z{5bGhF1eq+`C0`Tq=qxIRgwpyI9PioimYPzh*}L4F!S%vF!3V@ev05e0B|7eB_vfu z(2uc407weBDX46&p^v_vLylA5>&>wfwL>~#lb};JxcFa3iS8t_59cek} z%YO~HekC)5yV$WxIONMnz8&{3>0?843`b?)Pb5`=<@^&GJG4m_rJ&tIF75BItcMZ2 zK`#xhkr@<&m2b5Xaw~mocnTS(W5cB@n8WBW;`k7h03j|l0BAb)upJoj9r)%?nOxo0^$p^3&LsJ1*6n?o~Y;!TP!rWkg4^&D{9X-@{ zwoVDhOgPs~H2>`!!oqNXB;Kb=hgipK^YeETuE`DJ8=s;sPd*No}Xp0wDCr2PWotjK?o9i!TxU9RZt3 zfYF@z7`%QIk0EJHupSoZQW$Hcn)A7y!`M&JHOX%X9sO-uWV2dpss+EiO>A$Kd~C=% ze}v$M+jiR4+C|n*aI%=qa($S?3y`BFS~~IjSES=J1fL@~1E0|UN@S-cv5SjM?c4ow z2SWPD`51DUK6l6fiGRfWj#K5wf^3I^ z)_>gMVcY(=<9Pnor_MjtC!R(O9AF|&3Ek|=fx{P`*#};pU+YJ@1rtr|c(FY#bRPA~ z7u8soi#!k^`H?DNQo$RxR9FD+dcg#PX^~`cD}=y@?E{RcKm(w>}nHy!$qJ0e-Ne*<5R%EV9T z*8i-L?N`v}|MM0z&OLywLGMLoGi+mh2jF7Z(;aTP?c)`0o#ef+4~y|dkMLzAmT?1Iy9YzGlt&HcQyyHiZ%>Dr6);DR=K z@n;Eaa>n`wCu*tgbj_#n-g&7cLiR_F?)Y@M(FRTlYh^CkG%!$t=MMDzu1tE-1int6 zYBJZzs^TPv8z&E5a^&k#Tf$Jmp>BQI91!@jAx{(hIc1x}LU8fRQopPnhwqD6Kb*6m ze#O|NB?x|tHC42?x|;0LPqQ>n(O+FEQT`mKsdoC;OZg4&4#fisuN8EuQMW5MhTf*BM^AC~ zz@?XgCrD>luBgN*oCcdAEZ^n+xP(|qk8sA=q6D5TP%FTHh2=h1FBol6=}zM+NKT5E&H2ttIrL7g|z0p)DRCeLR`7p8EcCTD<_;s8g??74{~Wm zv`sgE;?NxoRuJa-Fe>tc2)Rep!1zArWpn|G~2aG3C2z?B^W!oltAp%Nu}6iLH@SFWU!2{I)rYq zEmHVi>MTuyK?jHT|E}@yRUj|gqMZuBIw8$dHbpboKYF zXUPb7@03F3;`aa_UANuUU1AB&(9;2+r_GL9@NY)>)UVhAF1OPq6$FH31xl<|`rH3X z^n{iZX|@Kj2Jj_W0`&rz>ezgIH|C4-F324~df5GROwQrb8qph8t{V0_RYIlawu#w<~@Yv1f+CUFcN8nl0!>_v=rjEmXI87K+QY!M+7&trNed zcR_B@yUIg(B}4E0Ipz?4-rAW2b86DYV1pv7Y>B|-NJyMm6UQo9VoQWhH=V^pZV)cD z#lgC#3|1bF^5$HlbruCXak)?y);b#ltpk0T)mRtc8xVv3em zX9W!_aF#dKYlKbK_cg;hoNO$g_arQ4cw6d ze0LgHbxk&d-et_Nmfn6tVu%ub9!cZDly5HML7r{S>c z=5W@+>*l2E*><7G4Ei!B?#58)OpoZn*X8&j6+ri5q-3c=+zip)SRGlh=r~5>-dX72 zu(W*!(=YQz#xf-Tme37%(rfT$5Q}$8Bw6YST;udlpM+zDgvvH`!!*Kw|_!Xxy<`c*c*RjJxmMlpzC0ix&J%aFnp>4pV=GbWJeah3r@1y={0YG8)Xhzl|ap z8Qqwc9bjS{9>LdP1Zc|;tGsd1q%dj?M&SDI$^yXdOO}~T-mTH3R(S=Lhx?A7jwY4F zlaW0jyZ3wNO!H5)o?3LK#n-gstVw)7+tN?QWmlxkmC= zjZG~W*m11nX!m2~=L(9SSo!G6V>=$N0#CuBnHSYu&MaSfolm&lSJ#L~vB8nh-ir4w z&T99$cMoUp886{d3&$65@n&z+!6t8~f!rINS8z=B;P;O3Clq}Zxm^u4W>g}~v*2q%DZUoX#s6omxRjtXXO5?G zIaB({SYCd={G6p|V8`Q${T1hmR*vP*JRuv)pEj01sBEwq*WnvI4?qGj+D<{Mix$aF1r5WU?XdK{w6g2c>2Q{dIFtgBcdR4!o$js0XjvjzEO)$76zBL4%E z5y;jbIbmL_wQlgohF-n1;>p5Hyro9GgxGjA&5{{Zcm=KW83SrO^U0#r zPH@%4khmI4EX8V1Mbg2L>4XEJ*POIcak^TG_;_K0bde;Ry)q#=&Rz-Lv#=ATd>W@N zi#IQk?DW2qNaph&#u&Xzl8A#Odmn}SFjkJmC+}MZ7uWRV3`b|2OHRE%|DOCYW8zp! zcK^Em6~3gRb4lih3Wt-5&n2XdC8S@}DpO*5RpU{dAD3MCE#4m` zlV{RAByt=1jAK9Ji{u;}1u5%K8d!d8%W&Lv-i{QqpmHGrZ*YAAyAm=1GwEv>C?CUxBaEay#L2UrrA)F6=wqQ>)1u4&=K)cJMmS- zOv8^SSO$m@33nK|+0CU41#2&zwV&uP0~q&!x5rGfmH6GC=KW0uNhAMq{EV3#COk+v z$dr=o5F4+}lQWQZ!23)VF?l`(9$8AoVyyfE1UPK{GV8X9`vd%{PPiz%eUF2gef=u9 z4zsSN#>x!4euaSE%3&7?973k!NcO9(EghA3tFd(Of$fm7YX)kt;>?!*$Tmb&F0QP$ zR@O;oV|jB|SBEG=6!?Sd6afRay#SyMSu!E>@WrpFTd{1p)vsRM)!E$D+_ekt@f7VX zjwbM96$>!s#{d|$t5*nC(ZG9O%qBDQ)?m(TLk%YoKiMFn1|Dcv+{fwY>e_B8^rqy% zjeO5xxxeyWmqQkkKX`j`h}n~l>9Y`o8kY@x5y=f}*mp2#sCBQN576zHb#U=D#PcI~ z6M-b%U`;2QePR=8K$Zz#nAGzCV&mj7v9%~>Lawet@BCac&ATm^L>hh!5qE;%?D_}H zDDbae{Qda;TyhJEO3&>{Q>(p6cVV`Q#GOXcU%DIRN8{$MsXl(`2nmk$%^% zhvWxu8s{MRi~Og{jux@tN$X{d8IM`RE2cfPWSqm_r(31pku_E+04vyM?2801vhc-L zZ*l=yU9>^Z>7owI-9HyrK1HNot%HS55&4A+4GO<5T9nXW=FXXo4dwi~*`%S|dshK5 zHQfcZf$N2)k z_hSg>5x;&(<0Z) z(E`_hu?zv8U6Pa

9Ol`V%PHuWo8$>vWT}Krm-Rw6OTq^E$g)yE_~U#D@V7^5k4^ z9e;irxse>7UjkCy#u=o13@!o5*`G7cL->8Qb{x(umyatsZPYjz`^(|h4Nc^D1cu-y z9pktbLpm*)LaJZSNZsM;_WGgWgTvG6Fj>Ki3*j1 zFYm)eISLFwISDD_Di&13A*}HT7SwPNx^XQF>X48~7L4Me;(J^6Uq2qrUiDl;MxX0o z=eU8r#&G)B@mLl#Vla*c+( zCz%D`GVjm1J7=7OVE^&~;YihG3|@AVMl~^xFEVg)-M&4;e9}8|^$(VU_%2WWe7voU zQ^&##LSn`u4Yw>E_x^Dv*}bkNiaV{<*Q9Z$Q*|&`p0+4!rgNvKDIxr3l)5IFe=~_7 mOpB_S#=lua5KdRuEa2aq!z0dog5d=i_7UTnB;J=u0RDf~ toPyJobOrderListItem(jo) } + val ids = filtered.mapNotNull { it.id } + val printed = pyJobOrderPrintSubmitService.sumPrintedQtyByJobOrderIds(ids) + return filtered.map { jo -> + toPyJobOrderListItem(jo, printed[jo.id!!]) + } } private fun parseLaserItemCodeFilters(raw: String?): Set { @@ -119,13 +126,14 @@ class PlasticBagPrinterService( .toSet() } - private fun toPyJobOrderListItem(jo: JobOrder): PyJobOrderListItem { + private fun toPyJobOrderListItem(jo: JobOrder, printed: PrintedQtyByChannel?): PyJobOrderListItem { val itemCode = jo.bom?.item?.code ?: jo.bom?.code val itemName = jo.bom?.name ?: jo.bom?.item?.name val itemId = jo.bom?.item?.id val stockInLine = jo.id?.let { stockInLineRepository.findFirstByJobOrder_IdAndDeletedFalse(it) } val stockInLineId = stockInLine?.id val lotNo = stockInLine?.lotNo + val p = printed ?: PrintedQtyByChannel() return PyJobOrderListItem( id = jo.id!!, code = jo.code, @@ -136,6 +144,9 @@ class PlasticBagPrinterService( stockInLineId = stockInLineId, itemId = itemId, lotNo = lotNo, + bagPrintedQty = p.bagPrintedQty, + labelPrintedQty = p.labelPrintedQty, + laserPrintedQty = p.laserPrintedQty, ) } diff --git a/src/main/java/com/ffii/fpsms/py/PrintedQtyByChannel.kt b/src/main/java/com/ffii/fpsms/py/PrintedQtyByChannel.kt new file mode 100644 index 0000000..1f53f6e --- /dev/null +++ b/src/main/java/com/ffii/fpsms/py/PrintedQtyByChannel.kt @@ -0,0 +1,8 @@ +package com.ffii.fpsms.py + +/** Per–job-order cumulative printed qty, split by printer channel (not mixed). */ +data class PrintedQtyByChannel( + val bagPrintedQty: Long = 0, + val labelPrintedQty: Long = 0, + val laserPrintedQty: Long = 0, +) diff --git a/src/main/java/com/ffii/fpsms/py/PyController.kt b/src/main/java/com/ffii/fpsms/py/PyController.kt index 2fc88d9..e80157e 100644 --- a/src/main/java/com/ffii/fpsms/py/PyController.kt +++ b/src/main/java/com/ffii/fpsms/py/PyController.kt @@ -7,9 +7,13 @@ import com.ffii.fpsms.modules.stock.entity.StockInLineRepository import org.springframework.format.annotation.DateTimeFormat import org.springframework.http.ResponseEntity import org.springframework.web.bind.annotation.GetMapping +import org.springframework.web.bind.annotation.PostMapping +import org.springframework.web.bind.annotation.RequestBody import org.springframework.web.bind.annotation.RequestMapping import org.springframework.web.bind.annotation.RequestParam import org.springframework.web.bind.annotation.RestController +import org.springframework.web.server.ResponseStatusException +import org.springframework.http.HttpStatus import java.time.LocalDate import java.time.LocalDateTime @@ -23,6 +27,7 @@ open class PyController( private val jobOrderRepository: JobOrderRepository, private val stockInLineRepository: StockInLineRepository, private val plasticBagPrinterService: PlasticBagPrinterService, + private val pyJobOrderPrintSubmitService: PyJobOrderPrintSubmitService, ) { companion object { private const val PACKAGING_PROCESS_NAME = "包裝" @@ -46,10 +51,34 @@ open class PyController( dayEndExclusive, PACKAGING_PROCESS_NAME, ) - val list = orders.map { jo -> toListItem(jo) } + val ids = orders.mapNotNull { it.id } + val printed = pyJobOrderPrintSubmitService.sumPrintedQtyByJobOrderIds(ids) + val list = orders.map { jo -> + toListItem(jo, printed[jo.id!!]) + } return ResponseEntity.ok(list) } + /** + * Record a print submit from Bag2 (e.g. 標簽機). No login. + * POST /py/job-order-print-submit + * Body: { "jobOrderId": 1, "qty": 10, "printChannel": "LABEL" | "DATAFLEX" | "LASER" } + */ + @PostMapping("/job-order-print-submit") + open fun submitJobOrderPrint( + @RequestBody body: PyJobOrderPrintSubmitRequest, + ): ResponseEntity { + val channel = body.printChannel?.trim()?.takeIf { it.isNotEmpty() } ?: PyPrintChannel.LABEL + if ( + channel != PyPrintChannel.LABEL && + channel != PyPrintChannel.DATAFLEX && + channel != PyPrintChannel.LASER + ) { + throw ResponseStatusException(HttpStatus.BAD_REQUEST, "Unsupported printChannel: $channel") + } + return ResponseEntity.ok(pyJobOrderPrintSubmitService.recordPrint(body.jobOrderId, body.qty, channel)) + } + /** * Same as [listJobOrders] but filtered by system setting [com.ffii.fpsms.modules.common.SettingNames.LASER_PRINT_ITEM_CODES] * (comma-separated item codes). Public — no login (same as /py/job-orders). @@ -62,13 +91,14 @@ open class PyController( return ResponseEntity.ok(plasticBagPrinterService.listLaserPrintJobOrders(date)) } - private fun toListItem(jo: JobOrder): PyJobOrderListItem { + private fun toListItem(jo: JobOrder, printed: PrintedQtyByChannel?): PyJobOrderListItem { val itemCode = jo.bom?.item?.code ?: jo.bom?.code val itemName = jo.bom?.name ?: jo.bom?.item?.name val itemId = jo.bom?.item?.id val stockInLine = jo.id?.let { stockInLineRepository.findFirstByJobOrder_IdAndDeletedFalse(it) } val stockInLineId = stockInLine?.id val lotNo = stockInLine?.lotNo + val p = printed ?: PrintedQtyByChannel() return PyJobOrderListItem( id = jo.id!!, code = jo.code, @@ -79,6 +109,9 @@ open class PyController( stockInLineId = stockInLineId, itemId = itemId, lotNo = lotNo, + bagPrintedQty = p.bagPrintedQty, + labelPrintedQty = p.labelPrintedQty, + laserPrintedQty = p.laserPrintedQty, ) } } diff --git a/src/main/java/com/ffii/fpsms/py/PyJobOrderListItem.kt b/src/main/java/com/ffii/fpsms/py/PyJobOrderListItem.kt index ac29be9..017d9d6 100644 --- a/src/main/java/com/ffii/fpsms/py/PyJobOrderListItem.kt +++ b/src/main/java/com/ffii/fpsms/py/PyJobOrderListItem.kt @@ -19,4 +19,10 @@ data class PyJobOrderListItem( val stockInLineId: Long?, val itemId: Long?, val lotNo: String?, + /** Cumulative qty from 打袋機 DataFlex submits (DATAFLEX). */ + val bagPrintedQty: Long = 0, + /** Cumulative qty from 標簽機 submits (LABEL). */ + val labelPrintedQty: Long = 0, + /** Cumulative qty from 激光機 submits (LASER). */ + val laserPrintedQty: Long = 0, ) diff --git a/src/main/java/com/ffii/fpsms/py/PyJobOrderPrintSubmitRepository.kt b/src/main/java/com/ffii/fpsms/py/PyJobOrderPrintSubmitRepository.kt new file mode 100644 index 0000000..713bdcc --- /dev/null +++ b/src/main/java/com/ffii/fpsms/py/PyJobOrderPrintSubmitRepository.kt @@ -0,0 +1,35 @@ +package com.ffii.fpsms.py + +import com.ffii.core.support.AbstractRepository +import com.ffii.fpsms.py.entity.PyJobOrderPrintSubmit +import org.springframework.data.jpa.repository.Query +import org.springframework.data.repository.query.Param + +interface PyJobOrderPrintSubmitRepository : AbstractRepository { + + @Query( + value = + "SELECT job_order_id, COALESCE(SUM(qty), 0) FROM py_job_order_print_submit " + + "WHERE deleted = 0 AND job_order_id IN (:ids) AND print_channel = :channel " + + "GROUP BY job_order_id", + nativeQuery = true, + ) + fun sumQtyGroupedByJobOrderId( + @Param("ids") ids: List, + @Param("channel") channel: String, + ): List> + + /** + * One row per (job_order_id, print_channel) with summed qty. + */ + @Query( + value = + "SELECT job_order_id, print_channel, COALESCE(SUM(qty), 0) FROM py_job_order_print_submit " + + "WHERE deleted = 0 AND job_order_id IN (:ids) " + + "GROUP BY job_order_id, print_channel", + nativeQuery = true, + ) + fun sumQtyGroupedByJobOrderIdAndChannel( + @Param("ids") ids: List, + ): List> +} diff --git a/src/main/java/com/ffii/fpsms/py/PyJobOrderPrintSubmitRequest.kt b/src/main/java/com/ffii/fpsms/py/PyJobOrderPrintSubmitRequest.kt new file mode 100644 index 0000000..503a6f1 --- /dev/null +++ b/src/main/java/com/ffii/fpsms/py/PyJobOrderPrintSubmitRequest.kt @@ -0,0 +1,11 @@ +package com.ffii.fpsms.py + +/** + * POST /py/job-order-print-submit + */ +data class PyJobOrderPrintSubmitRequest( + val jobOrderId: Long, + val qty: Int, + /** [PyPrintChannel.LABEL] | [PyPrintChannel.DATAFLEX] | [PyPrintChannel.LASER]; omit or blank → LABEL. */ + val printChannel: String? = null, +) diff --git a/src/main/java/com/ffii/fpsms/py/PyJobOrderPrintSubmitResponse.kt b/src/main/java/com/ffii/fpsms/py/PyJobOrderPrintSubmitResponse.kt new file mode 100644 index 0000000..f32f147 --- /dev/null +++ b/src/main/java/com/ffii/fpsms/py/PyJobOrderPrintSubmitResponse.kt @@ -0,0 +1,9 @@ +package com.ffii.fpsms.py + +data class PyJobOrderPrintSubmitResponse( + val jobOrderId: Long, + val submittedQty: Int, + val printChannel: String, + /** Cumulative printed qty for this job order and [printChannel] after this submit. */ + val cumulativeQtyForChannel: Long, +) diff --git a/src/main/java/com/ffii/fpsms/py/PyJobOrderPrintSubmitService.kt b/src/main/java/com/ffii/fpsms/py/PyJobOrderPrintSubmitService.kt new file mode 100644 index 0000000..5f168b3 --- /dev/null +++ b/src/main/java/com/ffii/fpsms/py/PyJobOrderPrintSubmitService.kt @@ -0,0 +1,72 @@ +package com.ffii.fpsms.py + +import com.ffii.fpsms.modules.jobOrder.entity.JobOrderRepository +import com.ffii.fpsms.py.entity.PyJobOrderPrintSubmit +import org.springframework.http.HttpStatus +import org.springframework.stereotype.Service +import org.springframework.transaction.annotation.Transactional +import org.springframework.web.server.ResponseStatusException + +@Service +open class PyJobOrderPrintSubmitService( + private val repository: PyJobOrderPrintSubmitRepository, + private val jobOrderRepository: JobOrderRepository, +) { + + /** + * Cumulative printed qty per job order, split by channel (打袋 / 標籤 / 激光). + */ + open fun sumPrintedQtyByJobOrderIds(ids: List): Map { + if (ids.isEmpty()) return emptyMap() + val rows = repository.sumQtyGroupedByJobOrderIdAndChannel(ids) + val out = mutableMapOf() + for (row in rows) { + val jobOrderId = (row[0] as Number).toLong() + val channel = (row[1] as String).trim() + val qty = (row[2] as Number).toLong() + val cur = out.getOrDefault(jobOrderId, PrintedQtyByChannel()) + out[jobOrderId] = + when (channel) { + PyPrintChannel.DATAFLEX -> cur.copy(bagPrintedQty = qty) + PyPrintChannel.LABEL -> cur.copy(labelPrintedQty = qty) + PyPrintChannel.LASER -> cur.copy(laserPrintedQty = qty) + else -> cur + } + } + return out + } + + private fun sumPrintedByJobOrderIdsAndChannel(ids: List, channel: String): Map { + if (ids.isEmpty()) return emptyMap() + val rows = repository.sumQtyGroupedByJobOrderId(ids, channel) + return rows.associate { row -> + (row[0] as Number).toLong() to (row[1] as Number).toLong() + } + } + + /** + * Persist one submit row and return cumulative print total for that job order and channel. + */ + @Transactional + open fun recordPrint(jobOrderId: Long, qty: Int, printChannel: String): PyJobOrderPrintSubmitResponse { + if (qty < 1) { + throw ResponseStatusException(HttpStatus.BAD_REQUEST, "qty must be at least 1") + } + val jo = jobOrderRepository.findById(jobOrderId).orElseThrow { + ResponseStatusException(HttpStatus.NOT_FOUND, "Job order not found: $jobOrderId") + } + val row = PyJobOrderPrintSubmit().apply { + jobOrder = jo + this.qty = qty + this.printChannel = printChannel + } + repository.save(row) + val total = sumPrintedByJobOrderIdsAndChannel(listOf(jobOrderId), printChannel)[jobOrderId] ?: qty.toLong() + return PyJobOrderPrintSubmitResponse( + jobOrderId = jobOrderId, + submittedQty = qty, + printChannel = printChannel, + cumulativeQtyForChannel = total, + ) + } +} diff --git a/src/main/java/com/ffii/fpsms/py/PyPrintChannel.kt b/src/main/java/com/ffii/fpsms/py/PyPrintChannel.kt new file mode 100644 index 0000000..f2f81ae --- /dev/null +++ b/src/main/java/com/ffii/fpsms/py/PyPrintChannel.kt @@ -0,0 +1,8 @@ +package com.ffii.fpsms.py + +/** Values for [com.ffii.fpsms.py.entity.PyJobOrderPrintSubmit.printChannel]. */ +object PyPrintChannel { + const val LABEL = "LABEL" + const val DATAFLEX = "DATAFLEX" + const val LASER = "LASER" +} diff --git a/src/main/java/com/ffii/fpsms/py/entity/PyJobOrderPrintSubmit.kt b/src/main/java/com/ffii/fpsms/py/entity/PyJobOrderPrintSubmit.kt new file mode 100644 index 0000000..92a7858 --- /dev/null +++ b/src/main/java/com/ffii/fpsms/py/entity/PyJobOrderPrintSubmit.kt @@ -0,0 +1,35 @@ +package com.ffii.fpsms.py.entity + +import com.ffii.core.entity.BaseEntity +import com.ffii.fpsms.modules.jobOrder.entity.JobOrder +import jakarta.persistence.Column +import jakarta.persistence.Entity +import jakarta.persistence.FetchType +import jakarta.persistence.JoinColumn +import jakarta.persistence.ManyToOne +import jakarta.persistence.Table +import jakarta.validation.constraints.NotNull +import jakarta.validation.constraints.Size + +/** + * One row each time a Bag/py client submits a print quantity for a job order (per printer channel). + * [printChannel] distinguishes 打袋 (DATAFLEX), 標籤 (LABEL), 激光 (LASER); cumulative [qty] per channel. + */ +@Entity +@Table(name = "py_job_order_print_submit") +open class PyJobOrderPrintSubmit : BaseEntity() { + + @NotNull + @ManyToOne(fetch = FetchType.LAZY) + @JoinColumn(name = "job_order_id", nullable = false, columnDefinition = "INT") + open var jobOrder: JobOrder? = null + + @NotNull + @Column(name = "qty", nullable = false) + open var qty: Int? = null + + @Size(max = 32) + @NotNull + @Column(name = "print_channel", nullable = false, length = 32) + open var printChannel: String? = null +} diff --git a/src/main/resources/db/changelog/changes/20260326_fai/01_create_py_job_order_print_submit.sql b/src/main/resources/db/changelog/changes/20260326_fai/01_create_py_job_order_print_submit.sql new file mode 100644 index 0000000..1825ba7 --- /dev/null +++ b/src/main/resources/db/changelog/changes/20260326_fai/01_create_py_job_order_print_submit.sql @@ -0,0 +1,22 @@ +--liquibase formatted sql +--changeset fai:20260326_py_job_order_print_submit + +CREATE TABLE IF NOT EXISTS `py_job_order_print_submit` ( + `id` BIGINT NOT NULL AUTO_INCREMENT, + `created` DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP, + `createdBy` VARCHAR(30) NULL DEFAULT NULL, + `version` INT NOT NULL DEFAULT '0', + `modified` DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP, + `modifiedBy` VARCHAR(30) NULL DEFAULT NULL, + `deleted` TINYINT(1) NOT NULL DEFAULT '0', + + `job_order_id` INT NOT NULL COMMENT 'FK job_order (must match job_order.id type)', + `qty` INT NOT NULL COMMENT 'Quantity printed this submit (labels, bags, etc.)', + `print_channel` VARCHAR(32) NOT NULL DEFAULT 'LABEL' COMMENT 'LABEL=標簽機, DATAFLEX=打袋機, …', + + CONSTRAINT `pk_py_job_order_print_submit` PRIMARY KEY (`id`), + KEY `idx_py_jops_job_order` (`job_order_id`), + KEY `idx_py_jops_channel_created` (`print_channel`, `created`), + CONSTRAINT `fk_py_jops_job_order` FOREIGN KEY (`job_order_id`) REFERENCES `job_order` (`id`) +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci + COMMENT='Per-submit print qty for Bag2/py clients; cumulative per job order for wastage/stock.';