| @@ -26,6 +26,7 @@ import tempfile | |||||
| import threading | import threading | ||||
| import time | import time | ||||
| import tkinter as tk | import tkinter as tk | ||||
| from dataclasses import dataclass | |||||
| from datetime import date, datetime, timedelta | from datetime import date, datetime, timedelta | ||||
| from tkinter import messagebox, ttk | from tkinter import messagebox, ttk | ||||
| from typing import Callable, Optional, Tuple | from typing import Callable, Optional, Tuple | ||||
| @@ -1887,6 +1888,204 @@ def ask_bag_count(parent: tk.Tk) -> Optional[Tuple[int, bool]]: | |||||
| return result[0] | 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: | def _sleep_interruptible(stop_event: threading.Event, total_sec: float) -> None: | ||||
| """Sleep up to total_sec but return early if stop_event is set.""" | """Sleep up to total_sec but return early if stop_event is set.""" | ||||
| end = time.perf_counter() + total_sec | end = time.perf_counter() + total_sec | ||||
| @@ -1903,16 +2102,18 @@ def open_dataflex_stop_window( | |||||
| parent: tk.Tk, | parent: tk.Tk, | ||||
| stop_event: threading.Event, | stop_event: threading.Event, | ||||
| stop_win_ref: list, | stop_win_ref: list, | ||||
| session: DataflexPrintSession, | |||||
| ) -> tk.Toplevel: | ) -> tk.Toplevel: | ||||
| """ | """ | ||||
| Small window with 停止列印 for DataFlex continuous mode (non-modal so stop stays usable). | 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, | 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. | 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 = tk.Toplevel(parent) | ||||
| win.title("打袋機連續列印") | win.title("打袋機連續列印") | ||||
| win.geometry("420x170") | |||||
| win.geometry("480x240") | |||||
| # On Windows, transient(root) can hide this Toplevel when the menubutton / printer row | # On Windows, transient(root) can hide this Toplevel when the menubutton / printer row | ||||
| # updates (e.g. switching to 激光機); keep transient only on non-Windows. | # updates (e.g. switching to 激光機); keep transient only on non-Windows. | ||||
| if os.name != "nt": | if os.name != "nt": | ||||
| @@ -1927,11 +2128,28 @@ def open_dataflex_stop_window( | |||||
| tk.Label( | tk.Label( | ||||
| win, | 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), | font=get_font(FONT_SIZE), | ||||
| bg=BG_TOP, | 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: | def clear_topmost() -> None: | ||||
| if os.name == "nt": | if os.name == "nt": | ||||
| @@ -2043,6 +2261,8 @@ def main() -> None: | |||||
| label_busy_ref: list = [False] | label_busy_ref: list = [False] | ||||
| # DataFlex continuous: stop Toplevel ref so we can lift it after other dialogs | # DataFlex continuous: stop Toplevel ref so we can lift it after other dialogs | ||||
| dataflex_stop_win_ref: list = [None] | 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: | def lift_dataflex_stop_if_running() -> None: | ||||
| """After closing another dialog (e.g. 標籤印數), bring the stop panel forward again.""" | """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) | name_lbl.pack(anchor=tk.NW) | ||||
| def _on_click(e, j=jo, b=batch, r=row): | 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: | if selected_row_holder[0] is not None: | ||||
| set_row_highlight(selected_row_holder[0], False) | set_row_highlight(selected_row_holder[0], False) | ||||
| set_row_highlight(r, True) | set_row_highlight(r, True) | ||||
| @@ -2513,158 +2747,43 @@ def main() -> None: | |||||
| ) | ) | ||||
| label_text = (lot_no or b).strip() | label_text = (lot_no or b).strip() | ||||
| if continuous: | 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_ev = threading.Event() | ||||
| stop_win = open_dataflex_stop_window( | 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: | else: | ||||
| run_dataflex_fixed_qty_thread( | run_dataflex_fixed_qty_thread( | ||||
| root=root, | root=root, | ||||
| @@ -2862,5 +2981,41 @@ def main() -> None: | |||||
| root.mainloop() | 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__": | 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) | |||||