Ver a proveniência

no message

master
DESKTOP-064TTA1\Fai LUK há 16 horas
ascendente
cometimento
1ee85fb402
1 ficheiros alterados com 443 adições e 64 eliminações
  1. +443
    -64
      python/Bag3.py

+ 443
- 64
python/Bag3.py Ver ficheiro

@@ -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_SOCKET_TIMEOUT = 3
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:
@@ -218,7 +233,7 @@ def generate_zpl_dataflex(
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).
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())
code = _zpl_escape((item_code or "—").strip())
@@ -230,7 +245,10 @@ def generate_zpl_dataflex(
else:
qr_payload = label_line if label_line else batch_no.strip()
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
^PQ1,0,1,N
^CI28
^PW700
^LL500
@@ -248,19 +266,62 @@ def generate_zpl_dataflex(

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)
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(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:
sock.close()

@@ -502,6 +563,35 @@ def _image_to_zpl_gfa(pil_image: "Image.Image") -> str:
^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:
"""
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:
"""Send ZPL label (^XA…^XZ) to DataFlex printer via TCP. Raises on connection/send error."""
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(zpl.encode("utf-8"))
try:
sock.shutdown(socket.SHUT_WR)
except OSError:
pass
finally:
sock.close()

@@ -619,6 +717,18 @@ def send_zpl_to_label_printer(target: str, zpl: str) -> None:
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]]:
"""Load last batch count and date from laser counter file. Returns (count, date_str)."""
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.
"""
if laser_busy_ref[0]:
messagebox.showwarning("激光機", "請等待目前激光發送完成。")
return
laser_busy_ref[0] = True

@@ -864,6 +975,163 @@ def run_laser_row_send_thread(
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:
"""Parse API printed qty field (may be float JSON) to int."""
try:
@@ -966,7 +1234,8 @@ def on_job_order_click(jo: dict, batch: str) -> None:
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 確認送出.
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.
"""
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))


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.title("打袋機連續列印")
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)
stop_win_ref[0] = win
if os.name == "nt":
try:
win.attributes("-topmost", True)
except tk.TclError:
pass

tk.Label(
win,
text="連續列印進行中,可隨時按下方停止。",
text="連續列印進行中(與上方列印機選項無關),可隨時按下方停止。",
font=get_font(FONT_SIZE),
bg=BG_TOP,
wraplength=400,
).pack(pady=(16, 8))

def clear_topmost() -> None:
if os.name == "nt":
try:
win.attributes("-topmost", False)
except tk.TclError:
pass

def stop() -> None:
stop_event.set()
stop_win_ref[0] = None
clear_topmost()
try:
win.destroy()
except tk.TclError:
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(
win,
text="停止列印",
@@ -1181,6 +1491,7 @@ def open_dataflex_stop_window(parent: tk.Tk, stop_event: threading.Event) -> tk.
pady=10,
).pack(pady=12)
win.protocol("WM_DELETE_WINDOW", stop)
parent.after(500, periodic_lift)
return win


@@ -1240,6 +1551,27 @@ def main() -> None:
# Laser: keep connection open for repeated sends; close when switching away
laser_conn_ref: list = [None]
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 = 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(
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)
printer_status_lbl = tk.Label(
right_frame,
@@ -1350,6 +1720,8 @@ def main() -> None:
except Exception:
pass
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)

@@ -1638,17 +2010,35 @@ def main() -> None:
label_text = (lot_no or b).strip()
if continuous:
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:
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_job_counter_reset(ip, port)
while not stop_ev.is_set():
send_zpl_to_dataflex(ip, port, zpl)
printed += 1
_sleep_interruptible(stop_ev, 2.0)
_sleep_interruptible(
stop_ev,
DATAFLEX_INTER_LABEL_DELAY_SEC,
)
except ConnectionRefusedError:
error_shown = True
root.after(
@@ -1677,8 +2067,16 @@ def main() -> None:
),
)
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:
@@ -1713,37 +2111,29 @@ def main() -> None:

threading.Thread(target=dflex_worker, daemon=True).start()
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() == "標簽機":
com = (settings.get("label_com") or "").strip()
if not com:
messagebox.showerror("標簽機", "請在設定中填寫標簽機名稱 (例如:TSC TTP-246M Pro)。")
else:
count = ask_label_count(root)
lift_dataflex_stop_if_running()
if count is not None:
item_code = j.get("itemCode") or "—"
item_name = j.get("itemName") or "—"
@@ -1762,31 +2152,20 @@ def main() -> None:
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,無法寫入伺服器記錄)",
)
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:
messagebox.showerror("標簽機", f"列印失敗:{err}")
elif printer_var.get() == "激光機":


Carregando…
Cancelar
Guardar