| @@ -197,6 +197,21 @@ PRINTER_CHECK_MS = 60 * 1000 # 1 minute when printer OK | |||||
| PRINTER_RETRY_MS = 30 * 1000 # 30 seconds when printer failed | PRINTER_RETRY_MS = 30 * 1000 # 30 seconds when printer failed | ||||
| PRINTER_SOCKET_TIMEOUT = 3 | PRINTER_SOCKET_TIMEOUT = 3 | ||||
| DATAFLEX_SEND_TIMEOUT = 10 # seconds when sending ZPL to DataFlex | DATAFLEX_SEND_TIMEOUT = 10 # seconds when sending ZPL to DataFlex | ||||
| # Gap between bag labels on DataFlex (one TCP session per batch; tune if bags are skipped) | |||||
| DATAFLEX_INTER_LABEL_DELAY_SEC = 2.5 | |||||
| # After ~JA/~RO/~JR recovery, let firmware finish before sending label ZPL (avoids stuck E1005) | |||||
| DATAFLEX_POST_RECOVERY_DELAY_SEC = 1.5 | |||||
| # Zebra ~RO only (used when FPSMS_DATAFLEX_NO_JR is set) | |||||
| DATAFLEX_RESET_BYTES = b"~RO1\r\n~RO2\r\n" | |||||
| # Full host recovery: ~JA clear buffers, ~RO counters, ~JR soft reset (clears latched errors without power cycle) | |||||
| DATAFLEX_FULL_RECOVERY_BYTES = b"~JA\r\n~RO1\r\n~RO2\r\n~JR\r\n" | |||||
| def _dataflex_recovery_payload() -> bytes: | |||||
| """~JA+~RO+~JR by default; set env FPSMS_DATAFLEX_NO_JR=1 to skip ~JR if firmware rejects it.""" | |||||
| if os.environ.get("FPSMS_DATAFLEX_NO_JR", "").strip().lower() in ("1", "true", "yes"): | |||||
| return b"~JA\r\n" + DATAFLEX_RESET_BYTES | |||||
| return DATAFLEX_FULL_RECOVERY_BYTES | |||||
| def _zpl_escape(s: str) -> str: | def _zpl_escape(s: str) -> str: | ||||
| @@ -218,7 +233,7 @@ def generate_zpl_dataflex( | |||||
| Row 1 (from zero): QR code, then item name (rotated 90°). | Row 1 (from zero): QR code, then item name (rotated 90°). | ||||
| Row 2: Batch/lot (left), item code (right). | Row 2: Batch/lot (left), item code (right). | ||||
| Label and QR use lotNo from API when present, else batch_no (Bxxxxx). | Label and QR use lotNo from API when present, else batch_no (Bxxxxx). | ||||
| Job counter reset (~RO) is sent separately via [send_dataflex_job_counter_reset] before labels. | |||||
| Host recovery (~JA/~RO/~JR) is sent separately via [send_dataflex_job_counter_reset] before labels. | |||||
| """ | """ | ||||
| desc = _zpl_escape((item_name or "—").strip()) | desc = _zpl_escape((item_name or "—").strip()) | ||||
| code = _zpl_escape((item_code or "—").strip()) | code = _zpl_escape((item_code or "—").strip()) | ||||
| @@ -230,7 +245,10 @@ def generate_zpl_dataflex( | |||||
| else: | else: | ||||
| qr_payload = label_line if label_line else batch_no.strip() | qr_payload = label_line if label_line else batch_no.strip() | ||||
| qr_value = _zpl_escape(qr_payload) | qr_value = _zpl_escape(qr_payload) | ||||
| # Explicit ^PQ1: each ^XA…^XZ is exactly one bag. Avoids E1005 "over quantity" on some Zebra/DataFlex | |||||
| # firmware when many labels are sent on one TCP session without a per-job quantity. | |||||
| return f"""^XA | return f"""^XA | ||||
| ^PQ1,0,1,N | |||||
| ^CI28 | ^CI28 | ||||
| ^PW700 | ^PW700 | ||||
| ^LL500 | ^LL500 | ||||
| @@ -248,19 +266,62 @@ def generate_zpl_dataflex( | |||||
| def send_dataflex_job_counter_reset(ip: str, port: int) -> None: | def send_dataflex_job_counter_reset(ip: str, port: int) -> None: | ||||
| """ | """ | ||||
| Reset printer job / label counters when switching to a new job (new row). | |||||
| Prepare DataFlex for a new job without power cycling: ~JA (clear host buffer / cancel pending), | |||||
| ~RO1/~RO2 (counters), ~JR (soft reset — clears latched E1005 / “over qty” on many Zebra-class units). | |||||
| Brief pause after send so ~JR can finish before label data. Set FPSMS_DATAFLEX_NO_JR=1 to omit ~JR. | |||||
| """ | |||||
| sock = socket.socket(socket.AF_INET, socket.SOCK_STREAM) | |||||
| try: | |||||
| sock.setsockopt(socket.IPPROTO_TCP, socket.TCP_NODELAY, 1) | |||||
| except OSError: | |||||
| pass | |||||
| sock.settimeout(DATAFLEX_SEND_TIMEOUT) | |||||
| try: | |||||
| sock.connect((ip, port)) | |||||
| sock.sendall(_dataflex_recovery_payload()) | |||||
| time.sleep(DATAFLEX_POST_RECOVERY_DELAY_SEC) | |||||
| try: | |||||
| sock.shutdown(socket.SHUT_WR) | |||||
| except OSError: | |||||
| pass | |||||
| finally: | |||||
| sock.close() | |||||
| Zebra: ~RO1 / ~RO2 reset counters 1 and 2. Sent as a *standalone* TCP write *before* any ^XA…^XZ | |||||
| label, because many firmwares ignore ~ commands inside a format block. | |||||
| Uses CRLF line endings (common Zebra expectation). Raises on TCP error like [send_zpl_to_dataflex]. | |||||
| def send_dataflex_reset_and_labels( | |||||
| ip: str, | |||||
| port: int, | |||||
| zpl: str, | |||||
| copies: int, | |||||
| delay_sec: float, | |||||
| ) -> None: | |||||
| """ | |||||
| One TCP connection: send ~RO reset, then `copies` identical ZPL labels with delay_sec between | |||||
| copies (not after the last). Avoids rapid connect/disconnect per bag, which can cause skipped | |||||
| prints on some DataFlex/Zebra TCP hosts. | |||||
| """ | """ | ||||
| raw = "~RO1\r\n~RO2\r\n".encode("ascii") | |||||
| if copies < 1: | |||||
| return | |||||
| raw_zpl = zpl.encode("utf-8") | |||||
| sock = socket.socket(socket.AF_INET, socket.SOCK_STREAM) | sock = socket.socket(socket.AF_INET, socket.SOCK_STREAM) | ||||
| try: | |||||
| sock.setsockopt(socket.IPPROTO_TCP, socket.TCP_NODELAY, 1) | |||||
| except OSError: | |||||
| pass | |||||
| sock.settimeout(DATAFLEX_SEND_TIMEOUT) | sock.settimeout(DATAFLEX_SEND_TIMEOUT) | ||||
| try: | try: | ||||
| sock.connect((ip, port)) | sock.connect((ip, port)) | ||||
| sock.sendall(raw) | |||||
| sock.sendall(_dataflex_recovery_payload()) | |||||
| time.sleep(DATAFLEX_POST_RECOVERY_DELAY_SEC) | |||||
| for i in range(copies): | |||||
| sock.sendall(raw_zpl) | |||||
| if i < copies - 1: | |||||
| time.sleep(delay_sec) | |||||
| try: | |||||
| sock.shutdown(socket.SHUT_WR) | |||||
| except OSError: | |||||
| pass | |||||
| finally: | finally: | ||||
| sock.close() | sock.close() | ||||
| @@ -502,6 +563,35 @@ def _image_to_zpl_gfa(pil_image: "Image.Image") -> str: | |||||
| ^XZ""" | ^XZ""" | ||||
| def zpl_apply_print_quantity(zpl: str, copies: int) -> str: | |||||
| """ | |||||
| Ask the printer to output `copies` identical labels from one ZPL format by inserting ^PQ after ^XA. | |||||
| Results in a **single** spool job / one write — no N separate Windows jobs, no chained ^XA blocks | |||||
| (which broke some TSC drivers with white-on-white ^GFA output). | |||||
| """ | |||||
| if copies <= 1: | |||||
| return zpl | |||||
| first_fmt = zpl.split("^XZ", 1)[0] if "^XZ" in zpl else zpl | |||||
| if "^PQ" in first_fmt.upper(): | |||||
| return zpl | |||||
| lines = zpl.splitlines() | |||||
| new_lines: list[str] = [] | |||||
| inserted = False | |||||
| for line in lines: | |||||
| new_lines.append(line) | |||||
| if not inserted and line.strip() == "^XA": | |||||
| # ZPL II: q labels, 0 pause between, 1 replicate (non-serial), N = default options. | |||||
| # Same graphic (^GFA) is repeated q times — e.g. 需求數量 150 → 150 identical labels, one spool job. | |||||
| new_lines.append(f"^PQ{copies},0,1,N") | |||||
| inserted = True | |||||
| if not inserted: | |||||
| raise RuntimeError( | |||||
| "標籤 ZPL 無法插入 ^PQ(格式非預期)。請聯絡程式維護。" | |||||
| ) | |||||
| ending = "\n" if zpl.endswith("\n") else "" | |||||
| return "\n".join(new_lines) + ending | |||||
| def send_image_to_label_printer(printer_name: str, pil_image: "Image.Image") -> None: | 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). | Send a PIL Image to 標簽機 via Windows GDI (so Chinese and graphics print correctly). | ||||
| @@ -571,10 +661,18 @@ def send_image_to_label_printer(printer_name: str, pil_image: "Image.Image") -> | |||||
| def send_zpl_to_dataflex(ip: str, port: int, zpl: str) -> None: | def send_zpl_to_dataflex(ip: str, port: int, zpl: str) -> None: | ||||
| """Send ZPL label (^XA…^XZ) to DataFlex printer via TCP. Raises on connection/send error.""" | """Send ZPL label (^XA…^XZ) to DataFlex printer via TCP. Raises on connection/send error.""" | ||||
| sock = socket.socket(socket.AF_INET, socket.SOCK_STREAM) | sock = socket.socket(socket.AF_INET, socket.SOCK_STREAM) | ||||
| try: | |||||
| sock.setsockopt(socket.IPPROTO_TCP, socket.TCP_NODELAY, 1) | |||||
| except OSError: | |||||
| pass | |||||
| sock.settimeout(DATAFLEX_SEND_TIMEOUT) | sock.settimeout(DATAFLEX_SEND_TIMEOUT) | ||||
| try: | try: | ||||
| sock.connect((ip, port)) | sock.connect((ip, port)) | ||||
| sock.sendall(zpl.encode("utf-8")) | sock.sendall(zpl.encode("utf-8")) | ||||
| try: | |||||
| sock.shutdown(socket.SHUT_WR) | |||||
| except OSError: | |||||
| pass | |||||
| finally: | finally: | ||||
| sock.close() | sock.close() | ||||
| @@ -619,6 +717,18 @@ def send_zpl_to_label_printer(target: str, zpl: str) -> None: | |||||
| ser.close() | ser.close() | ||||
| def send_zpl_to_label_printer_batch(target: str, zpl: str, copies: int) -> None: | |||||
| """ | |||||
| Print multiple identical ZPL labels in **exactly one** Windows spool job (or one COM write). | |||||
| Uses ZPL ^PQ so the printer firmware repeats the format N times — never one job per label. | |||||
| """ | |||||
| if copies < 1: | |||||
| return | |||||
| zpl_out = zpl_apply_print_quantity(zpl, copies) | |||||
| send_zpl_to_label_printer(target, zpl_out) | |||||
| def load_laser_last_count() -> tuple[int, Optional[str]]: | def load_laser_last_count() -> tuple[int, Optional[str]]: | ||||
| """Load last batch count and date from laser counter file. Returns (count, date_str).""" | """Load last batch count and date from laser counter file. Returns (count, date_str).""" | ||||
| if not os.path.exists(LASER_COUNTER_FILE): | if not os.path.exists(LASER_COUNTER_FILE): | ||||
| @@ -810,6 +920,7 @@ def run_laser_row_send_thread( | |||||
| After success, POST LASER qty to API when job_order_id and base_url are set. | After success, POST LASER qty to API when job_order_id and base_url are set. | ||||
| """ | """ | ||||
| if laser_busy_ref[0]: | if laser_busy_ref[0]: | ||||
| messagebox.showwarning("激光機", "請等待目前激光發送完成。") | |||||
| return | return | ||||
| laser_busy_ref[0] = True | laser_busy_ref[0] = True | ||||
| @@ -864,6 +975,163 @@ def run_laser_row_send_thread( | |||||
| threading.Thread(target=worker, daemon=True).start() | threading.Thread(target=worker, daemon=True).start() | ||||
| def run_dataflex_fixed_qty_thread( | |||||
| root: tk.Tk, | |||||
| dataflex_lock: threading.Lock, | |||||
| dataflex_busy_ref: list, | |||||
| ip: str, | |||||
| port: int, | |||||
| n: int, | |||||
| zpl: str, | |||||
| label_text: str, | |||||
| jo_id: Optional[int], | |||||
| base_url: str, | |||||
| set_status_message: Callable[[str, bool], None], | |||||
| on_recorded: Callable[[], None], | |||||
| ) -> None: | |||||
| """ | |||||
| Send n DataFlex labels with delay between copies. Runs off the Tk main thread so the UI | |||||
| stays responsive (printer dropdown, other controls) during printing. | |||||
| """ | |||||
| def worker() -> None: | |||||
| with dataflex_lock: | |||||
| if dataflex_busy_ref[0]: | |||||
| root.after( | |||||
| 0, | |||||
| lambda: messagebox.showwarning( | |||||
| "打袋機", | |||||
| "請等待目前列印完成或先停止連續列印。", | |||||
| ), | |||||
| ) | |||||
| return | |||||
| dataflex_busy_ref[0] = True | |||||
| try: | |||||
| send_dataflex_reset_and_labels( | |||||
| ip, | |||||
| port, | |||||
| zpl, | |||||
| n, | |||||
| DATAFLEX_INTER_LABEL_DELAY_SEC, | |||||
| ) | |||||
| root.after( | |||||
| 0, | |||||
| lambda: set_status_message( | |||||
| f"已送出列印:批次 {label_text} x {n} 張", | |||||
| is_error=False, | |||||
| ), | |||||
| ) | |||||
| if jo_id is not None: | |||||
| try: | |||||
| submit_job_order_print_submit(base_url, int(jo_id), n, "DATAFLEX") | |||||
| root.after(0, on_recorded) | |||||
| except requests.RequestException as ex: | |||||
| root.after( | |||||
| 0, | |||||
| lambda err=str(ex): messagebox.showwarning( | |||||
| "打袋機", | |||||
| f"已送出 {n} 張,但伺服器記錄失敗:{err}", | |||||
| ), | |||||
| ) | |||||
| 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), | |||||
| ) | |||||
| finally: | |||||
| with dataflex_lock: | |||||
| dataflex_busy_ref[0] = False | |||||
| threading.Thread(target=worker, daemon=True).start() | |||||
| def run_label_print_batch_thread( | |||||
| root: tk.Tk, | |||||
| label_lock: threading.Lock, | |||||
| label_busy_ref: list, | |||||
| com: str, | |||||
| zpl_img: str, | |||||
| n: int, | |||||
| jo_id: Optional[int], | |||||
| base_url: str, | |||||
| set_status_message: Callable[[str, bool], None], | |||||
| on_recorded: Callable[[], None], | |||||
| ) -> None: | |||||
| """ | |||||
| Send n label copies off the main thread so DataFlex / laser / UI stay usable in parallel. | |||||
| """ | |||||
| def worker() -> None: | |||||
| with label_lock: | |||||
| if label_busy_ref[0]: | |||||
| root.after( | |||||
| 0, | |||||
| lambda: messagebox.showwarning( | |||||
| "標籤機", | |||||
| "請等待目前標籤列印完成。", | |||||
| ), | |||||
| ) | |||||
| return | |||||
| label_busy_ref[0] = True | |||||
| try: | |||||
| send_zpl_to_label_printer_batch(com, zpl_img, n) | |||||
| root.after( | |||||
| 0, | |||||
| lambda: set_status_message(f"已送出列印:標籤 x {n} 張", is_error=False), | |||||
| ) | |||||
| if jo_id is not None: | |||||
| try: | |||||
| submit_job_order_print_submit(base_url, int(jo_id), n, "LABEL") | |||||
| root.after(0, on_recorded) | |||||
| root.after( | |||||
| 0, | |||||
| lambda: messagebox.showinfo( | |||||
| "標籤機", | |||||
| f"已送出列印:{n} 張標籤(已記錄)", | |||||
| ), | |||||
| ) | |||||
| except requests.RequestException as ex: | |||||
| root.after( | |||||
| 0, | |||||
| lambda err=str(ex): messagebox.showwarning( | |||||
| "標籤機", | |||||
| f"標籤已列印 {n} 張,但伺服器記錄失敗:{err}", | |||||
| ), | |||||
| ) | |||||
| else: | |||||
| root.after( | |||||
| 0, | |||||
| lambda: messagebox.showwarning( | |||||
| "標籤機", | |||||
| f"已送出列印:{n} 張標籤(無工單 id,無法寫入伺服器記錄)", | |||||
| ), | |||||
| ) | |||||
| except Exception as err: | |||||
| root.after( | |||||
| 0, | |||||
| lambda e=str(err): messagebox.showerror("標籤機", f"列印失敗:{e}"), | |||||
| ) | |||||
| finally: | |||||
| with label_lock: | |||||
| label_busy_ref[0] = False | |||||
| threading.Thread(target=worker, daemon=True).start() | |||||
| def _printed_qty_int(raw) -> int: | def _printed_qty_int(raw) -> int: | ||||
| """Parse API printed qty field (may be float JSON) to int.""" | """Parse API printed qty field (may be float JSON) to int.""" | ||||
| try: | try: | ||||
| @@ -966,7 +1234,8 @@ def on_job_order_click(jo: dict, batch: str) -> None: | |||||
| def ask_label_count(parent: tk.Tk) -> Optional[int]: | def ask_label_count(parent: tk.Tk) -> Optional[int]: | ||||
| """ | """ | ||||
| When printer is 標簽機, ask how many labels to print: | When printer is 標簽機, ask how many labels to print: | ||||
| optional direct qty in text field, +50/+10/+5/+1, 重置, then 確認送出. | |||||
| optional direct qty in text field (e.g. 150), +50/+10/+5/+1, 重置, then 確認送出. | |||||
| That count becomes ZPL ^PQ in one job — 150 → 150 identical labels. | |||||
| Returns count (>= 1), or None if cancelled. | Returns count (>= 1), or None if cancelled. | ||||
| """ | """ | ||||
| result: list[Optional[int]] = [None] | result: list[Optional[int]] = [None] | ||||
| @@ -1148,28 +1417,69 @@ def _sleep_interruptible(stop_event: threading.Event, total_sec: float) -> None: | |||||
| time.sleep(min(0.05, remaining)) | time.sleep(min(0.05, remaining)) | ||||
| def open_dataflex_stop_window(parent: tk.Tk, stop_event: threading.Event) -> tk.Toplevel: | |||||
| """Small window with 停止列印 for DataFlex continuous mode (non-modal so stop stays usable).""" | |||||
| def open_dataflex_stop_window( | |||||
| parent: tk.Tk, | |||||
| stop_event: threading.Event, | |||||
| stop_win_ref: list, | |||||
| ) -> 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. | |||||
| """ | |||||
| win = tk.Toplevel(parent) | win = tk.Toplevel(parent) | ||||
| win.title("打袋機連續列印") | win.title("打袋機連續列印") | ||||
| win.geometry("420x170") | win.geometry("420x170") | ||||
| win.transient(parent) | |||||
| # 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": | |||||
| win.transient(parent) | |||||
| win.configure(bg=BG_TOP) | win.configure(bg=BG_TOP) | ||||
| stop_win_ref[0] = win | |||||
| if os.name == "nt": | |||||
| try: | |||||
| win.attributes("-topmost", True) | |||||
| except tk.TclError: | |||||
| pass | |||||
| tk.Label( | tk.Label( | ||||
| win, | win, | ||||
| text="連續列印進行中,可隨時按下方停止。", | |||||
| text="連續列印進行中(與上方列印機選項無關),可隨時按下方停止。", | |||||
| font=get_font(FONT_SIZE), | font=get_font(FONT_SIZE), | ||||
| bg=BG_TOP, | bg=BG_TOP, | ||||
| wraplength=400, | wraplength=400, | ||||
| ).pack(pady=(16, 8)) | ).pack(pady=(16, 8)) | ||||
| def clear_topmost() -> None: | |||||
| if os.name == "nt": | |||||
| try: | |||||
| win.attributes("-topmost", False) | |||||
| except tk.TclError: | |||||
| pass | |||||
| def stop() -> None: | def stop() -> None: | ||||
| stop_event.set() | stop_event.set() | ||||
| stop_win_ref[0] = None | |||||
| clear_topmost() | |||||
| try: | try: | ||||
| win.destroy() | win.destroy() | ||||
| except tk.TclError: | except tk.TclError: | ||||
| pass | pass | ||||
| def periodic_lift() -> None: | |||||
| if stop_win_ref[0] is not win: | |||||
| return | |||||
| try: | |||||
| if not win.winfo_exists(): | |||||
| return | |||||
| win.lift() | |||||
| if os.name == "nt": | |||||
| win.attributes("-topmost", True) | |||||
| except tk.TclError: | |||||
| return | |||||
| parent.after(4000, periodic_lift) | |||||
| tk.Button( | tk.Button( | ||||
| win, | win, | ||||
| text="停止列印", | text="停止列印", | ||||
| @@ -1181,6 +1491,7 @@ def open_dataflex_stop_window(parent: tk.Tk, stop_event: threading.Event) -> tk. | |||||
| pady=10, | pady=10, | ||||
| ).pack(pady=12) | ).pack(pady=12) | ||||
| win.protocol("WM_DELETE_WINDOW", stop) | win.protocol("WM_DELETE_WINDOW", stop) | ||||
| parent.after(500, periodic_lift) | |||||
| return win | return win | ||||
| @@ -1240,6 +1551,27 @@ def main() -> None: | |||||
| # Laser: keep connection open for repeated sends; close when switching away | # Laser: keep connection open for repeated sends; close when switching away | ||||
| laser_conn_ref: list = [None] | laser_conn_ref: list = [None] | ||||
| laser_send_busy_ref: list = [False] | laser_send_busy_ref: list = [False] | ||||
| # DataFlex: shared lock so fixed-qty and continuous jobs do not overlap (independent of laser/label) | |||||
| dataflex_lock = threading.Lock() | |||||
| dataflex_busy_ref: list = [False] | |||||
| # 標籤機: own lock so label jobs do not overlap; does not block DataFlex or laser | |||||
| label_lock = threading.Lock() | |||||
| label_busy_ref: list = [False] | |||||
| # DataFlex continuous: stop Toplevel ref so we can lift it after other dialogs | |||||
| dataflex_stop_win_ref: list = [None] | |||||
| def lift_dataflex_stop_if_running() -> None: | |||||
| """After closing another dialog (e.g. 標籤印數), bring the stop panel forward again.""" | |||||
| w = dataflex_stop_win_ref[0] | |||||
| if w is None: | |||||
| return | |||||
| try: | |||||
| if w.winfo_exists(): | |||||
| w.lift() | |||||
| if os.name == "nt": | |||||
| w.attributes("-topmost", True) | |||||
| except tk.TclError: | |||||
| pass | |||||
| # Top: left [前一天] [date] [後一天] | right [printer dropdown] | # Top: left [前一天] [date] [後一天] | right [printer dropdown] | ||||
| top = tk.Frame(root, padx=12, pady=12, bg=BG_TOP) | top = tk.Frame(root, padx=12, pady=12, bg=BG_TOP) | ||||
| @@ -1291,6 +1623,44 @@ def main() -> None: | |||||
| ttk.Button(right_frame, text="設定", command=lambda: open_setup_window(root, settings, base_url_ref)).pack( | ttk.Button(right_frame, text="設定", command=lambda: open_setup_window(root, settings, base_url_ref)).pack( | ||||
| side=tk.LEFT, padx=(0, 12) | side=tk.LEFT, padx=(0, 12) | ||||
| ) | ) | ||||
| def on_dataflex_host_reset() -> None: | |||||
| """Send ~JA/~RO/~JR to clear E1005 latch without turning the printer off.""" | |||||
| ip = (settings.get("dabag_ip") or "").strip() | |||||
| port_str = (settings.get("dabag_port") or "3008").strip() | |||||
| if not ip: | |||||
| messagebox.showwarning("打袋機", "請先在「設定」填寫打袋機 IP。") | |||||
| return | |||||
| try: | |||||
| port = int(port_str) | |||||
| except ValueError: | |||||
| port = 3008 | |||||
| def worker() -> None: | |||||
| try: | |||||
| send_dataflex_job_counter_reset(ip, port) | |||||
| root.after( | |||||
| 0, | |||||
| lambda: messagebox.showinfo( | |||||
| "打袋機", | |||||
| "已送出主機重設(緩衝清除/計數/軟重設)。\n" | |||||
| "若畫面仍顯示 E1005,請再按一次或關機重開。", | |||||
| ), | |||||
| ) | |||||
| except OSError as ex: | |||||
| root.after( | |||||
| 0, | |||||
| lambda e=str(ex): messagebox.showerror( | |||||
| "打袋機", | |||||
| f"連線失敗,無法重設:{e}", | |||||
| ), | |||||
| ) | |||||
| threading.Thread(target=worker, daemon=True).start() | |||||
| ttk.Button(right_frame, text="打袋重設", command=on_dataflex_host_reset).pack( | |||||
| side=tk.LEFT, padx=(0, 8) | |||||
| ) | |||||
| # 列印機 label: green when printer connected, red when not (checked periodically) | # 列印機 label: green when printer connected, red when not (checked periodically) | ||||
| printer_status_lbl = tk.Label( | printer_status_lbl = tk.Label( | ||||
| right_frame, | right_frame, | ||||
| @@ -1350,6 +1720,8 @@ def main() -> None: | |||||
| except Exception: | except Exception: | ||||
| pass | pass | ||||
| laser_conn_ref[0] = None | laser_conn_ref[0] = None | ||||
| # DataFlex continuous stop panel can drop behind after OptionMenu closes; re-lift for any choice. | |||||
| root.after(100, lift_dataflex_stop_if_running) | |||||
| printer_var.trace_add("write", on_printer_selection_changed) | printer_var.trace_add("write", on_printer_selection_changed) | ||||
| @@ -1638,17 +2010,35 @@ def main() -> None: | |||||
| label_text = (lot_no or b).strip() | label_text = (lot_no or b).strip() | ||||
| if continuous: | if continuous: | ||||
| stop_ev = threading.Event() | stop_ev = threading.Event() | ||||
| stop_win = open_dataflex_stop_window(root, stop_ev) | |||||
| stop_win = open_dataflex_stop_window( | |||||
| root, stop_ev, dataflex_stop_win_ref | |||||
| ) | |||||
| def dflex_worker() -> None: | 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 | printed = 0 | ||||
| error_shown = False | error_shown = False | ||||
| try: | 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_job_counter_reset(ip, port) | send_dataflex_job_counter_reset(ip, port) | ||||
| while not stop_ev.is_set(): | while not stop_ev.is_set(): | ||||
| send_zpl_to_dataflex(ip, port, zpl) | send_zpl_to_dataflex(ip, port, zpl) | ||||
| printed += 1 | printed += 1 | ||||
| _sleep_interruptible(stop_ev, 2.0) | |||||
| _sleep_interruptible( | |||||
| stop_ev, | |||||
| DATAFLEX_INTER_LABEL_DELAY_SEC, | |||||
| ) | |||||
| except ConnectionRefusedError: | except ConnectionRefusedError: | ||||
| error_shown = True | error_shown = True | ||||
| root.after( | root.after( | ||||
| @@ -1677,8 +2067,16 @@ def main() -> None: | |||||
| ), | ), | ||||
| ) | ) | ||||
| finally: | finally: | ||||
| with dataflex_lock: | |||||
| dataflex_busy_ref[0] = False | |||||
| def _done() -> 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: | try: | ||||
| stop_win.destroy() | stop_win.destroy() | ||||
| except tk.TclError: | except tk.TclError: | ||||
| @@ -1713,37 +2111,29 @@ def main() -> None: | |||||
| threading.Thread(target=dflex_worker, daemon=True).start() | threading.Thread(target=dflex_worker, daemon=True).start() | ||||
| else: | else: | ||||
| try: | |||||
| send_dataflex_job_counter_reset(ip, port) | |||||
| 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) | |||||
| run_dataflex_fixed_qty_thread( | |||||
| root=root, | |||||
| dataflex_lock=dataflex_lock, | |||||
| dataflex_busy_ref=dataflex_busy_ref, | |||||
| ip=ip, | |||||
| port=port, | |||||
| n=n, | |||||
| zpl=zpl, | |||||
| label_text=label_text, | |||||
| jo_id=j.get("id"), | |||||
| base_url=base_url_ref[0], | |||||
| set_status_message=set_status_message, | |||||
| on_recorded=lambda: load_job_orders( | |||||
| from_user_date_change=False | |||||
| ), | |||||
| ) | |||||
| elif printer_var.get() == "標簽機": | elif printer_var.get() == "標簽機": | ||||
| com = (settings.get("label_com") or "").strip() | com = (settings.get("label_com") or "").strip() | ||||
| if not com: | if not com: | ||||
| messagebox.showerror("標簽機", "請在設定中填寫標簽機名稱 (例如:TSC TTP-246M Pro)。") | messagebox.showerror("標簽機", "請在設定中填寫標簽機名稱 (例如:TSC TTP-246M Pro)。") | ||||
| else: | else: | ||||
| count = ask_label_count(root) | count = ask_label_count(root) | ||||
| lift_dataflex_stop_if_running() | |||||
| if count is not None: | if count is not None: | ||||
| item_code = j.get("itemCode") or "—" | item_code = j.get("itemCode") or "—" | ||||
| item_name = j.get("itemName") or "—" | item_name = j.get("itemName") or "—" | ||||
| @@ -1762,31 +2152,20 @@ def main() -> None: | |||||
| lot_no=lot_no, | lot_no=lot_no, | ||||
| ) | ) | ||||
| zpl_img = _image_to_zpl_gfa(label_img) | 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,無法寫入伺服器記錄)", | |||||
| ) | |||||
| run_label_print_batch_thread( | |||||
| root=root, | |||||
| label_lock=label_lock, | |||||
| label_busy_ref=label_busy_ref, | |||||
| com=com, | |||||
| zpl_img=zpl_img, | |||||
| n=n, | |||||
| jo_id=j.get("id"), | |||||
| base_url=base_url_ref[0], | |||||
| set_status_message=set_status_message, | |||||
| on_recorded=lambda: load_job_orders( | |||||
| from_user_date_change=False | |||||
| ), | |||||
| ) | |||||
| except Exception as err: | except Exception as err: | ||||
| messagebox.showerror("標簽機", f"列印失敗:{err}") | messagebox.showerror("標簽機", f"列印失敗:{err}") | ||||
| elif printer_var.get() == "激光機": | elif printer_var.get() == "激光機": | ||||