| @@ -26,6 +26,7 @@ import tempfile | |||
| import threading | |||
| import time | |||
| import tkinter as tk | |||
| from dataclasses import dataclass | |||
| from datetime import date, datetime, timedelta | |||
| from tkinter import messagebox, ttk | |||
| from typing import Callable, Optional, Tuple | |||
| @@ -1887,6 +1888,204 @@ def ask_bag_count(parent: tk.Tk) -> Optional[Tuple[int, bool]]: | |||
| return result[0] | |||
| @dataclass(frozen=True) | |||
| class DataflexPrintSession: | |||
| """ | |||
| Snapshot taken when the user starts DataFlex print (especially C 連續印). | |||
| The worker must use only this object — not grid row index, scroll position, or selection. | |||
| """ | |||
| job_order_id: Optional[int] | |||
| job_code: str | |||
| item_code: str | |||
| item_name: str | |||
| label_text: str | |||
| zpl: str | |||
| printer_ip: str | |||
| printer_port: int | |||
| batch_display: str | |||
| def build_dataflex_print_session( | |||
| jo: dict, | |||
| batch: str, | |||
| zpl: str, | |||
| label_text: str, | |||
| printer_ip: str, | |||
| printer_port: int, | |||
| ) -> DataflexPrintSession: | |||
| jo_id = jo.get("id") | |||
| jo_code = (jo.get("code") or "").strip() | |||
| if not jo_code and jo_id is not None: | |||
| jo_code = f"#{jo_id}" | |||
| elif not jo_code: | |||
| jo_code = "—" | |||
| return DataflexPrintSession( | |||
| job_order_id=int(jo_id) if jo_id is not None else None, | |||
| job_code=jo_code, | |||
| item_code=(jo.get("itemCode") or "—").strip(), | |||
| item_name=(jo.get("itemName") or "—").strip(), | |||
| label_text=label_text, | |||
| zpl=zpl, | |||
| printer_ip=printer_ip, | |||
| printer_port=printer_port, | |||
| batch_display=(batch or "—").strip(), | |||
| ) | |||
| def run_dataflex_continuous_thread( | |||
| root: tk.Tk, | |||
| session: DataflexPrintSession, | |||
| stop_event: threading.Event, | |||
| stop_win: tk.Toplevel, | |||
| dataflex_lock: threading.Lock, | |||
| dataflex_busy_ref: list, | |||
| dataflex_stop_win_ref: list, | |||
| active_session_ref: list, | |||
| base_url: str, | |||
| set_status_message: Callable[[str, bool], None], | |||
| on_recorded: Callable[[], None], | |||
| ) -> None: | |||
| """Send bags in a loop until stop_event; all payload comes from session (in-memory snapshot).""" | |||
| def worker() -> None: | |||
| with dataflex_lock: | |||
| if dataflex_busy_ref[0]: | |||
| active_session_ref[0] = None | |||
| def _abort_start() -> None: | |||
| messagebox.showwarning( | |||
| "打袋機", | |||
| "請等待目前列印完成或先停止連續列印。", | |||
| ) | |||
| dataflex_stop_win_ref[0] = None | |||
| try: | |||
| stop_win.destroy() | |||
| except tk.TclError: | |||
| pass | |||
| root.after(0, _abort_start) | |||
| return | |||
| dataflex_busy_ref[0] = True | |||
| ip = session.printer_ip | |||
| port = session.printer_port | |||
| zpl = session.zpl | |||
| label_text = session.label_text | |||
| printed = 0 | |||
| error_shown = False | |||
| try: | |||
| send_dataflex_start_job_reset(ip, port, force=True) | |||
| while not stop_event.is_set(): | |||
| send_dataflex_label_with_recovery(ip, port, zpl) | |||
| printed += 1 | |||
| if DATAFLEX_UI_PROGRESS_EVERY > 0 and ( | |||
| printed == 1 or printed % DATAFLEX_UI_PROGRESS_EVERY == 0 | |||
| ): | |||
| p = printed | |||
| root.after( | |||
| 0, | |||
| lambda p=p, jc=session.job_code: set_status_message( | |||
| f"連續打袋 · 工單 {jc}… 已印 {p} 張", | |||
| is_error=False, | |||
| ), | |||
| ) | |||
| if ( | |||
| DATAFLEX_VERIFY_EVERY_LABELS > 0 | |||
| and printed % DATAFLEX_VERIFY_EVERY_LABELS == 0 | |||
| ): | |||
| recover_dataflex_if_host_fault(ip, port) | |||
| if ( | |||
| DATAFLEX_COOLDOWN_EVERY_LABELS > 0 | |||
| and printed % DATAFLEX_COOLDOWN_EVERY_LABELS == 0 | |||
| ): | |||
| _sleep_interruptible(stop_event, max(0.0, DATAFLEX_COOLDOWN_SEC)) | |||
| if ( | |||
| DATAFLEX_THERMAL_REST_EVERY_LABELS > 0 | |||
| and printed % DATAFLEX_THERMAL_REST_EVERY_LABELS == 0 | |||
| ): | |||
| _sleep_interruptible(stop_event, max(0.0, DATAFLEX_THERMAL_REST_SEC)) | |||
| _sleep_interruptible(stop_event, DATAFLEX_INTER_LABEL_DELAY_SEC) | |||
| except ConnectionRefusedError: | |||
| error_shown = True | |||
| root.after( | |||
| 0, | |||
| lambda: set_status_message( | |||
| f"無法連線至 {ip}:{port},請確認印表機已開機且 IP 正確。", | |||
| is_error=True, | |||
| ), | |||
| ) | |||
| except socket.timeout: | |||
| error_shown = True | |||
| root.after( | |||
| 0, | |||
| lambda: set_status_message( | |||
| f"連線逾時 ({ip}:{port}),請檢查網路與連接埠。", | |||
| is_error=True, | |||
| ), | |||
| ) | |||
| except OSError as err: | |||
| error_shown = True | |||
| root.after( | |||
| 0, | |||
| lambda e=err: set_status_message(f"列印失敗:{e}", is_error=True), | |||
| ) | |||
| except RuntimeError as err: | |||
| error_shown = True | |||
| root.after( | |||
| 0, | |||
| lambda e=err: set_status_message(f"打袋機錯誤:{e}", is_error=True), | |||
| ) | |||
| except Exception as err: | |||
| error_shown = True | |||
| root.after( | |||
| 0, | |||
| lambda e=err: set_status_message(f"打袋機例外:{e}", is_error=True), | |||
| ) | |||
| finally: | |||
| with dataflex_lock: | |||
| dataflex_busy_ref[0] = False | |||
| active_session_ref[0] = None | |||
| def _done() -> None: | |||
| dataflex_stop_win_ref[0] = None | |||
| try: | |||
| if os.name == "nt": | |||
| stop_win.attributes("-topmost", False) | |||
| except tk.TclError: | |||
| pass | |||
| try: | |||
| stop_win.destroy() | |||
| except tk.TclError: | |||
| pass | |||
| jc = session.job_code | |||
| if printed > 0: | |||
| set_status_message( | |||
| f"連續列印結束:工單 {jc} · {label_text},已印 {printed} 張", | |||
| is_error=False, | |||
| ) | |||
| if session.job_order_id is not None: | |||
| try: | |||
| submit_job_order_print_submit( | |||
| base_url, | |||
| session.job_order_id, | |||
| printed, | |||
| "DATAFLEX", | |||
| ) | |||
| on_recorded() | |||
| except requests.RequestException as ex: | |||
| messagebox.showwarning( | |||
| "打袋機", | |||
| f"列印可能已完成,但伺服器記錄失敗(可再試):{ex}", | |||
| ) | |||
| elif not error_shown: | |||
| set_status_message("連續列印未印出或已取消", is_error=True) | |||
| root.after(0, _done) | |||
| threading.Thread(target=worker, daemon=True).start() | |||
| def _sleep_interruptible(stop_event: threading.Event, total_sec: float) -> None: | |||
| """Sleep up to total_sec but return early if stop_event is set.""" | |||
| end = time.perf_counter() + total_sec | |||
| @@ -1903,16 +2102,18 @@ def open_dataflex_stop_window( | |||
| parent: tk.Tk, | |||
| stop_event: threading.Event, | |||
| stop_win_ref: list, | |||
| session: DataflexPrintSession, | |||
| ) -> tk.Toplevel: | |||
| """ | |||
| Small window with 停止列印 for DataFlex continuous mode (non-modal so stop stays usable). | |||
| Stays above other dialogs (e.g. 標籤機 quantity) via periodic lift + optional topmost on Windows, | |||
| so switching printer and printing labels does not hide the stop control. Ref is cleared on destroy. | |||
| Job details come from the in-memory session snapshot, not the grid selection. | |||
| """ | |||
| win = tk.Toplevel(parent) | |||
| win.title("打袋機連續列印") | |||
| win.geometry("420x170") | |||
| win.geometry("480x240") | |||
| # On Windows, transient(root) can hide this Toplevel when the menubutton / printer row | |||
| # updates (e.g. switching to 激光機); keep transient only on non-Windows. | |||
| if os.name != "nt": | |||
| @@ -1927,11 +2128,28 @@ def open_dataflex_stop_window( | |||
| tk.Label( | |||
| win, | |||
| text="連續列印進行中(與上方列印機選項無關),可隨時按下方停止。", | |||
| text="連續列印進行中(內容以按下 C 時的工單為準,與列表捲動/日期無關)", | |||
| font=get_font(FONT_SIZE_META), | |||
| bg=BG_TOP, | |||
| wraplength=440, | |||
| justify=tk.CENTER, | |||
| ).pack(pady=(12, 6)) | |||
| detail = ( | |||
| f"工單:{session.job_code}\n" | |||
| f"品號:{session.item_code}\n" | |||
| f"品名:{session.item_name}\n" | |||
| f"批次/批號:{session.label_text}" | |||
| ) | |||
| tk.Label( | |||
| win, | |||
| text=detail, | |||
| font=get_font(FONT_SIZE), | |||
| bg=BG_TOP, | |||
| wraplength=400, | |||
| ).pack(pady=(16, 8)) | |||
| fg="#111111", | |||
| wraplength=440, | |||
| justify=tk.LEFT, | |||
| anchor=tk.W, | |||
| ).pack(padx=16, pady=(0, 8), fill=tk.X) | |||
| def clear_topmost() -> None: | |||
| if os.name == "nt": | |||
| @@ -2043,6 +2261,8 @@ def main() -> None: | |||
| label_busy_ref: list = [False] | |||
| # DataFlex continuous: stop Toplevel ref so we can lift it after other dialogs | |||
| dataflex_stop_win_ref: list = [None] | |||
| # In-memory job snapshot for C 連續印 (not tied to grid row position after start) | |||
| active_dataflex_session_ref: list[Optional[DataflexPrintSession]] = [None] | |||
| def lift_dataflex_stop_if_running() -> None: | |||
| """After closing another dialog (e.g. 標籤印數), bring the stop panel forward again.""" | |||
| @@ -2475,6 +2695,20 @@ def main() -> None: | |||
| name_lbl.pack(anchor=tk.NW) | |||
| def _on_click(e, j=jo, b=batch, r=row): | |||
| if ( | |||
| printer_var.get() == "打袋機 DataFlex" | |||
| and dataflex_busy_ref[0] | |||
| and active_dataflex_session_ref[0] is not None | |||
| ): | |||
| s = active_dataflex_session_ref[0] | |||
| messagebox.showwarning( | |||
| "打袋機", | |||
| f"連續列印進行中,請先按「停止列印」。\n\n" | |||
| f"工單:{s.job_code}\n" | |||
| f"品號:{s.item_code}\n" | |||
| f"品名:{s.item_name}", | |||
| ) | |||
| return | |||
| if selected_row_holder[0] is not None: | |||
| set_row_highlight(selected_row_holder[0], False) | |||
| set_row_highlight(r, True) | |||
| @@ -2513,158 +2747,43 @@ def main() -> None: | |||
| ) | |||
| label_text = (lot_no or b).strip() | |||
| if continuous: | |||
| if dataflex_busy_ref[0]: | |||
| messagebox.showwarning( | |||
| "打袋機", | |||
| "請等待目前列印完成或先停止連續列印。", | |||
| ) | |||
| return | |||
| session = build_dataflex_print_session( | |||
| j, | |||
| b, | |||
| zpl, | |||
| label_text, | |||
| ip, | |||
| port, | |||
| ) | |||
| active_dataflex_session_ref[0] = session | |||
| stop_ev = threading.Event() | |||
| stop_win = open_dataflex_stop_window( | |||
| root, stop_ev, dataflex_stop_win_ref | |||
| root, | |||
| stop_ev, | |||
| dataflex_stop_win_ref, | |||
| session, | |||
| ) | |||
| run_dataflex_continuous_thread( | |||
| root=root, | |||
| session=session, | |||
| stop_event=stop_ev, | |||
| stop_win=stop_win, | |||
| dataflex_lock=dataflex_lock, | |||
| dataflex_busy_ref=dataflex_busy_ref, | |||
| dataflex_stop_win_ref=dataflex_stop_win_ref, | |||
| active_session_ref=active_dataflex_session_ref, | |||
| base_url=base_url_ref[0], | |||
| set_status_message=set_status_message, | |||
| on_recorded=lambda: load_job_orders( | |||
| from_user_date_change=False | |||
| ), | |||
| ) | |||
| def dflex_worker() -> None: | |||
| with dataflex_lock: | |||
| if dataflex_busy_ref[0]: | |||
| root.after( | |||
| 0, | |||
| lambda: messagebox.showwarning( | |||
| "打袋機", | |||
| "請等待目前列印完成或先停止連續列印。", | |||
| ), | |||
| ) | |||
| return | |||
| dataflex_busy_ref[0] = True | |||
| printed = 0 | |||
| error_shown = False | |||
| try: | |||
| # One TCP job per bag (not one endless stream). Persistent socket | |||
| # caused E1005 over-qty on some DataFlex units after a few labels. | |||
| send_dataflex_start_job_reset(ip, port, force=True) | |||
| while not stop_ev.is_set(): | |||
| send_dataflex_label_with_recovery(ip, port, zpl) | |||
| printed += 1 | |||
| if DATAFLEX_UI_PROGRESS_EVERY > 0 and ( | |||
| printed == 1 | |||
| or printed % DATAFLEX_UI_PROGRESS_EVERY == 0 | |||
| ): | |||
| p = printed | |||
| root.after( | |||
| 0, | |||
| lambda p=p: set_status_message( | |||
| f"連續打袋列印中… 已印 {p} 張", | |||
| is_error=False, | |||
| ), | |||
| ) | |||
| if ( | |||
| DATAFLEX_VERIFY_EVERY_LABELS > 0 | |||
| and printed % DATAFLEX_VERIFY_EVERY_LABELS == 0 | |||
| ): | |||
| recover_dataflex_if_host_fault(ip, port) | |||
| if ( | |||
| DATAFLEX_COOLDOWN_EVERY_LABELS > 0 | |||
| and printed % DATAFLEX_COOLDOWN_EVERY_LABELS == 0 | |||
| ): | |||
| _sleep_interruptible( | |||
| stop_ev, | |||
| max(0.0, DATAFLEX_COOLDOWN_SEC), | |||
| ) | |||
| if ( | |||
| DATAFLEX_THERMAL_REST_EVERY_LABELS > 0 | |||
| and printed % DATAFLEX_THERMAL_REST_EVERY_LABELS == 0 | |||
| ): | |||
| _sleep_interruptible( | |||
| stop_ev, | |||
| max(0.0, DATAFLEX_THERMAL_REST_SEC), | |||
| ) | |||
| _sleep_interruptible( | |||
| stop_ev, | |||
| DATAFLEX_INTER_LABEL_DELAY_SEC, | |||
| ) | |||
| except ConnectionRefusedError: | |||
| error_shown = True | |||
| root.after( | |||
| 0, | |||
| lambda: set_status_message( | |||
| f"無法連線至 {ip}:{port},請確認印表機已開機且 IP 正確。", | |||
| is_error=True, | |||
| ), | |||
| ) | |||
| except socket.timeout: | |||
| error_shown = True | |||
| root.after( | |||
| 0, | |||
| lambda: set_status_message( | |||
| f"連線逾時 ({ip}:{port}),請檢查網路與連接埠。", | |||
| is_error=True, | |||
| ), | |||
| ) | |||
| except OSError as err: | |||
| error_shown = True | |||
| root.after( | |||
| 0, | |||
| lambda e=err: set_status_message( | |||
| f"列印失敗:{e}", | |||
| is_error=True, | |||
| ), | |||
| ) | |||
| except RuntimeError as err: | |||
| error_shown = True | |||
| root.after( | |||
| 0, | |||
| lambda e=err: set_status_message( | |||
| f"打袋機錯誤:{e}", | |||
| is_error=True, | |||
| ), | |||
| ) | |||
| except Exception as err: | |||
| error_shown = True | |||
| root.after( | |||
| 0, | |||
| lambda e=err: set_status_message( | |||
| f"打袋機例外:{e}", | |||
| is_error=True, | |||
| ), | |||
| ) | |||
| finally: | |||
| with dataflex_lock: | |||
| dataflex_busy_ref[0] = False | |||
| def _done() -> None: | |||
| dataflex_stop_win_ref[0] = None | |||
| try: | |||
| if os.name == "nt": | |||
| stop_win.attributes("-topmost", False) | |||
| except tk.TclError: | |||
| pass | |||
| try: | |||
| stop_win.destroy() | |||
| except tk.TclError: | |||
| pass | |||
| if printed > 0: | |||
| set_status_message( | |||
| f"連續列印結束:批次 {label_text},已印 {printed} 張", | |||
| 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), | |||
| printed, | |||
| "DATAFLEX", | |||
| ) | |||
| load_job_orders(from_user_date_change=False) | |||
| except requests.RequestException as ex: | |||
| messagebox.showwarning( | |||
| "打袋機", | |||
| f"列印可能已完成,但伺服器記錄失敗(可再試):{ex}", | |||
| ) | |||
| elif not error_shown: | |||
| set_status_message( | |||
| "連續列印未印出或已取消", | |||
| is_error=True, | |||
| ) | |||
| root.after(0, _done) | |||
| threading.Thread(target=dflex_worker, daemon=True).start() | |||
| else: | |||
| run_dataflex_fixed_qty_thread( | |||
| root=root, | |||
| @@ -2862,5 +2981,41 @@ def main() -> None: | |||
| root.mainloop() | |||
| def _startup_error_log_path() -> str: | |||
| if getattr(sys, "frozen", False): | |||
| base = os.path.dirname(sys.executable) | |||
| else: | |||
| base = os.path.dirname(os.path.abspath(__file__)) | |||
| return os.path.join(base, "bag3_startup_error.log") | |||
| if __name__ == "__main__": | |||
| main() | |||
| try: | |||
| main() | |||
| except SystemExit: | |||
| raise | |||
| except Exception: | |||
| import traceback | |||
| log_path = _startup_error_log_path() | |||
| try: | |||
| with open(log_path, "w", encoding="utf-8") as f: | |||
| traceback.print_exc(file=f) | |||
| except OSError: | |||
| log_path = "(could not write log file)" | |||
| msg = f"Bag3 啟動失敗,詳情已寫入:\n{log_path}" | |||
| print(msg, file=sys.stderr) | |||
| traceback.print_exc() | |||
| try: | |||
| _err_root = tk.Tk() | |||
| _err_root.withdraw() | |||
| messagebox.showerror("Bag3", msg) | |||
| _err_root.destroy() | |||
| except Exception: | |||
| pass | |||
| if getattr(sys, "frozen", False): | |||
| try: | |||
| input("按 Enter 關閉…") | |||
| except (EOFError, KeyboardInterrupt): | |||
| pass | |||
| sys.exit(1) | |||