| @@ -2,9 +2,15 @@ | |||||
| """ | """ | ||||
| Bag3 v3.2 – FPSMS job orders by plan date (this file is the maintained version). | 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. | 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. | Bag2 is kept as a separate legacy v2.x line; do not assume Bag2 matches Bag3. | ||||
| Run: python Bag3.py | Run: python Bag3.py | ||||
| @@ -193,22 +199,28 @@ BG_STATUS_OK = "#90EE90" # light green when connected | |||||
| FG_STATUS_OK = "#006400" # green text | FG_STATUS_OK = "#006400" # green text | ||||
| RETRY_MS = 30 * 1000 # 30 seconds reconnect | RETRY_MS = 30 * 1000 # 30 seconds reconnect | ||||
| REFRESH_MS = 60 * 1000 # 60 seconds refresh when connected | 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_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) | # Gap between bag labels on DataFlex (one TCP session per batch; tune if bags are skipped) | ||||
| DATAFLEX_INTER_LABEL_DELAY_SEC = 2.5 | 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" | 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) | # 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" | 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"): | if os.environ.get("FPSMS_DATAFLEX_NO_JR", "").strip().lower() in ("1", "true", "yes"): | ||||
| return b"~JA\r\n" + DATAFLEX_RESET_BYTES | return b"~JA\r\n" + DATAFLEX_RESET_BYTES | ||||
| return DATAFLEX_FULL_RECOVERY_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 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). | ||||
| 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()) | desc = _zpl_escape((item_name or "—").strip()) | ||||
| code = _zpl_escape((item_code or "—").strip()) | code = _zpl_escape((item_code or "—").strip()) | ||||
| @@ -264,12 +276,33 @@ def generate_zpl_dataflex( | |||||
| ^XZ""" | ^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) | sock = socket.socket(socket.AF_INET, socket.SOCK_STREAM) | ||||
| try: | try: | ||||
| @@ -279,8 +312,8 @@ def send_dataflex_job_counter_reset(ip: str, port: int) -> None: | |||||
| sock.settimeout(DATAFLEX_SEND_TIMEOUT) | sock.settimeout(DATAFLEX_SEND_TIMEOUT) | ||||
| try: | try: | ||||
| sock.connect((ip, port)) | 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: | try: | ||||
| sock.shutdown(socket.SHUT_WR) | sock.shutdown(socket.SHUT_WR) | ||||
| except OSError: | except OSError: | ||||
| @@ -297,9 +330,8 @@ def send_dataflex_reset_and_labels( | |||||
| delay_sec: float, | delay_sec: float, | ||||
| ) -> None: | ) -> 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: | if copies < 1: | ||||
| return | return | ||||
| @@ -312,8 +344,8 @@ def send_dataflex_reset_and_labels( | |||||
| sock.settimeout(DATAFLEX_SEND_TIMEOUT) | sock.settimeout(DATAFLEX_SEND_TIMEOUT) | ||||
| try: | try: | ||||
| sock.connect((ip, port)) | 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): | for i in range(copies): | ||||
| sock.sendall(raw_zpl) | sock.sendall(raw_zpl) | ||||
| if i < copies - 1: | if i < copies - 1: | ||||
| @@ -958,6 +990,14 @@ def run_laser_row_send_thread( | |||||
| f"已發送,但伺服器記錄失敗:{err}", | f"已發送,但伺服器記錄失敗:{err}", | ||||
| ), | ), | ||||
| ) | ) | ||||
| elif base_url: | |||||
| root.after( | |||||
| 0, | |||||
| lambda: messagebox.showwarning( | |||||
| "激光機", | |||||
| "已發送,但無工單 id,無法寫入伺服器記錄。", | |||||
| ), | |||||
| ) | |||||
| root.after( | root.after( | ||||
| 0, | 0, | ||||
| lambda: set_status_message("已發送", is_error=False), | lambda: set_status_message("已發送", is_error=False), | ||||
| @@ -1032,6 +1072,14 @@ def run_dataflex_fixed_qty_thread( | |||||
| f"已送出 {n} 張,但伺服器記錄失敗:{err}", | f"已送出 {n} 張,但伺服器記錄失敗:{err}", | ||||
| ), | ), | ||||
| ) | ) | ||||
| else: | |||||
| root.after( | |||||
| 0, | |||||
| lambda: messagebox.showwarning( | |||||
| "打袋機", | |||||
| f"已送出列印 {n} 張,但無工單 id,無法寫入伺服器記錄。", | |||||
| ), | |||||
| ) | |||||
| except ConnectionRefusedError: | except ConnectionRefusedError: | ||||
| root.after( | root.after( | ||||
| 0, | 0, | ||||
| @@ -1198,14 +1246,34 @@ def submit_job_order_print_submit( | |||||
| qty: int, | qty: int, | ||||
| print_channel: str = "LABEL", | print_channel: str = "LABEL", | ||||
| ) -> None: | ) -> 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" | 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: | def set_row_highlight(row_frame: tk.Frame, selected: bool) -> None: | ||||
| @@ -2031,7 +2099,7 @@ def main() -> None: | |||||
| try: | try: | ||||
| # One TCP job per bag (not one endless stream). Persistent socket | # One TCP job per bag (not one endless stream). Persistent socket | ||||
| # caused E1005 over-qty on some DataFlex units after a few labels. | # 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(): | while not stop_ev.is_set(): | ||||
| send_zpl_to_dataflex(ip, port, zpl) | send_zpl_to_dataflex(ip, port, zpl) | ||||
| printed += 1 | printed += 1 | ||||