| @@ -2,9 +2,15 @@ | |||
| """ | |||
| Bag3 v3.2 – FPSMS job orders by plan date (this file is the maintained version). | |||
| Uses the public API GET /py/job-orders (no login required). | |||
| Uses the public API GET /py/job-orders and POST /py/job-order-print-submit (no login required). | |||
| UI tuned for aged users: larger font, Traditional Chinese labels, prev/next date. | |||
| Database print counts (py_job_order_print_submit): | |||
| Each finished print run calls submit_job_order_print_submit() with jobOrderId, qty, | |||
| and printChannel (DATAFLEX | LABEL | LASER). The server appends one row per call; | |||
| GET /py/job-orders returns cumulative bagPrintedQty / labelPrintedQty / laserPrintedQty | |||
| per job order. Re-printing the same job later adds another row (SUM increases). | |||
| Bag2 is kept as a separate legacy v2.x line; do not assume Bag2 matches Bag3. | |||
| Run: python Bag3.py | |||
| @@ -193,22 +199,28 @@ 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 | |||
| # POST /py/job-order-print-submit: retries when server is briefly unavailable | |||
| PRINT_SUBMIT_MAX_ATTEMPTS = 3 | |||
| PRINT_SUBMIT_RETRY_DELAY_SEC = 1.0 | |||
| 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) | |||
| # Before each print job: light reset only (~JA + ~RO). Short delay so first bag starts quickly. | |||
| DATAFLEX_PREPRINT_BYTES = b"~JA\r\n~RO1\r\n~RO2\r\n" | |||
| DATAFLEX_POST_PREPRINT_DELAY_SEC = 0.35 | |||
| # Full recovery (~JR soft reset) — used by「打袋重設」only; longer delay for firmware | |||
| DATAFLEX_POST_FULL_RECOVERY_DELAY_SEC = 1.2 | |||
| # Zebra ~RO only (used when FPSMS_DATAFLEX_NO_JR is set for full recovery) | |||
| 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.""" | |||
| def _dataflex_full_recovery_payload() -> bytes: | |||
| """~JA+~RO+~JR for manual「打袋重設」; set env FPSMS_DATAFLEX_NO_JR=1 to skip ~JR.""" | |||
| 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 | |||
| @@ -233,7 +245,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). | |||
| Host recovery (~JA/~RO/~JR) is sent separately via [send_dataflex_job_counter_reset] before labels. | |||
| Light preprint (~JA/~RO) is sent before labels; full ~JR recovery is only for「打袋重設」. | |||
| """ | |||
| desc = _zpl_escape((item_name or "—").strip()) | |||
| code = _zpl_escape((item_code or "—").strip()) | |||
| @@ -264,12 +276,33 @@ def generate_zpl_dataflex( | |||
| ^XZ""" | |||
| def send_dataflex_job_counter_reset(ip: str, port: int) -> None: | |||
| def send_dataflex_preprint_reset(ip: str, port: int) -> None: | |||
| """ | |||
| Fast prep before printing: ~JA + ~RO (no ~JR). Clears buffer and zeros batch counters so the first | |||
| bag starts quickly. Use before fixed-qty batch and continuous mode. | |||
| """ | |||
| 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). | |||
| 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_PREPRINT_BYTES) | |||
| time.sleep(DATAFLEX_POST_PREPRINT_DELAY_SEC) | |||
| try: | |||
| sock.shutdown(socket.SHUT_WR) | |||
| except OSError: | |||
| pass | |||
| finally: | |||
| sock.close() | |||
| Brief pause after send so ~JR can finish before label data. Set FPSMS_DATAFLEX_NO_JR=1 to omit ~JR. | |||
| def send_dataflex_job_counter_reset(ip: str, port: int) -> None: | |||
| """ | |||
| Full host recovery for「打袋重設」: ~JA, ~RO, and ~JR (soft reset) to clear latched E1005. | |||
| Slower than [send_dataflex_preprint_reset]; do not use on every row click. | |||
| """ | |||
| sock = socket.socket(socket.AF_INET, socket.SOCK_STREAM) | |||
| try: | |||
| @@ -279,8 +312,8 @@ def send_dataflex_job_counter_reset(ip: str, port: int) -> None: | |||
| sock.settimeout(DATAFLEX_SEND_TIMEOUT) | |||
| try: | |||
| sock.connect((ip, port)) | |||
| sock.sendall(_dataflex_recovery_payload()) | |||
| time.sleep(DATAFLEX_POST_RECOVERY_DELAY_SEC) | |||
| sock.sendall(_dataflex_full_recovery_payload()) | |||
| time.sleep(DATAFLEX_POST_FULL_RECOVERY_DELAY_SEC) | |||
| try: | |||
| sock.shutdown(socket.SHUT_WR) | |||
| except OSError: | |||
| @@ -297,9 +330,8 @@ def send_dataflex_reset_and_labels( | |||
| 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. | |||
| One TCP connection: light preprint (~JA + ~RO), short pause, then `copies` identical ZPL labels | |||
| with delay_sec between copies (not after the last). Avoids rapid connect/disconnect per bag. | |||
| """ | |||
| if copies < 1: | |||
| return | |||
| @@ -312,8 +344,8 @@ def send_dataflex_reset_and_labels( | |||
| sock.settimeout(DATAFLEX_SEND_TIMEOUT) | |||
| try: | |||
| sock.connect((ip, port)) | |||
| sock.sendall(_dataflex_recovery_payload()) | |||
| time.sleep(DATAFLEX_POST_RECOVERY_DELAY_SEC) | |||
| sock.sendall(DATAFLEX_PREPRINT_BYTES) | |||
| time.sleep(DATAFLEX_POST_PREPRINT_DELAY_SEC) | |||
| for i in range(copies): | |||
| sock.sendall(raw_zpl) | |||
| if i < copies - 1: | |||
| @@ -958,6 +990,14 @@ def run_laser_row_send_thread( | |||
| f"已發送,但伺服器記錄失敗:{err}", | |||
| ), | |||
| ) | |||
| elif base_url: | |||
| root.after( | |||
| 0, | |||
| lambda: messagebox.showwarning( | |||
| "激光機", | |||
| "已發送,但無工單 id,無法寫入伺服器記錄。", | |||
| ), | |||
| ) | |||
| root.after( | |||
| 0, | |||
| lambda: set_status_message("已發送", is_error=False), | |||
| @@ -1032,6 +1072,14 @@ def run_dataflex_fixed_qty_thread( | |||
| f"已送出 {n} 張,但伺服器記錄失敗:{err}", | |||
| ), | |||
| ) | |||
| else: | |||
| root.after( | |||
| 0, | |||
| lambda: messagebox.showwarning( | |||
| "打袋機", | |||
| f"已送出列印 {n} 張,但無工單 id,無法寫入伺服器記錄。", | |||
| ), | |||
| ) | |||
| except ConnectionRefusedError: | |||
| root.after( | |||
| 0, | |||
| @@ -1198,14 +1246,34 @@ def submit_job_order_print_submit( | |||
| qty: int, | |||
| print_channel: str = "LABEL", | |||
| ) -> None: | |||
| """POST /py/job-order-print-submit — one row per submit for DB wastage/stock tracking.""" | |||
| """ | |||
| Record printed quantity in the FPSMS database via PyController. | |||
| POST ``/api/py/job-order-print-submit`` (path under base_url) — **public endpoint, no login** | |||
| or API key required. Each successful call appends one row to ``py_job_order_print_submit``; | |||
| totals per job order and channel are aggregated server-side. | |||
| Raises ``requests.RequestException`` if all retry attempts fail. | |||
| """ | |||
| 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() | |||
| payload = { | |||
| "jobOrderId": int(job_order_id), | |||
| "qty": int(qty), | |||
| "printChannel": print_channel, | |||
| } | |||
| last_err: Optional[Exception] = None | |||
| for attempt in range(PRINT_SUBMIT_MAX_ATTEMPTS): | |||
| try: | |||
| resp = requests.post(url, json=payload, timeout=30) | |||
| resp.raise_for_status() | |||
| return | |||
| except requests.RequestException as ex: | |||
| last_err = ex | |||
| if attempt < PRINT_SUBMIT_MAX_ATTEMPTS - 1: | |||
| time.sleep(PRINT_SUBMIT_RETRY_DELAY_SEC) | |||
| if last_err is not None: | |||
| raise last_err | |||
| raise RuntimeError("submit_job_order_print_submit: unexpected empty error") | |||
| def set_row_highlight(row_frame: tk.Frame, selected: bool) -> None: | |||
| @@ -2031,7 +2099,7 @@ def main() -> None: | |||
| 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_preprint_reset(ip, port) | |||
| while not stop_ev.is_set(): | |||
| send_zpl_to_dataflex(ip, port, zpl) | |||
| printed += 1 | |||