Du kan inte välja fler än 25 ämnen Ämnen måste starta med en bokstav eller siffra, kan innehålla bindestreck ('-') och vara max 35 tecken långa.
 
 
 
 
 

3136 rader
117 KiB

  1. #!/usr/bin/env python3
  2. """
  3. Bag4 v4.0 – FPSMS job orders by plan date, with DataFlex expiry under Lot.
  4. Uses the public API GET /py/job-orders and POST /py/job-order-print-submit (no login required).
  5. Same as Bag3, plus expiry (today + defaultShelfLifeDays) on DataFlex ZPL
  6. and as the 4th laser TCP param, always printed as `Expiry Date 20260826`.
  7. Bag3 remains the line without expiry on DataFlex; do not share settings files.
  8. Run: python Bag4.py
  9. """
  10. import errno
  11. import json
  12. import os
  13. import select
  14. import socket
  15. import sys
  16. import tempfile
  17. import threading
  18. import time
  19. import tkinter as tk
  20. from dataclasses import dataclass
  21. from datetime import date, datetime, timedelta
  22. from tkinter import messagebox, ttk
  23. from typing import Callable, Optional, Tuple
  24. import requests
  25. # UI "列印機" check uses short TCP probes; DataFlex may refuse connections briefly during E1005 recovery.
  26. _DATAFLEX_RECOVERY_GRACE_UNTIL: float = 0.0
  27. def touch_dataflex_recovery_grace(seconds: float = 22.0) -> None:
  28. """While host reset runs, avoid flashing printer status to red."""
  29. global _DATAFLEX_RECOVERY_GRACE_UNTIL
  30. u = time.time() + max(0.0, seconds)
  31. if u > _DATAFLEX_RECOVERY_GRACE_UNTIL:
  32. _DATAFLEX_RECOVERY_GRACE_UNTIL = u
  33. try:
  34. import serial
  35. except ImportError:
  36. serial = None # type: ignore
  37. try:
  38. import win32print # type: ignore[import]
  39. import win32ui # type: ignore[import]
  40. import win32con # type: ignore[import]
  41. import win32gui # type: ignore[import]
  42. except ImportError:
  43. win32print = None # type: ignore[assignment]
  44. win32ui = None # type: ignore[assignment]
  45. win32con = None # type: ignore[assignment]
  46. win32gui = None # type: ignore[assignment]
  47. try:
  48. from PIL import Image, ImageDraw, ImageFont, ImageOps
  49. try:
  50. from PIL import ImageWin # type: ignore
  51. except Exception:
  52. ImageWin = None # type: ignore[assignment]
  53. import qrcode
  54. _HAS_PIL_QR = True
  55. except ImportError:
  56. Image = None # type: ignore[assignment]
  57. ImageDraw = None # type: ignore[assignment]
  58. ImageFont = None # type: ignore[assignment]
  59. ImageOps = None # type: ignore[assignment]
  60. ImageWin = None # type: ignore[assignment]
  61. qrcode = None # type: ignore[assignment]
  62. _HAS_PIL_QR = False
  63. APP_VERSION = "4.0"
  64. DEFAULT_BASE_URL = os.environ.get("FPSMS_BASE_URL", "http://localhost:8090/api")
  65. # When run as PyInstaller exe, save settings next to the exe; otherwise next to script
  66. if getattr(sys, "frozen", False):
  67. _SETTINGS_DIR = os.path.dirname(sys.executable)
  68. else:
  69. _SETTINGS_DIR = os.path.dirname(os.path.abspath(__file__))
  70. # Bag4 has its own settings file so it doesn't share with Bag1/Bag2/Bag3.
  71. SETTINGS_FILE = os.path.join(_SETTINGS_DIR, "bag4_settings.json")
  72. LASER_COUNTER_FILE = os.path.join(_SETTINGS_DIR, "bag4_last_batch_count.txt")
  73. DEFAULT_SETTINGS = {
  74. "api_ip": "localhost",
  75. "api_port": "8090",
  76. "dabag_ip": "",
  77. "dabag_port": "3008",
  78. "laser_ip": "192.168.17.10",
  79. "laser_port": "45678",
  80. # For 標簽機 on Windows, this is the Windows printer name, e.g. "TSC TTP-246M Pro"
  81. "label_com": "TSC TTP-246M Pro",
  82. }
  83. def load_settings() -> dict:
  84. """Load settings from JSON file; return defaults if missing or invalid."""
  85. try:
  86. if os.path.isfile(SETTINGS_FILE):
  87. with open(SETTINGS_FILE, "r", encoding="utf-8") as f:
  88. data = json.load(f)
  89. return {**DEFAULT_SETTINGS, **data}
  90. except Exception:
  91. pass
  92. return dict(DEFAULT_SETTINGS)
  93. def save_settings(settings: dict) -> None:
  94. """Save settings to JSON file."""
  95. with open(SETTINGS_FILE, "w", encoding="utf-8") as f:
  96. json.dump(settings, f, indent=2, ensure_ascii=False)
  97. def build_base_url(api_ip: str, api_port: str) -> str:
  98. ip = (api_ip or "localhost").strip()
  99. port = (api_port or "8090").strip()
  100. return f"http://{ip}:{port}/api"
  101. def try_printer_connection(printer_name: str, sett: dict) -> bool:
  102. """Try to connect to the selected printer (TCP IP:port or COM). Returns True if OK."""
  103. if printer_name == "打袋機 DataFlex":
  104. ip = (sett.get("dabag_ip") or "").strip()
  105. port_str = (sett.get("dabag_port") or "3008").strip()
  106. if not ip:
  107. return False
  108. try:
  109. port = int(port_str)
  110. except ValueError:
  111. return False
  112. # Retry once: firmware often busy for ~1s after E1005 / blank label.
  113. timeout = max(PRINTER_SOCKET_TIMEOUT, 6.0)
  114. for attempt in range(2):
  115. try:
  116. s = socket.create_connection((ip, port), timeout=timeout)
  117. s.close()
  118. return True
  119. except (socket.error, OSError):
  120. if attempt == 0:
  121. time.sleep(0.35)
  122. continue
  123. return False
  124. if printer_name == "激光機":
  125. ip = (sett.get("laser_ip") or "").strip()
  126. port_str = (sett.get("laser_port") or "45678").strip()
  127. if not ip:
  128. return False
  129. try:
  130. port = int(port_str)
  131. s = socket.create_connection((ip, port), timeout=PRINTER_SOCKET_TIMEOUT)
  132. s.close()
  133. return True
  134. except (socket.error, ValueError, OSError):
  135. return False
  136. if printer_name == "標簽機":
  137. target = (sett.get("label_com") or "").strip()
  138. if not target:
  139. return False
  140. # On Windows, allow using a Windows printer name (e.g. "TSC TTP-246M Pro")
  141. # as an alternative to a COM port. If it doesn't look like a COM port,
  142. # try opening it via the Windows print spooler.
  143. if os.name == "nt" and not target.upper().startswith("COM"):
  144. if win32print is None:
  145. return False
  146. try:
  147. handle = win32print.OpenPrinter(target)
  148. win32print.ClosePrinter(handle)
  149. return True
  150. except Exception:
  151. return False
  152. # Fallback: treat as serial COM port (original behaviour)
  153. if serial is None:
  154. return False
  155. try:
  156. ser = serial.Serial(target, timeout=1)
  157. ser.close()
  158. return True
  159. except (serial.SerialException, OSError):
  160. return False
  161. return False
  162. # Larger font for aged users (point size)
  163. FONT_SIZE = 16
  164. FONT_SIZE_BUTTONS = 15
  165. # Printer selector: field + dropdown (use tk.OptionMenu so menu font is respected on Windows)
  166. FONT_SIZE_COMBO = 18
  167. FONT_SIZE_QTY = 12 # smaller for 需求數量 under batch no.
  168. FONT_SIZE_META = 11 # single-line 需求/已印 (compact list)
  169. # Less vertical padding so ~30 rows fit more comfortably
  170. LIST_ROW_PADY = 2
  171. LIST_ROW_IPADY = 5
  172. FONT_SIZE_ITEM = 20 # item code and item name (larger for readability)
  173. FONT_FAMILY = "Microsoft JhengHei UI" # Traditional Chinese; fallback to TkDefaultFont
  174. FONT_SIZE_ITEM_CODE = 20 # item code (larger for readability)
  175. FONT_SIZE_ITEM_NAME = 26 # item name (bigger than item code)
  176. # Column widths: fixed frame widths so 品號/品名 columns line up across rows
  177. LEFT_COL_WIDTH_PX = 300 # 工單 + 需求/已印 block
  178. ITEM_CODE_WRAP = 140 # Label wraplength (px)
  179. # Narrower than wrap+padding so short codes sit closer to 品名 (still aligned across rows)
  180. CODE_COL_WIDTH_PX = ITEM_CODE_WRAP + 6
  181. ITEM_NAME_WRAP = 640 # item name wraps in remaining space
  182. # Light blue theme (softer than pure grey)
  183. BG_TOP = "#E8F4FC"
  184. BG_LIST = "#D4E8F7"
  185. BG_ROOT = "#E1F0FF"
  186. BG_ROW = "#C5E1F5"
  187. BG_ROW_SELECTED = "#6BB5FF" # highlighted when selected (for printing)
  188. # Connection status bar
  189. BG_STATUS_ERROR = "#FFCCCB" # red background when disconnected
  190. FG_STATUS_ERROR = "#B22222" # red text
  191. BG_STATUS_OK = "#90EE90" # light green when connected
  192. FG_STATUS_OK = "#006400" # green text
  193. RETRY_MS = 30 * 1000 # 30 seconds reconnect
  194. # POST /py/job-order-print-submit: retries when server is briefly unavailable
  195. PRINT_SUBMIT_MAX_ATTEMPTS = 5
  196. PRINT_SUBMIT_RETRY_DELAY_SEC = 1.5
  197. PRINTER_CHECK_MS = 60 * 1000 # 1 minute when printer OK
  198. PRINTER_RETRY_MS = 30 * 1000 # 30 seconds when printer failed
  199. PRINTER_SOCKET_TIMEOUT = 3
  200. DATAFLEX_SEND_TIMEOUT = 10 # seconds when sending ZPL to DataFlex
  201. def _dataflex_float_env(name: str, default: float) -> float:
  202. raw = (os.environ.get(name) or "").strip()
  203. if not raw:
  204. return default
  205. try:
  206. return float(raw)
  207. except ValueError:
  208. return default
  209. def _dataflex_bool_env(name: str, default: bool) -> bool:
  210. raw = (os.environ.get(name) or "").strip().lower()
  211. if not raw:
  212. return default
  213. return raw in ("1", "true", "yes", "on")
  214. def _dataflex_int_env(name: str, default: int) -> int:
  215. raw = (os.environ.get(name) or "").strip()
  216. if not raw:
  217. return default
  218. try:
  219. return int(raw)
  220. except ValueError:
  221. return default
  222. # Job list auto-refresh interval (ms). 0 = off (list rebuild can hide DataFlex「停止列印」).
  223. # Re-enable: FPSMS_JOB_LIST_REFRESH_MS=60000
  224. JOB_LIST_AUTO_REFRESH_MS = max(0, _dataflex_int_env("FPSMS_JOB_LIST_REFRESH_MS", 0))
  225. # Defer full list rebuild while printing; retry after this interval until idle. FPSMS_JOB_LIST_DEFER_WHILE_PRINTING_MS
  226. JOB_LIST_DEFER_WHILE_PRINTING_MS = max(
  227. 500,
  228. _dataflex_int_env("FPSMS_JOB_LIST_DEFER_WHILE_PRINTING_MS", 1500),
  229. )
  230. # Gap between bag labels (after each job has fully left the client). Tune if bags are blank/skipped.
  231. # Override: FPSMS_DATAFLEX_INTER_LABEL_DELAY_SEC (e.g. 3.5 if ~3–5% blanks per 100).
  232. DATAFLEX_INTER_LABEL_DELAY_SEC = _dataflex_float_env(
  233. "FPSMS_DATAFLEX_INTER_LABEL_DELAY_SEC", 0.3
  234. )
  235. # Brief pause after each ZPL send so firmware can commit before we FIN the socket (reduces dropped/blank jobs).
  236. # Override: FPSMS_DATAFLEX_POST_LABEL_SETTLE_SEC
  237. DATAFLEX_POST_LABEL_SETTLE_SEC = _dataflex_float_env(
  238. "FPSMS_DATAFLEX_POST_LABEL_SETTLE_SEC", 0.08
  239. )
  240. # Before each print job: light reset only (~JA + ~RO). Must finish before first ^XA or first label can be lost.
  241. # Override: FPSMS_DATAFLEX_POST_PREPRINT_DELAY_SEC
  242. DATAFLEX_PREPRINT_BYTES = b"~JA\r\n~RO1\r\n~RO2\r\n"
  243. DATAFLEX_POST_PREPRINT_DELAY_SEC = _dataflex_float_env(
  244. "FPSMS_DATAFLEX_POST_PREPRINT_DELAY_SEC", 0.55
  245. )
  246. # Whether each new print job starts with full reset (~JR) so DataFlex batch counter returns to 0.
  247. # Set FPSMS_DATAFLEX_FULL_RESET_EACH_JOB=0 to keep only light preprint reset.
  248. DATAFLEX_FULL_RESET_EACH_JOB = _dataflex_bool_env(
  249. "FPSMS_DATAFLEX_FULL_RESET_EACH_JOB", False
  250. )
  251. # Extra-safe mode: run light preprint reset (~JA/~RO) before EVERY bag.
  252. # Slower, but reduces E1005 on unstable firmware.
  253. DATAFLEX_PREPRINT_EACH_LABEL = _dataflex_bool_env(
  254. "FPSMS_DATAFLEX_PREPRINT_EACH_LABEL", False
  255. )
  256. DATAFLEX_VERIFY_STATUS_AFTER_SEND = _dataflex_bool_env(
  257. "FPSMS_DATAFLEX_VERIFY_STATUS_AFTER_SEND", False
  258. )
  259. # Hard-disable automatic reset/counter commands during printing.
  260. # When False, normal print path sends ZPL only (no ~JA/~RO/~JR).
  261. DATAFLEX_AUTO_RESET_ENABLED = _dataflex_bool_env(
  262. "FPSMS_DATAFLEX_AUTO_RESET_ENABLED", False
  263. )
  264. # After a failed TCP send, always run host recovery + retry (recommended when E1005 stops the run).
  265. DATAFLEX_RECOVER_ON_SEND_ERROR = _dataflex_bool_env(
  266. "FPSMS_DATAFLEX_RECOVER_ON_SEND_ERROR", True
  267. )
  268. DATAFLEX_STATUS_QUERY_TIMEOUT_SEC = _dataflex_float_env(
  269. "FPSMS_DATAFLEX_STATUS_QUERY_TIMEOUT_SEC", 0.8
  270. )
  271. DATAFLEX_RECOVERY_MAX_ATTEMPTS = max(
  272. 1,
  273. _dataflex_int_env("FPSMS_DATAFLEX_RECOVERY_MAX_ATTEMPTS", 2),
  274. )
  275. DATAFLEX_RECOVERY_WAIT_SEC = _dataflex_float_env(
  276. "FPSMS_DATAFLEX_RECOVERY_WAIT_SEC", 0.8
  277. )
  278. # Prevent cumulative thermal/mechanical fault in long runs (E1000 after ~40 bags on some units):
  279. # pause briefly every N bags.
  280. DATAFLEX_COOLDOWN_EVERY_LABELS = max(
  281. 0,
  282. _dataflex_int_env("FPSMS_DATAFLEX_COOLDOWN_EVERY_LABELS", 8),
  283. )
  284. DATAFLEX_COOLDOWN_SEC = _dataflex_float_env(
  285. "FPSMS_DATAFLEX_COOLDOWN_SEC", 3.5
  286. )
  287. # Extra long pause every M bags (head cool-down). 0 = off. FPSMS_DATAFLEX_THERMAL_REST_EVERY_LABELS
  288. DATAFLEX_THERMAL_REST_EVERY_LABELS = max(
  289. 0,
  290. _dataflex_int_env("FPSMS_DATAFLEX_THERMAL_REST_EVERY_LABELS", 20),
  291. )
  292. DATAFLEX_THERMAL_REST_SEC = _dataflex_float_env(
  293. "FPSMS_DATAFLEX_THERMAL_REST_SEC", 5.0
  294. )
  295. # Light ~HS check every N bags. Default off — periodic checks + recovery caused long stalls with E1005 on some units.
  296. DATAFLEX_VERIFY_EVERY_LABELS = max(
  297. 0,
  298. _dataflex_int_env("FPSMS_DATAFLEX_VERIFY_EVERY_LABELS", 0),
  299. )
  300. # Status bar progress while printing (main thread). 0 = off.
  301. DATAFLEX_UI_PROGRESS_EVERY = max(
  302. 0,
  303. _dataflex_int_env("FPSMS_DATAFLEX_UI_PROGRESS_EVERY", 5),
  304. )
  305. # One TCP send: single ZPL with ^PQn (n identical bags). Some DataFlex units may fault (E1005); default off.
  306. DATAFLEX_SINGLE_TCP_JOB = _dataflex_bool_env(
  307. "FPSMS_DATAFLEX_SINGLE_TCP_JOB", False
  308. )
  309. # Link-OS SGD: raw-ZPL job shows this host id instead of a generic name (e.g. "ZPL. EMULATION").
  310. # Same id when the same job order is printed again. Disable: FPSMS_DATAFLEX_HOST_IDENTIFICATION_SGD=0
  311. DATAFLEX_HOST_IDENTIFICATION_SGD = _dataflex_bool_env(
  312. "FPSMS_DATAFLEX_HOST_IDENTIFICATION_SGD", True
  313. )
  314. # Bag ZPL size (dots). ^PW700 matched little content (mostly vertical ^A@R), so previews showed a wide strip with empty right margin.
  315. DATAFLEX_LABEL_PW = max(
  316. 280,
  317. _dataflex_int_env("FPSMS_DATAFLEX_LABEL_PW", 400),
  318. )
  319. DATAFLEX_LABEL_LL = max(
  320. 200,
  321. _dataflex_int_env("FPSMS_DATAFLEX_LABEL_LL", 500),
  322. )
  323. # Some Zebra/DataFlex units RST the socket on host half-close; Windows surfaces WinError 10054.
  324. # Set FPSMS_DATAFLEX_SKIP_SHUTDOWN_WR=1 to omit shutdown(SHUT_WR) and only close() (often avoids RST).
  325. DATAFLEX_SKIP_SHUTDOWN_WR = _dataflex_bool_env(
  326. "FPSMS_DATAFLEX_SKIP_SHUTDOWN_WR", False
  327. )
  328. # Full recovery (~JR soft reset) — used by「打袋重設」only; longer delay for firmware
  329. DATAFLEX_POST_FULL_RECOVERY_DELAY_SEC = 1.2
  330. # Zebra ~RO only (used when FPSMS_DATAFLEX_NO_JR is set for full recovery)
  331. DATAFLEX_RESET_BYTES = b"~RO1\r\n~RO2\r\n"
  332. # Full host recovery: ~JA clear buffers, ~RO counters, ~JR soft reset (clears latched errors without power cycle)
  333. DATAFLEX_FULL_RECOVERY_BYTES = b"~JA\r\n~RO1\r\n~RO2\r\n~JR\r\n"
  334. def _dataflex_full_recovery_payload() -> bytes:
  335. """~JA+~RO+~JR for manual「打袋重設」; set env FPSMS_DATAFLEX_NO_JR=1 to skip ~JR."""
  336. if os.environ.get("FPSMS_DATAFLEX_NO_JR", "").strip().lower() in ("1", "true", "yes"):
  337. return b"~JA\r\n" + DATAFLEX_RESET_BYTES
  338. return DATAFLEX_FULL_RECOVERY_BYTES
  339. def _zpl_escape(s: str) -> str:
  340. """Escape text for ZPL ^FD...^FS (backslash and caret)."""
  341. return s.replace("\\", "\\\\").replace("^", "\\^")
  342. def _dataflex_host_identification_sgd_prefix(job_order_id: Optional[int]) -> str:
  343. """
  344. Optional ASCII prefix before ^XA: set zpl.host_identification so the printer lists the job
  345. under the job order id instead of a generic raw-ZPL label.
  346. """
  347. if not DATAFLEX_HOST_IDENTIFICATION_SGD or job_order_id is None:
  348. return ""
  349. try:
  350. jid = str(int(job_order_id))
  351. except (TypeError, ValueError):
  352. return ""
  353. if not jid.isdigit():
  354. return ""
  355. return f'! U1 setvar "zpl.host_identification" "{jid}"\r\n'
  356. def _dataflex_zpl_bytes(zpl: str) -> bytes:
  357. """UTF-8 ZPL with one trailing CRLF so the printer sees a clear job boundary."""
  358. s = (zpl or "").rstrip("\r\n")
  359. return (s + "\r\n").encode("utf-8")
  360. def _dataflex_is_benign_tcp_reset(err: BaseException) -> bool:
  361. """True when peer closed with RST/FIN in a way that is normal for raw printer TCP (Windows 10054)."""
  362. if isinstance(err, (BrokenPipeError, ConnectionResetError, ConnectionAbortedError)):
  363. return True
  364. if isinstance(err, OSError):
  365. if getattr(err, "winerror", None) == 10054: # WSAECONNRESET
  366. return True
  367. if err.errno in (
  368. errno.ECONNRESET,
  369. errno.EPIPE,
  370. errno.ECONNABORTED,
  371. ):
  372. return True
  373. return False
  374. def _dataflex_shutdown_write_maybe(sock: socket.socket) -> None:
  375. """Half-close write side; ignore printer RST (common after ZPL on port 9100-style links)."""
  376. if DATAFLEX_SKIP_SHUTDOWN_WR:
  377. return
  378. try:
  379. sock.shutdown(socket.SHUT_WR)
  380. except OSError as e:
  381. if _dataflex_is_benign_tcp_reset(e):
  382. return
  383. raise
  384. EXPIRY_PRINT_PREFIX = "Expiry Date "
  385. EXPIRY_LABEL_PREFIX = "Expiry "
  386. def format_expiry_print_label(d: date) -> str:
  387. """DataFlex / laser wording: `Expiry Date 20260826`."""
  388. return f"{EXPIRY_PRINT_PREFIX}{d.strftime('%Y%m%d')}"
  389. def format_expiry_label_compact(d: date) -> str:
  390. """標簽機 wording (narrow): `Expiry 20260826`."""
  391. return f"{EXPIRY_LABEL_PREFIX}{d.strftime('%Y%m%d')}"
  392. def _parse_api_expiry_date(raw) -> Optional[date]:
  393. """Accept ISO `yyyy-MM-dd`, compact `yyyyMMdd`, `Expiry Date yyyyMMdd`, or `[yyyy,M,d]`."""
  394. if raw is None or raw == "":
  395. return None
  396. if isinstance(raw, datetime):
  397. return raw.date()
  398. if isinstance(raw, date):
  399. return raw
  400. if isinstance(raw, (list, tuple)) and len(raw) >= 3:
  401. try:
  402. return date(int(raw[0]), int(raw[1]), int(raw[2]))
  403. except (TypeError, ValueError):
  404. return None
  405. s = str(raw).strip().replace(";", ",")
  406. if not s:
  407. return None
  408. if s.lower().startswith(EXPIRY_PRINT_PREFIX.lower()):
  409. s = s[len(EXPIRY_PRINT_PREFIX):].strip()
  410. elif s.lower().startswith(EXPIRY_LABEL_PREFIX.lower()):
  411. s = s[len(EXPIRY_LABEL_PREFIX):].strip()
  412. digits = "".join(ch for ch in s if ch.isdigit())
  413. if len(digits) >= 8:
  414. try:
  415. return date(int(digits[0:4]), int(digits[4:6]), int(digits[6:8]))
  416. except ValueError:
  417. return None
  418. return None
  419. def standardize_expiry_print_label(raw) -> str:
  420. """Normalize any expiry input to `Expiry Date 20260826`, or empty if unknown."""
  421. parsed = _parse_api_expiry_date(raw)
  422. return format_expiry_print_label(parsed) if parsed else ""
  423. def standardize_expiry_label_compact(raw) -> str:
  424. """Normalize any expiry input to `Expiry 20260826` for 標簽機, or empty if unknown."""
  425. parsed = _parse_api_expiry_date(raw)
  426. return format_expiry_label_compact(parsed) if parsed else ""
  427. def job_expiry_zpl_text(jo: dict) -> Optional[str]:
  428. """Expiry as `Expiry Date yyyyMMdd` from shelf-life days, else API expiryDate."""
  429. days = jo.get("defaultShelfLifeDays")
  430. if isinstance(days, bool):
  431. days = None
  432. if isinstance(days, float) and days.is_integer():
  433. days = int(days)
  434. if isinstance(days, int) and days > 0:
  435. return format_expiry_print_label(date.today() + timedelta(days=days))
  436. label = standardize_expiry_print_label(jo.get("expiryDate"))
  437. return label or None
  438. def job_expiry_laser_param(jo: dict) -> str:
  439. """Same `Expiry Date yyyyMMdd` as DataFlex, sanitized for `;`-separated laser TCP."""
  440. return (job_expiry_zpl_text(jo) or "").replace(";", ",")
  441. def generate_zpl_dataflex(
  442. batch_no: str,
  443. item_code: str,
  444. item_name: str,
  445. item_id: Optional[int] = None,
  446. stock_in_line_id: Optional[int] = None,
  447. lot_no: Optional[str] = None,
  448. job_order_id: Optional[int] = None,
  449. expiry_text: Optional[str] = None,
  450. font_regular: str = "E:STXihei.ttf",
  451. font_bold: str = "E:STXihei.ttf",
  452. ) -> str:
  453. """
  454. Row 1 (from zero): QR code, then item name (rotated 90°).
  455. Row 2: smaller Lot, then expiry at the next X, then item code.
  456. Text is ^A@R (90° CW); the next line under Lot is +X, not +Y.
  457. Label and QR use lotNo from API when present, else batch_no (Bxxxxx).
  458. Light preprint (~JA/~RO) is sent before labels; full ~JR recovery is only for「打袋重設」.
  459. """
  460. desc = _zpl_escape((item_name or "—").strip())
  461. code = _zpl_escape((item_code or "—").strip())
  462. label_line = (lot_no or batch_no or "").strip()
  463. label_esc = _zpl_escape(label_line)
  464. exp_raw = standardize_expiry_print_label(expiry_text)
  465. exp_esc = _zpl_escape(exp_raw) if exp_raw else ""
  466. # QR payload: prefer JSON {"itemId":..., "stockInLineId":...} when both present; else fall back to lot/batch text
  467. if item_id is not None and stock_in_line_id is not None:
  468. qr_payload = json.dumps({"itemId": item_id, "stockInLineId": stock_in_line_id})
  469. else:
  470. qr_payload = label_line if label_line else batch_no.strip()
  471. qr_value = _zpl_escape(qr_payload)
  472. # Explicit ^PQ1: each ^XA…^XZ is exactly one bag. Avoids E1005 "over quantity" on some Zebra/DataFlex
  473. # firmware when many labels are sent on one TCP session without a per-job quantity.
  474. host_id = _dataflex_host_identification_sgd_prefix(job_order_id)
  475. if exp_esc:
  476. row2 = f"""^FO0,200
  477. ^A@R,40,40,{font_regular}^FD{label_esc}^FS
  478. ^FO42,200
  479. ^A@R,40,40,{font_regular}^FD{exp_esc}^FS
  480. ^FO88,200
  481. ^A@R,88,88,{font_bold}^FD{code}^FS"""
  482. else:
  483. row2 = f"""^FO0,200
  484. ^A@R,72,72,{font_regular}^FD{label_esc}^FS
  485. ^FO55,200
  486. ^A@R,88,88,{font_bold}^FD{code}^FS"""
  487. return host_id + f"""^XA
  488. ^PQ1,0,1,N
  489. ^CI28
  490. ^PW{DATAFLEX_LABEL_PW}
  491. ^LL{DATAFLEX_LABEL_LL}
  492. ^PO N
  493. ^FO10,20
  494. ^BQN,2,4^FDQA,{qr_value}^FS
  495. ^FO170,20
  496. ^A@R,72,72,{font_regular}^FD{desc}^FS
  497. {row2}
  498. ^XZ"""
  499. def dataflex_zpl_set_print_quantity(zpl: str, copies: int) -> str:
  500. """
  501. Replace the fixed ^PQ1 line from generate_zpl_dataflex() with ^PQn so one ZPL job prints
  502. n identical bags over one TCP connection.
  503. """
  504. if copies < 1:
  505. copies = 1
  506. old = "^PQ1,0,1,N"
  507. if old not in zpl:
  508. raise RuntimeError(
  509. "DataFlex ZPL 缺少預期的 ^PQ1 列(無法改為單次連線多張)。"
  510. )
  511. return zpl.replace(old, f"^PQ{copies},0,1,N", 1)
  512. def send_dataflex_preprint_reset(ip: str, port: int, *, force: bool = False) -> None:
  513. """
  514. Fast prep before printing: ~JA + ~RO (no ~JR). Clears buffer and zeros batch counters so the first
  515. bag starts quickly. Use before fixed-qty batch and continuous mode.
  516. """
  517. if not force and not DATAFLEX_AUTO_RESET_ENABLED:
  518. return
  519. sock = socket.socket(socket.AF_INET, socket.SOCK_STREAM)
  520. try:
  521. sock.setsockopt(socket.IPPROTO_TCP, socket.TCP_NODELAY, 1)
  522. except OSError:
  523. pass
  524. sock.settimeout(DATAFLEX_SEND_TIMEOUT)
  525. try:
  526. sock.connect((ip, port))
  527. sock.sendall(DATAFLEX_PREPRINT_BYTES)
  528. time.sleep(DATAFLEX_POST_PREPRINT_DELAY_SEC)
  529. _dataflex_shutdown_write_maybe(sock)
  530. finally:
  531. sock.close()
  532. def send_dataflex_job_counter_reset(ip: str, port: int, *, force: bool = False) -> None:
  533. """
  534. Full host recovery for「打袋重設」: ~JA, ~RO, and ~JR (soft reset) to clear latched E1005.
  535. Slower than [send_dataflex_preprint_reset]; do not use on every row click.
  536. """
  537. if not force and not DATAFLEX_AUTO_RESET_ENABLED:
  538. return
  539. sock = socket.socket(socket.AF_INET, socket.SOCK_STREAM)
  540. try:
  541. sock.setsockopt(socket.IPPROTO_TCP, socket.TCP_NODELAY, 1)
  542. except OSError:
  543. pass
  544. sock.settimeout(DATAFLEX_SEND_TIMEOUT)
  545. try:
  546. sock.connect((ip, port))
  547. sock.sendall(_dataflex_full_recovery_payload())
  548. time.sleep(DATAFLEX_POST_FULL_RECOVERY_DELAY_SEC)
  549. _dataflex_shutdown_write_maybe(sock)
  550. finally:
  551. sock.close()
  552. def send_dataflex_start_job_reset(ip: str, port: int, *, force: bool = False) -> None:
  553. """
  554. Start-of-job reset sequence.
  555. Full reset first (default) ensures printer-side batch quantity returns to 0 for each job;
  556. then light preprint reset prepares the first bag send.
  557. Use force=True for the start of each print job and when selecting a job row so batch
  558. counter resets even if FPSMS_DATAFLEX_AUTO_RESET_ENABLED=0 (that flag mainly gates
  559. extra per-label / recovery traffic).
  560. """
  561. if not force and not DATAFLEX_AUTO_RESET_ENABLED:
  562. return
  563. if DATAFLEX_FULL_RESET_EACH_JOB:
  564. send_dataflex_job_counter_reset(ip, port, force=force)
  565. send_dataflex_preprint_reset(ip, port, force=force)
  566. def send_dataflex_reset_and_labels(
  567. ip: str,
  568. port: int,
  569. zpl: str,
  570. copies: int,
  571. delay_sec: float,
  572. ) -> None:
  573. """
  574. One TCP connection: light preprint (~JA + ~RO), short pause, then `copies` identical ZPL labels
  575. with delay_sec between copies (not after the last). Avoids rapid connect/disconnect per bag.
  576. """
  577. if copies < 1:
  578. return
  579. raw_zpl = _dataflex_zpl_bytes(zpl)
  580. sock = socket.socket(socket.AF_INET, socket.SOCK_STREAM)
  581. try:
  582. sock.setsockopt(socket.IPPROTO_TCP, socket.TCP_NODELAY, 1)
  583. except OSError:
  584. pass
  585. sock.settimeout(DATAFLEX_SEND_TIMEOUT)
  586. try:
  587. sock.connect((ip, port))
  588. sock.sendall(DATAFLEX_PREPRINT_BYTES)
  589. time.sleep(DATAFLEX_POST_PREPRINT_DELAY_SEC)
  590. for i in range(copies):
  591. sock.sendall(raw_zpl)
  592. time.sleep(DATAFLEX_POST_LABEL_SETTLE_SEC)
  593. if i < copies - 1:
  594. time.sleep(delay_sec)
  595. _dataflex_shutdown_write_maybe(sock)
  596. finally:
  597. sock.close()
  598. def generate_zpl_label_small(
  599. batch_no: str,
  600. item_code: str,
  601. item_name: str,
  602. item_id: Optional[int] = None,
  603. stock_in_line_id: Optional[int] = None,
  604. lot_no: Optional[str] = None,
  605. expiry_text: Optional[str] = None,
  606. font: str = "MingLiUHKSCS",
  607. ) -> str:
  608. """
  609. ZPL for 標簽機. Row 1: item name. Row 2: QR left | item code + lot + expiry right.
  610. Expiry is always `Expiry yyyyMMdd` when present (narrow label).
  611. QR contains {"itemId": xxx, "stockInLineId": xxx} when both present; else batch_no.
  612. Unicode (^CI28); font set for Big-5 (e.g. MingLiUHKSCS).
  613. """
  614. desc = _zpl_escape((item_name or "—").strip())
  615. code = _zpl_escape((item_code or "—").strip())
  616. label_line2 = (lot_no or batch_no or "—").strip()
  617. label_line2_esc = _zpl_escape(label_line2)
  618. exp_esc = _zpl_escape(standardize_expiry_label_compact(expiry_text))
  619. if item_id is not None and stock_in_line_id is not None:
  620. qr_data = _zpl_escape(json.dumps({"itemId": item_id, "stockInLineId": stock_in_line_id}))
  621. else:
  622. qr_data = f"QA,{batch_no}"
  623. exp_zpl = f"""
  624. ^FO150,230
  625. ^A@N,36,36,{font}^FD{exp_esc}^FS""" if exp_esc else ""
  626. return f"""^XA
  627. ^CI28
  628. ^PW500
  629. ^LL500
  630. ^FO10,15
  631. ^FB480,3,0,L,0
  632. ^A@N,38,38,{font}^FD{desc}^FS
  633. ^FO10,110
  634. ^BQN,2,6^FD{qr_data}^FS
  635. ^FO150,110
  636. ^A@N,48,48,{font}^FD{code}^FS
  637. ^FO150,175
  638. ^A@N,40,40,{font}^FD{label_line2_esc}^FS{exp_zpl}
  639. ^XZ"""
  640. # Label image size (pixels) for 標簽機 image printing.
  641. # Enlarged for readability (approx +90% scale).
  642. LABEL_IMAGE_W = 720
  643. LABEL_IMAGE_H = 530
  644. LABEL_PADDING = 23
  645. LABEL_FONT_NAME_SIZE = 42
  646. LABEL_FONT_CODE_SIZE = 49
  647. LABEL_FONT_BATCH_SIZE = 34
  648. LABEL_FONT_EXPIRY_SIZE = 34
  649. LABEL_QR_SIZE = 210
  650. def _get_chinese_font(size: int) -> Optional["ImageFont.FreeTypeFont"]:
  651. """Return a Chinese-capable font for PIL, or None to use default."""
  652. if ImageFont is None:
  653. return None
  654. # Prefer real font files on Windows (font *names* may fail and silently fallback).
  655. if os.name == "nt":
  656. fonts_dir = os.path.join(os.environ.get("WINDIR", r"C:\Windows"), "Fonts")
  657. for rel in (
  658. "msjh.ttc", # Microsoft JhengHei
  659. "msjhl.ttc", # Microsoft JhengHei Light
  660. "msjhbd.ttc", # Microsoft JhengHei Bold
  661. "mingliu.ttc",
  662. "mingliub.ttc",
  663. "kaiu.ttf",
  664. "msyh.ttc", # Microsoft YaHei
  665. "msyhbd.ttc",
  666. "simhei.ttf",
  667. "simsun.ttc",
  668. ):
  669. p = os.path.join(fonts_dir, rel)
  670. try:
  671. if os.path.exists(p):
  672. return ImageFont.truetype(p, size)
  673. except (OSError, IOError):
  674. continue
  675. # Fallback: try common font names (may still work depending on Pillow build)
  676. for name in (
  677. "Microsoft JhengHei UI",
  678. "Microsoft JhengHei",
  679. "MingLiU",
  680. "MingLiU_HKSCS",
  681. "Microsoft YaHei",
  682. "SimHei",
  683. "SimSun",
  684. ):
  685. try:
  686. return ImageFont.truetype(name, size)
  687. except (OSError, IOError):
  688. continue
  689. try:
  690. return ImageFont.load_default()
  691. except Exception:
  692. return None
  693. def render_label_to_image(
  694. batch_no: str,
  695. item_code: str,
  696. item_name: str,
  697. item_id: Optional[int] = None,
  698. stock_in_line_id: Optional[int] = None,
  699. lot_no: Optional[str] = None,
  700. expiry_text: Optional[str] = None,
  701. ) -> "Image.Image":
  702. """
  703. Render 標簽機 label as a PIL Image (white bg, black text + QR).
  704. Lot line is followed by `Expiry yyyyMMdd` when [expiry_text] is set.
  705. Use this image for printing so Chinese displays correctly; words are drawn bigger.
  706. Requires Pillow and qrcode. Raises RuntimeError if not available.
  707. """
  708. if not _HAS_PIL_QR or Image is None or qrcode is None:
  709. raise RuntimeError("Pillow and qrcode are required for image labels. Run: pip install Pillow qrcode[pil]")
  710. img = Image.new("RGB", (LABEL_IMAGE_W, LABEL_IMAGE_H), "white")
  711. draw = ImageDraw.Draw(img)
  712. # QR payload (same as ZPL)
  713. if item_id is not None and stock_in_line_id is not None:
  714. qr_data = json.dumps({"itemId": item_id, "stockInLineId": stock_in_line_id})
  715. else:
  716. qr_data = f"QA,{batch_no}"
  717. # Draw QR top-left area
  718. qr = qrcode.QRCode(box_size=4, border=2)
  719. qr.add_data(qr_data)
  720. qr.make(fit=True)
  721. qr_img = qr.make_image(fill_color="black", back_color="white")
  722. _resample = getattr(Image, "Resampling", Image).NEAREST
  723. qr_img = qr_img.resize((LABEL_QR_SIZE, LABEL_QR_SIZE), _resample)
  724. img.paste(qr_img, (LABEL_PADDING, LABEL_PADDING))
  725. # Fonts (bigger for readability)
  726. font_name = _get_chinese_font(LABEL_FONT_NAME_SIZE)
  727. font_code = _get_chinese_font(LABEL_FONT_CODE_SIZE)
  728. font_batch = _get_chinese_font(LABEL_FONT_BATCH_SIZE)
  729. font_expiry = _get_chinese_font(LABEL_FONT_EXPIRY_SIZE)
  730. x_right = LABEL_PADDING + LABEL_QR_SIZE + LABEL_PADDING
  731. y_line = LABEL_PADDING
  732. # Line 1: item name (wrap within remaining width)
  733. name_str = (item_name or "—").strip()
  734. max_name_w = LABEL_IMAGE_W - x_right - LABEL_PADDING
  735. if font_name:
  736. # Wrap rule: after 7 "words" (excl. parentheses). ()() not counted; +=*/. and A–Z/a–z count as 0.5.
  737. def _wrap_text(text: str, font, max_width: int) -> list:
  738. ignore = set("()()")
  739. half = set("+=*/.abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ")
  740. max_count = 6.5
  741. lines: list[str] = []
  742. current: list[str] = []
  743. count = 0.0
  744. for ch in text:
  745. if ch == "\n":
  746. lines.append("".join(current).strip())
  747. current = []
  748. count = 0.0
  749. continue
  750. ch_count = 0.0 if ch in ignore else (0.5 if ch in half else 1.0)
  751. if count + ch_count > max_count and current:
  752. lines.append("".join(current).strip())
  753. current = []
  754. count = 0.0
  755. current.append(ch)
  756. count += ch_count
  757. if current:
  758. lines.append("".join(current).strip())
  759. # Max 2 rows for item name. If still long, keep everything in row 2.
  760. if len(lines) > 2:
  761. lines = [lines[0], "".join(lines[1:]).strip()]
  762. # Safety: if any line still exceeds pixel width, wrap by width as well.
  763. if hasattr(draw, "textbbox"):
  764. out: list[str] = []
  765. for ln in lines:
  766. buf: list[str] = []
  767. for ch in ln:
  768. buf.append(ch)
  769. bbox = draw.textbbox((0, 0), "".join(buf), font=font)
  770. if bbox[2] - bbox[0] > max_width and len(buf) > 1:
  771. out.append("".join(buf[:-1]).strip())
  772. buf = [buf[-1]]
  773. if buf:
  774. out.append("".join(buf).strip())
  775. out = [x for x in out if x]
  776. if len(out) > 2:
  777. out = [out[0], "".join(out[1:]).strip()]
  778. return out
  779. lines = [x for x in lines if x]
  780. if len(lines) > 2:
  781. lines = [lines[0], "".join(lines[1:]).strip()]
  782. return lines
  783. lines = _wrap_text(name_str, font_name, max_name_w)
  784. for i, ln in enumerate(lines):
  785. draw.text((x_right, y_line + i * (LABEL_FONT_NAME_SIZE + 4)), ln, font=font_name, fill="black")
  786. y_line += len(lines) * (LABEL_FONT_NAME_SIZE + 4) + 8
  787. else:
  788. draw.text((x_right, y_line), name_str[:30], fill="black")
  789. y_line += LABEL_FONT_NAME_SIZE + 12
  790. # Item code (bigger)
  791. code_str = (item_code or "—").strip()
  792. if font_code:
  793. draw.text((x_right, y_line), code_str, font=font_code, fill="black")
  794. else:
  795. draw.text((x_right, y_line), code_str, fill="black")
  796. y_line += LABEL_FONT_CODE_SIZE + 6
  797. # Batch/lot line
  798. batch_str = (lot_no or batch_no or "—").strip()
  799. if font_batch:
  800. draw.text((x_right, y_line), batch_str, font=font_batch, fill="black")
  801. else:
  802. draw.text((x_right, y_line), batch_str, fill="black")
  803. y_line += LABEL_FONT_BATCH_SIZE + 6
  804. exp_str = standardize_expiry_label_compact(expiry_text)
  805. if exp_str:
  806. if font_expiry:
  807. draw.text((x_right, y_line), exp_str, font=font_expiry, fill="black")
  808. else:
  809. draw.text((x_right, y_line), exp_str, fill="black")
  810. return img
  811. def _image_to_zpl_gfa(pil_image: "Image.Image") -> str:
  812. """
  813. Convert a PIL image into ZPL ^GFA (ASCII hex) so we can print Chinese reliably
  814. on ZPL printers (USB/Windows printer or COM) without relying on GDI drivers.
  815. """
  816. if Image is None or ImageOps is None:
  817. raise RuntimeError("Pillow is required for image-to-ZPL conversion.")
  818. # Convert to 1-bit monochrome bitmap. Invert so '1' bits represent black in ZPL.
  819. img_bw = ImageOps.invert(pil_image.convert("L")).convert("1")
  820. w, h = img_bw.size
  821. bytes_per_row = (w + 7) // 8
  822. raw = img_bw.tobytes()
  823. total = bytes_per_row * h
  824. # Ensure length matches expected (Pillow should already pack per row).
  825. if len(raw) != total:
  826. raw = raw[:total].ljust(total, b"\x00")
  827. hex_data = raw.hex().upper()
  828. return f"""^XA
  829. ^PW{w}
  830. ^LL{h}
  831. ^FO0,0
  832. ^GFA,{total},{total},{bytes_per_row},{hex_data}
  833. ^FS
  834. ^XZ"""
  835. def zpl_apply_print_quantity(zpl: str, copies: int) -> str:
  836. """
  837. Ask the printer to output `copies` identical labels from one ZPL format by inserting ^PQ after ^XA.
  838. Results in a **single** spool job / one write — no N separate Windows jobs, no chained ^XA blocks
  839. (which broke some TSC drivers with white-on-white ^GFA output).
  840. """
  841. if copies <= 1:
  842. return zpl
  843. first_fmt = zpl.split("^XZ", 1)[0] if "^XZ" in zpl else zpl
  844. if "^PQ" in first_fmt.upper():
  845. return zpl
  846. lines = zpl.splitlines()
  847. new_lines: list[str] = []
  848. inserted = False
  849. for line in lines:
  850. new_lines.append(line)
  851. if not inserted and line.strip() == "^XA":
  852. # ZPL II: q labels, 0 pause between, 1 replicate (non-serial), N = default options.
  853. # Same graphic (^GFA) is repeated q times — e.g. 需求數量 150 → 150 identical labels, one spool job.
  854. new_lines.append(f"^PQ{copies},0,1,N")
  855. inserted = True
  856. if not inserted:
  857. raise RuntimeError(
  858. "標籤 ZPL 無法插入 ^PQ(格式非預期)。請聯絡程式維護。"
  859. )
  860. ending = "\n" if zpl.endswith("\n") else ""
  861. return "\n".join(new_lines) + ending
  862. def send_image_to_label_printer(printer_name: str, pil_image: "Image.Image") -> None:
  863. """
  864. Send a PIL Image to 標簽機 via Windows GDI (so Chinese and graphics print correctly).
  865. Only supported when target is a Windows printer name (not COM port). Requires pywin32.
  866. """
  867. dest = (printer_name or "").strip()
  868. if not dest:
  869. raise ValueError("Label printer destination is empty.")
  870. if os.name != "nt" or dest.upper().startswith("COM"):
  871. raise RuntimeError("Image printing is only supported for a Windows printer name (e.g. TSC TTP-246M Pro).")
  872. if win32print is None or win32ui is None or win32con is None or win32gui is None:
  873. raise RuntimeError("pywin32 is required. Run: pip install pywin32")
  874. dc = win32ui.CreateDC()
  875. dc.CreatePrinterDC(dest)
  876. dc.StartDoc("FPSMS Label")
  877. dc.StartPage()
  878. try:
  879. bmp_w = pil_image.width
  880. bmp_h = pil_image.height
  881. # Scale-to-fit printable area (important for smaller physical labels).
  882. try:
  883. page_w = int(dc.GetDeviceCaps(win32con.HORZRES))
  884. page_h = int(dc.GetDeviceCaps(win32con.VERTRES))
  885. except Exception:
  886. page_w, page_h = bmp_w, bmp_h
  887. if page_w <= 0 or page_h <= 0:
  888. page_w, page_h = bmp_w, bmp_h
  889. scale = min(page_w / max(1, bmp_w), page_h / max(1, bmp_h))
  890. out_w = max(1, int(bmp_w * scale))
  891. out_h = max(1, int(bmp_h * scale))
  892. x0 = max(0, (page_w - out_w) // 2)
  893. y0 = max(0, (page_h - out_h) // 2)
  894. # Most reliable: render via Pillow ImageWin directly to printer DC.
  895. if ImageWin is not None:
  896. dib = ImageWin.Dib(pil_image.convert("RGB"))
  897. dib.draw(dc.GetHandleOutput(), (x0, y0, x0 + out_w, y0 + out_h))
  898. else:
  899. # Fallback: Draw image to printer DC via temp BMP (GDI uses BMP)
  900. with tempfile.NamedTemporaryFile(suffix=".bmp", delete=False) as f:
  901. tmp_bmp = f.name
  902. try:
  903. pil_image.save(tmp_bmp, "BMP")
  904. hbm = win32gui.LoadImage(
  905. 0, tmp_bmp, win32con.IMAGE_BITMAP, 0, 0,
  906. win32con.LR_LOADFROMFILE | win32con.LR_CREATEDIBSECTION,
  907. )
  908. if hbm == 0:
  909. raise RuntimeError("Failed to load label image as bitmap.")
  910. try:
  911. mem_dc = win32ui.CreateDCFromHandle(win32gui.CreateCompatibleDC(dc.GetSafeHdc()))
  912. bmp = getattr(win32ui, "CreateBitmapFromHandle", lambda h: win32ui.PyCBitmap.FromHandle(h))(hbm)
  913. mem_dc.SelectObject(bmp)
  914. dc.StretchBlt((x0, y0), (out_w, out_h), mem_dc, (0, 0), (bmp_w, bmp_h), win32con.SRCCOPY)
  915. finally:
  916. win32gui.DeleteObject(hbm)
  917. finally:
  918. try:
  919. os.unlink(tmp_bmp)
  920. except OSError:
  921. pass
  922. finally:
  923. dc.EndPage()
  924. dc.EndDoc()
  925. def send_zpl_to_dataflex(ip: str, port: int, zpl: str) -> None:
  926. """Send ZPL label (^XA…^XZ) to DataFlex printer via TCP. Raises on connection/send error."""
  927. sock = socket.socket(socket.AF_INET, socket.SOCK_STREAM)
  928. try:
  929. sock.setsockopt(socket.IPPROTO_TCP, socket.TCP_NODELAY, 1)
  930. except OSError:
  931. pass
  932. sock.settimeout(DATAFLEX_SEND_TIMEOUT)
  933. try:
  934. sock.connect((ip, port))
  935. sock.sendall(_dataflex_zpl_bytes(zpl))
  936. time.sleep(DATAFLEX_POST_LABEL_SETTLE_SEC)
  937. _dataflex_shutdown_write_maybe(sock)
  938. finally:
  939. sock.close()
  940. def query_dataflex_host_status(ip: str, port: int) -> str:
  941. """
  942. Query DataFlex/Zebra host status (~HS). Returns decoded status text, or empty string
  943. when device does not return host status.
  944. """
  945. sock = socket.socket(socket.AF_INET, socket.SOCK_STREAM)
  946. try:
  947. sock.setsockopt(socket.IPPROTO_TCP, socket.TCP_NODELAY, 1)
  948. except OSError:
  949. pass
  950. sock.settimeout(max(0.2, DATAFLEX_STATUS_QUERY_TIMEOUT_SEC))
  951. try:
  952. sock.connect((ip, port))
  953. sock.sendall(b"~HS\r\n")
  954. chunks: list[bytes] = []
  955. while True:
  956. try:
  957. data = sock.recv(4096)
  958. except socket.timeout:
  959. break
  960. except OSError as ex:
  961. if _dataflex_is_benign_tcp_reset(ex):
  962. break
  963. raise
  964. if not data:
  965. break
  966. chunks.append(data)
  967. if sum(len(c) for c in chunks) >= 16384:
  968. break
  969. return b"".join(chunks).decode("utf-8", errors="ignore")
  970. finally:
  971. sock.close()
  972. def _dataflex_status_has_e1005(status_text: str) -> bool:
  973. s = (status_text or "").lower()
  974. return "e1005" in s or "1005" in s
  975. def _dataflex_status_problem_code(status_text: str) -> Optional[str]:
  976. """If host status (~HS) suggests a fault, return a short code like E1000; else None."""
  977. s = (status_text or "").lower()
  978. for code in ("e1000", "e1005", "e1004", "e1003", "e1002", "e1001"):
  979. if code in s:
  980. return code.upper()
  981. return None
  982. def assert_dataflex_host_ok(ip: str, port: int) -> None:
  983. """
  984. Query ~HS once. If printer reports a known fault token, stop the job early.
  985. Empty/short replies are ignored (some firmware is quiet).
  986. """
  987. st = query_dataflex_host_status(ip, port)
  988. if not (st or "").strip():
  989. return
  990. prob = _dataflex_status_problem_code(st)
  991. if prob is not None:
  992. raise RuntimeError(
  993. f"打袋機狀態異常 {prob}(~HS)。請看機台畫面處理後再印。"
  994. )
  995. def recover_dataflex_if_host_fault(ip: str, port: int) -> None:
  996. """
  997. If ~HS reports E1000/E1005/etc., clear host state once and continue — do not abort the whole run.
  998. Keeps work short so the print thread does not look "frozen".
  999. """
  1000. st = query_dataflex_host_status(ip, port)
  1001. if not (st or "").strip():
  1002. return
  1003. if _dataflex_status_problem_code(st) is None:
  1004. return
  1005. touch_dataflex_recovery_grace(14.0)
  1006. send_dataflex_job_counter_reset(ip, port, force=True)
  1007. send_dataflex_preprint_reset(ip, port, force=True)
  1008. time.sleep(max(0.35, DATAFLEX_RECOVERY_WAIT_SEC))
  1009. def send_dataflex_label_with_recovery(ip: str, port: int, zpl: str) -> None:
  1010. """
  1011. Send one bag label with one automatic recovery attempt.
  1012. If first send fails (including firmware-latched states such as E1005),
  1013. perform full recovery (~JA/~RO/~JR), then light preprint reset (~JA/~RO),
  1014. and retry once.
  1015. """
  1016. last_err: Optional[Exception] = None
  1017. for attempt in range(DATAFLEX_RECOVERY_MAX_ATTEMPTS):
  1018. try:
  1019. if DATAFLEX_AUTO_RESET_ENABLED and DATAFLEX_PREPRINT_EACH_LABEL:
  1020. send_dataflex_preprint_reset(ip, port)
  1021. send_zpl_to_dataflex(ip, port, zpl)
  1022. if DATAFLEX_VERIFY_STATUS_AFTER_SEND:
  1023. status_text = query_dataflex_host_status(ip, port)
  1024. if _dataflex_status_has_e1005(status_text):
  1025. raise RuntimeError("DataFlex E1005 detected from host status.")
  1026. return
  1027. except (ConnectionRefusedError, socket.timeout, OSError, RuntimeError) as ex:
  1028. last_err = ex
  1029. if attempt >= DATAFLEX_RECOVERY_MAX_ATTEMPTS - 1:
  1030. break
  1031. if DATAFLEX_AUTO_RESET_ENABLED or DATAFLEX_RECOVER_ON_SEND_ERROR:
  1032. touch_dataflex_recovery_grace(14.0)
  1033. send_dataflex_job_counter_reset(ip, port, force=True)
  1034. send_dataflex_preprint_reset(ip, port, force=True)
  1035. time.sleep(max(0.35, DATAFLEX_RECOVERY_WAIT_SEC))
  1036. if last_err is not None:
  1037. raise last_err
  1038. raise RuntimeError("DataFlex label send failed.")
  1039. def send_zpl_to_label_printer(target: str, zpl: str) -> None:
  1040. """
  1041. Send ZPL to 標簽機.
  1042. On Windows, if target is not a COM port (e.g. "TSC TTP-246M Pro"),
  1043. send raw ZPL to the named Windows printer via the spooler.
  1044. Otherwise, treat target as a serial COM port (original behaviour).
  1045. """
  1046. dest = (target or "").strip()
  1047. if not dest:
  1048. raise ValueError("Label printer destination is empty.")
  1049. # Unicode (^CI28); send UTF-8 to 標簽機
  1050. raw_bytes = zpl.encode("utf-8")
  1051. # Windows printer name path (USB printer installed as normal printer)
  1052. if os.name == "nt" and not dest.upper().startswith("COM"):
  1053. if win32print is None:
  1054. raise RuntimeError("pywin32 not installed. Run: pip install pywin32")
  1055. handle = win32print.OpenPrinter(dest)
  1056. try:
  1057. job = win32print.StartDocPrinter(handle, 1, ("FPSMS Label", None, "RAW"))
  1058. win32print.StartPagePrinter(handle)
  1059. win32print.WritePrinter(handle, raw_bytes)
  1060. win32print.EndPagePrinter(handle)
  1061. win32print.EndDocPrinter(handle)
  1062. finally:
  1063. win32print.ClosePrinter(handle)
  1064. return
  1065. # Fallback: serial COM port
  1066. if serial is None:
  1067. raise RuntimeError("pyserial not installed. Run: pip install pyserial")
  1068. ser = serial.Serial(dest, timeout=5)
  1069. try:
  1070. ser.write(raw_bytes)
  1071. finally:
  1072. ser.close()
  1073. def send_zpl_to_label_printer_batch(target: str, zpl: str, copies: int) -> None:
  1074. """
  1075. Print multiple identical ZPL labels in **exactly one** Windows spool job (or one COM write).
  1076. Uses ZPL ^PQ so the printer firmware repeats the format N times — never one job per label.
  1077. """
  1078. if copies < 1:
  1079. return
  1080. zpl_out = zpl_apply_print_quantity(zpl, copies)
  1081. send_zpl_to_label_printer(target, zpl_out)
  1082. def load_laser_last_count() -> tuple[int, Optional[str]]:
  1083. """Load last batch count and date from laser counter file. Returns (count, date_str)."""
  1084. if not os.path.exists(LASER_COUNTER_FILE):
  1085. return 0, None
  1086. try:
  1087. with open(LASER_COUNTER_FILE, "r", encoding="utf-8") as f:
  1088. lines = f.read().strip().splitlines()
  1089. if len(lines) >= 2:
  1090. return int(lines[1].strip()), lines[0].strip()
  1091. except Exception:
  1092. pass
  1093. return 0, None
  1094. def save_laser_last_count(date_str: str, count: int) -> None:
  1095. """Save laser batch count and date to file."""
  1096. try:
  1097. with open(LASER_COUNTER_FILE, "w", encoding="utf-8") as f:
  1098. f.write(f"{date_str}\n{count}")
  1099. except Exception:
  1100. pass
  1101. LASER_PUSH_INTERVAL = 2 # seconds between pushes (like sample script)
  1102. # Click row with 激光機 selected: send this many times, delay between sends (not after last).
  1103. LASER_ROW_SEND_COUNT = 3
  1104. LASER_ROW_SEND_DELAY_SEC = 3
  1105. def laser_push_loop(
  1106. ip: str,
  1107. port: int,
  1108. stop_event: threading.Event,
  1109. root: tk.Tk,
  1110. on_error: Callable[[str], None],
  1111. ) -> None:
  1112. """
  1113. Run in a background thread: persistent connection to EZCAD, push B{yymmdd}{count:03d};;
  1114. every LASER_PUSH_INTERVAL seconds. Resets count each new day. Uses counter file.
  1115. """
  1116. conn = None
  1117. push_count, last_saved_date = load_laser_last_count()
  1118. while not stop_event.is_set():
  1119. try:
  1120. if conn is None:
  1121. conn = socket.socket(socket.AF_INET, socket.SOCK_STREAM)
  1122. conn.settimeout(0.4)
  1123. conn.connect((ip, port))
  1124. now = datetime.now()
  1125. today_str = now.strftime("%y%m%d")
  1126. if last_saved_date != today_str:
  1127. push_count = 1
  1128. last_saved_date = today_str
  1129. batch = f"B{today_str}{push_count:03d}"
  1130. reply = f"{batch};;"
  1131. conn.sendall(reply.encode("utf-8"))
  1132. save_laser_last_count(today_str, push_count)
  1133. rlist, _, _ = select.select([conn], [], [], 0.4)
  1134. if rlist:
  1135. data = conn.recv(4096)
  1136. if not data:
  1137. conn.close()
  1138. conn = None
  1139. push_count += 1
  1140. for _ in range(int(LASER_PUSH_INTERVAL * 2)):
  1141. if stop_event.is_set():
  1142. break
  1143. time.sleep(0.5)
  1144. except socket.timeout:
  1145. pass
  1146. except Exception as e:
  1147. if conn:
  1148. try:
  1149. conn.close()
  1150. except Exception:
  1151. pass
  1152. conn = None
  1153. try:
  1154. root.after(0, lambda msg=str(e): on_error(msg))
  1155. except Exception:
  1156. pass
  1157. for _ in range(6):
  1158. if stop_event.is_set():
  1159. break
  1160. time.sleep(0.5)
  1161. if conn:
  1162. try:
  1163. conn.close()
  1164. except Exception:
  1165. pass
  1166. def send_job_to_laser(
  1167. conn_ref: list,
  1168. ip: str,
  1169. port: int,
  1170. item_id: Optional[int],
  1171. stock_in_line_id: Optional[int],
  1172. item_code: str,
  1173. item_name: str,
  1174. expiry_text: Optional[str] = None,
  1175. ) -> tuple[bool, str]:
  1176. """
  1177. Send to laser using `;` separated params:
  1178. {"itemId": itemId, "stockInLineId": stockInLineId} ; itemCode ; itemName ; [Expiry Date yyyyMMdd] ;;
  1179. conn_ref: [socket or None] - reused across calls; closed only when switching printer.
  1180. When both item_id and stock_in_line_id present, sends JSON first param; else fallback: 0;item_code;item_name;;
  1181. Expiry is the 4th field when present (same wording as DataFlex). The EZCAD job must bind it.
  1182. Returns (success, message).
  1183. """
  1184. code_str = (item_code or "").strip().replace(";", ",")
  1185. name_str = (item_name or "").strip().replace(";", ",")
  1186. exp_str = standardize_expiry_print_label(expiry_text).replace(";", ",")
  1187. if item_id is not None and stock_in_line_id is not None:
  1188. # Use compact JSON so device-side parser doesn't get spaces.
  1189. json_part = json.dumps(
  1190. {"itemId": item_id, "stockInLineId": stock_in_line_id},
  1191. separators=(",", ":"),
  1192. )
  1193. head = json_part
  1194. else:
  1195. head = "0"
  1196. if exp_str:
  1197. reply = f"{head};{code_str};{name_str};{exp_str};;"
  1198. else:
  1199. reply = f"{head};{code_str};{name_str};;"
  1200. conn = conn_ref[0]
  1201. try:
  1202. if conn is None:
  1203. conn = socket.socket(socket.AF_INET, socket.SOCK_STREAM)
  1204. conn.settimeout(3.0)
  1205. conn.connect((ip, port))
  1206. conn_ref[0] = conn
  1207. conn.settimeout(3.0)
  1208. conn.sendall(reply.encode("utf-8"))
  1209. conn.settimeout(0.5)
  1210. try:
  1211. data = conn.recv(4096)
  1212. if data:
  1213. ack = data.decode("utf-8", errors="ignore").strip().lower()
  1214. if "receive" in ack and "invalid" not in ack:
  1215. return True, f"已送出激光機:{reply}(已確認)"
  1216. except socket.timeout:
  1217. pass
  1218. return True, f"已送出激光機:{reply}"
  1219. except (ConnectionRefusedError, socket.timeout, OSError) as e:
  1220. if conn_ref[0] is not None:
  1221. try:
  1222. conn_ref[0].close()
  1223. except Exception:
  1224. pass
  1225. conn_ref[0] = None
  1226. if isinstance(e, ConnectionRefusedError):
  1227. return False, f"無法連線至 {ip}:{port},請確認激光機已開機且 IP 正確。"
  1228. if isinstance(e, socket.timeout):
  1229. return False, f"連線逾時 ({ip}:{port}),請檢查網路與連接埠。"
  1230. return False, f"激光機送出失敗:{e}"
  1231. def send_job_to_laser_with_retry(
  1232. conn_ref: list,
  1233. ip: str,
  1234. port: int,
  1235. item_id: Optional[int],
  1236. stock_in_line_id: Optional[int],
  1237. item_code: str,
  1238. item_name: str,
  1239. expiry_text: Optional[str] = None,
  1240. ) -> tuple[bool, str]:
  1241. """Send job to laser; on failure, retry once. Returns (success, message)."""
  1242. ok, msg = send_job_to_laser(
  1243. conn_ref, ip, port, item_id, stock_in_line_id, item_code, item_name, expiry_text,
  1244. )
  1245. if ok:
  1246. return True, msg
  1247. ok2, msg2 = send_job_to_laser(
  1248. conn_ref, ip, port, item_id, stock_in_line_id, item_code, item_name, expiry_text,
  1249. )
  1250. return ok2, msg2
  1251. def run_laser_row_send_thread(
  1252. root: tk.Tk,
  1253. laser_conn_ref: list,
  1254. laser_busy_ref: list,
  1255. ip: str,
  1256. port: int,
  1257. item_id: Optional[int],
  1258. stock_in_line_id: Optional[int],
  1259. item_code: str,
  1260. item_name: str,
  1261. set_status_message: Callable[[str, bool], None],
  1262. base_url: Optional[str] = None,
  1263. job_order_id: Optional[int] = None,
  1264. on_recorded: Optional[Callable[[], None]] = None,
  1265. expiry_text: Optional[str] = None,
  1266. ) -> None:
  1267. """
  1268. On row click with 激光機: send LASER_ROW_SEND_COUNT times with LASER_ROW_SEND_DELAY_SEC between sends.
  1269. UI updates on main thread; work runs in background so the window does not freeze.
  1270. After success, POST LASER qty to API when job_order_id and base_url are set.
  1271. """
  1272. if laser_busy_ref[0]:
  1273. messagebox.showwarning("激光機", "請等待目前激光發送完成。")
  1274. return
  1275. laser_busy_ref[0] = True
  1276. def worker() -> None:
  1277. try:
  1278. n = LASER_ROW_SEND_COUNT
  1279. for i in range(n):
  1280. ok, msg = send_job_to_laser_with_retry(
  1281. laser_conn_ref,
  1282. ip,
  1283. port,
  1284. item_id,
  1285. stock_in_line_id,
  1286. item_code,
  1287. item_name,
  1288. expiry_text,
  1289. )
  1290. if not ok:
  1291. root.after(
  1292. 0,
  1293. lambda m=msg: messagebox.showwarning("激光機", m),
  1294. )
  1295. return
  1296. if i < n - 1:
  1297. time.sleep(LASER_ROW_SEND_DELAY_SEC)
  1298. posted = False
  1299. if base_url and job_order_id is not None:
  1300. try:
  1301. submit_job_order_print_submit(base_url, int(job_order_id), n, "LASER")
  1302. posted = True
  1303. except requests.RequestException as ex:
  1304. root.after(
  1305. 0,
  1306. lambda err=str(ex): messagebox.showwarning(
  1307. "激光機",
  1308. f"已發送,但伺服器記錄失敗:{err}",
  1309. ),
  1310. )
  1311. elif base_url:
  1312. root.after(
  1313. 0,
  1314. lambda: messagebox.showwarning(
  1315. "激光機",
  1316. "已發送,但無工單 id,無法寫入伺服器記錄。",
  1317. ),
  1318. )
  1319. root.after(
  1320. 0,
  1321. lambda: set_status_message("已發送", is_error=False),
  1322. )
  1323. if on_recorded is not None and posted:
  1324. root.after(0, on_recorded)
  1325. except Exception as e:
  1326. root.after(
  1327. 0,
  1328. lambda err=str(e): messagebox.showwarning("激光機", f"送出失敗:{err}"),
  1329. )
  1330. finally:
  1331. laser_busy_ref[0] = False
  1332. threading.Thread(target=worker, daemon=True).start()
  1333. def run_dataflex_fixed_qty_thread(
  1334. root: tk.Tk,
  1335. dataflex_lock: threading.Lock,
  1336. dataflex_busy_ref: list,
  1337. ip: str,
  1338. port: int,
  1339. n: int,
  1340. zpl: str,
  1341. label_text: str,
  1342. jo_id: Optional[int],
  1343. base_url: str,
  1344. set_status_message: Callable[[str, bool], None],
  1345. on_recorded: Callable[[], None],
  1346. ) -> None:
  1347. """
  1348. Send n DataFlex labels with delay between copies. Runs off the Tk main thread so the UI
  1349. stays responsive (printer dropdown, other controls) during printing.
  1350. """
  1351. def worker() -> None:
  1352. with dataflex_lock:
  1353. if dataflex_busy_ref[0]:
  1354. root.after(
  1355. 0,
  1356. lambda: messagebox.showwarning(
  1357. "打袋機",
  1358. "請等待目前列印完成或先停止連續列印。",
  1359. ),
  1360. )
  1361. return
  1362. dataflex_busy_ref[0] = True
  1363. printed = 0
  1364. used_single_tcp = False
  1365. try:
  1366. send_dataflex_start_job_reset(ip, port, force=True)
  1367. if DATAFLEX_SINGLE_TCP_JOB and n >= 1:
  1368. # One TCP connection, one ZPL, ^PQn — printer firmware prints n identical bags.
  1369. used_single_tcp = True
  1370. zpl_one = dataflex_zpl_set_print_quantity(zpl, n)
  1371. root.after(
  1372. 0,
  1373. lambda tn=n: set_status_message(
  1374. f"打袋單次發送中… {tn} 張(^PQ{tn})",
  1375. is_error=False,
  1376. ),
  1377. )
  1378. send_dataflex_label_with_recovery(ip, port, zpl_one)
  1379. if DATAFLEX_VERIFY_EVERY_LABELS > 0:
  1380. recover_dataflex_if_host_fault(ip, port)
  1381. printed = n
  1382. else:
  1383. # One TCP job per bag. Slower but avoids E1005 on some units when ^PQ is large.
  1384. for i in range(n):
  1385. send_dataflex_label_with_recovery(ip, port, zpl)
  1386. printed += 1
  1387. if DATAFLEX_UI_PROGRESS_EVERY > 0 and (
  1388. printed == 1 or printed % DATAFLEX_UI_PROGRESS_EVERY == 0
  1389. ):
  1390. p, t = printed, n
  1391. root.after(
  1392. 0,
  1393. lambda p=p, t=t: set_status_message(
  1394. f"打袋列印中… {p}/{t}",
  1395. is_error=False,
  1396. ),
  1397. )
  1398. if (
  1399. DATAFLEX_VERIFY_EVERY_LABELS > 0
  1400. and printed % DATAFLEX_VERIFY_EVERY_LABELS == 0
  1401. ):
  1402. recover_dataflex_if_host_fault(ip, port)
  1403. if (
  1404. DATAFLEX_COOLDOWN_EVERY_LABELS > 0
  1405. and printed % DATAFLEX_COOLDOWN_EVERY_LABELS == 0
  1406. and i < n - 1
  1407. ):
  1408. time.sleep(max(0.0, DATAFLEX_COOLDOWN_SEC))
  1409. if (
  1410. DATAFLEX_THERMAL_REST_EVERY_LABELS > 0
  1411. and printed % DATAFLEX_THERMAL_REST_EVERY_LABELS == 0
  1412. and i < n - 1
  1413. ):
  1414. time.sleep(max(0.0, DATAFLEX_THERMAL_REST_SEC))
  1415. if i < n - 1:
  1416. time.sleep(DATAFLEX_INTER_LABEL_DELAY_SEC)
  1417. root.after(
  1418. 0,
  1419. lambda u=used_single_tcp: set_status_message(
  1420. (
  1421. f"已送出列印(單次 TCP):批次 {label_text} x {n} 張"
  1422. if u
  1423. else f"已送出列印:批次 {label_text} x {n} 張"
  1424. ),
  1425. is_error=False,
  1426. ),
  1427. )
  1428. if jo_id is not None:
  1429. try:
  1430. submit_job_order_print_submit(base_url, int(jo_id), n, "DATAFLEX")
  1431. root.after(0, on_recorded)
  1432. except requests.RequestException as ex:
  1433. root.after(
  1434. 0,
  1435. lambda err=str(ex): messagebox.showwarning(
  1436. "打袋機",
  1437. f"列印可能已完成,但伺服器記錄失敗(可再試):{err}",
  1438. ),
  1439. )
  1440. else:
  1441. root.after(
  1442. 0,
  1443. lambda: messagebox.showwarning(
  1444. "打袋機",
  1445. f"已送出列印 {n} 張,但無工單 id,無法寫入伺服器記錄。",
  1446. ),
  1447. )
  1448. except ConnectionRefusedError:
  1449. root.after(
  1450. 0,
  1451. lambda: set_status_message(
  1452. f"無法連線至 {ip}:{port},已送出 {printed}/{n} 張。",
  1453. is_error=True,
  1454. ),
  1455. )
  1456. except socket.timeout:
  1457. root.after(
  1458. 0,
  1459. lambda: set_status_message(
  1460. f"連線逾時 ({ip}:{port}),已送出 {printed}/{n} 張。",
  1461. is_error=True,
  1462. ),
  1463. )
  1464. except OSError as err:
  1465. root.after(
  1466. 0,
  1467. lambda e=err: set_status_message(
  1468. f"列印失敗:{e}(已送出 {printed}/{n} 張)",
  1469. is_error=True,
  1470. ),
  1471. )
  1472. except RuntimeError as err:
  1473. root.after(
  1474. 0,
  1475. lambda e=err: set_status_message(
  1476. f"打袋機錯誤:{e}(已送出 {printed}/{n} 張)",
  1477. is_error=True,
  1478. ),
  1479. )
  1480. except Exception as err:
  1481. root.after(
  1482. 0,
  1483. lambda e=err: set_status_message(
  1484. f"打袋機例外:{e}(已送出 {printed}/{n} 張)",
  1485. is_error=True,
  1486. ),
  1487. )
  1488. finally:
  1489. with dataflex_lock:
  1490. dataflex_busy_ref[0] = False
  1491. threading.Thread(target=worker, daemon=True).start()
  1492. def run_label_print_batch_thread(
  1493. root: tk.Tk,
  1494. label_lock: threading.Lock,
  1495. label_busy_ref: list,
  1496. com: str,
  1497. zpl_img: str,
  1498. n: int,
  1499. jo_id: Optional[int],
  1500. base_url: str,
  1501. set_status_message: Callable[[str, bool], None],
  1502. on_recorded: Callable[[], None],
  1503. ) -> None:
  1504. """
  1505. Send n label copies off the main thread so DataFlex / laser / UI stay usable in parallel.
  1506. """
  1507. def worker() -> None:
  1508. with label_lock:
  1509. if label_busy_ref[0]:
  1510. root.after(
  1511. 0,
  1512. lambda: messagebox.showwarning(
  1513. "標籤機",
  1514. "請等待目前標籤列印完成。",
  1515. ),
  1516. )
  1517. return
  1518. label_busy_ref[0] = True
  1519. try:
  1520. send_zpl_to_label_printer_batch(com, zpl_img, n)
  1521. root.after(
  1522. 0,
  1523. lambda: set_status_message(f"已送出列印:標籤 x {n} 張", is_error=False),
  1524. )
  1525. if jo_id is not None:
  1526. try:
  1527. submit_job_order_print_submit(base_url, int(jo_id), n, "LABEL")
  1528. root.after(0, on_recorded)
  1529. root.after(
  1530. 0,
  1531. lambda: messagebox.showinfo(
  1532. "標籤機",
  1533. f"已送出列印:{n} 張標籤(已記錄)",
  1534. ),
  1535. )
  1536. except requests.RequestException as ex:
  1537. root.after(
  1538. 0,
  1539. lambda err=str(ex): messagebox.showwarning(
  1540. "標籤機",
  1541. f"標籤已列印 {n} 張,但伺服器記錄失敗:{err}",
  1542. ),
  1543. )
  1544. else:
  1545. root.after(
  1546. 0,
  1547. lambda: messagebox.showwarning(
  1548. "標籤機",
  1549. f"已送出列印:{n} 張標籤(無工單 id,無法寫入伺服器記錄)",
  1550. ),
  1551. )
  1552. except Exception as err:
  1553. root.after(
  1554. 0,
  1555. lambda e=str(err): messagebox.showerror("標籤機", f"列印失敗:{e}"),
  1556. )
  1557. finally:
  1558. with label_lock:
  1559. label_busy_ref[0] = False
  1560. threading.Thread(target=worker, daemon=True).start()
  1561. def _printed_qty_int(raw) -> int:
  1562. """Parse API printed qty field (may be float JSON) to int."""
  1563. try:
  1564. return int(float(raw)) if raw is not None else 0
  1565. except (TypeError, ValueError):
  1566. return 0
  1567. def _filter_job_orders_by_search(data: list, needle: str) -> list:
  1568. """Substring match on item code, job order code, item name, lot (case-insensitive)."""
  1569. n = needle.strip().lower()
  1570. if not n:
  1571. return data
  1572. out: list = []
  1573. for jo in data:
  1574. parts = [
  1575. str(jo.get("itemCode") or ""),
  1576. str(jo.get("code") or ""),
  1577. str(jo.get("itemName") or ""),
  1578. str(jo.get("lotNo") or ""),
  1579. ]
  1580. if any(n in p.lower() for p in parts):
  1581. out.append(jo)
  1582. return out
  1583. def format_qty(val) -> str:
  1584. """Format quantity: integer without .0, with thousand separator."""
  1585. if val is None:
  1586. return "—"
  1587. try:
  1588. n = float(val)
  1589. if n == int(n):
  1590. return f"{int(n):,}"
  1591. return f"{n:,.2f}".rstrip("0").rstrip(".")
  1592. except (TypeError, ValueError):
  1593. return str(val)
  1594. def batch_no(year: int, job_order_id: int) -> str:
  1595. """Batch no.: B + 4-digit year + jobOrderId zero-padded to 6 digits."""
  1596. return f"B{year}{job_order_id:06d}"
  1597. def get_font(size: int = FONT_SIZE, bold: bool = False) -> tuple:
  1598. try:
  1599. return (FONT_FAMILY, size, "bold" if bold else "normal")
  1600. except Exception:
  1601. return ("TkDefaultFont", size, "bold" if bold else "normal")
  1602. def fetch_job_orders(base_url: str, plan_start: date) -> list:
  1603. """Call GET /py/job-orders and return the JSON list."""
  1604. url = f"{base_url.rstrip('/')}/py/job-orders"
  1605. params = {"planStart": plan_start.isoformat()}
  1606. resp = requests.get(url, params=params, timeout=30)
  1607. resp.raise_for_status()
  1608. return resp.json()
  1609. def submit_job_order_print_submit(
  1610. base_url: str,
  1611. job_order_id: int,
  1612. qty: int,
  1613. print_channel: str = "LABEL",
  1614. ) -> None:
  1615. """
  1616. Record printed quantity in the FPSMS database via PyController.
  1617. POST ``/api/py/job-order-print-submit`` (path under base_url) — **public endpoint, no login**
  1618. or API key required. Each successful call appends one row to ``py_job_order_print_submit``;
  1619. totals per job order and channel are aggregated server-side.
  1620. Raises ``requests.RequestException`` if all retry attempts fail.
  1621. """
  1622. url = f"{base_url.rstrip('/')}/py/job-order-print-submit"
  1623. payload = {
  1624. "jobOrderId": int(job_order_id),
  1625. "qty": int(qty),
  1626. "printChannel": print_channel,
  1627. }
  1628. last_err: Optional[Exception] = None
  1629. for attempt in range(PRINT_SUBMIT_MAX_ATTEMPTS):
  1630. try:
  1631. resp = requests.post(url, json=payload, timeout=30)
  1632. resp.raise_for_status()
  1633. return
  1634. except requests.RequestException as ex:
  1635. last_err = ex
  1636. if attempt < PRINT_SUBMIT_MAX_ATTEMPTS - 1:
  1637. time.sleep(PRINT_SUBMIT_RETRY_DELAY_SEC)
  1638. if last_err is not None:
  1639. raise last_err
  1640. raise RuntimeError("submit_job_order_print_submit: unexpected empty error")
  1641. def set_row_highlight(row_frame: tk.Frame, selected: bool) -> None:
  1642. """Set row and all nested Frame/Label children to selected or normal background."""
  1643. bg = BG_ROW_SELECTED if selected else BG_ROW
  1644. def _paint(w: tk.Misc) -> None:
  1645. if isinstance(w, (tk.Frame, tk.Label)):
  1646. w.configure(bg=bg)
  1647. for c in w.winfo_children():
  1648. _paint(c)
  1649. _paint(row_frame)
  1650. def on_job_order_click(jo: dict, batch: str) -> None:
  1651. """Show message and highlight row (keeps printing to selected printer)."""
  1652. item_code = jo.get("itemCode") or "—"
  1653. item_name = jo.get("itemName") or "—"
  1654. messagebox.showinfo(
  1655. "工單",
  1656. f'已點選:批次 {batch}\n品號 {item_code} {item_name}',
  1657. )
  1658. def ask_label_count(parent: tk.Tk) -> Optional[int]:
  1659. """
  1660. When printer is 標簽機, ask how many labels to print:
  1661. optional direct qty in text field (e.g. 150), +50/+10/+5/+1, 重置, then 確認送出.
  1662. That count becomes ZPL ^PQ in one job — 150 → 150 identical labels.
  1663. Returns count (>= 1), or None if cancelled.
  1664. """
  1665. result: list[Optional[int]] = [None]
  1666. qty_var = tk.StringVar(value="0")
  1667. win = tk.Toplevel(parent)
  1668. win.title("標簽印數")
  1669. win.geometry("580x280")
  1670. win.transient(parent)
  1671. win.grab_set()
  1672. win.configure(bg=BG_TOP)
  1673. ttk.Label(win, text="印多少個?", font=get_font(FONT_SIZE)).pack(pady=(12, 4))
  1674. entry_row = tk.Frame(win, bg=BG_TOP)
  1675. entry_row.pack(pady=8)
  1676. tk.Label(entry_row, text="需求數量:", font=get_font(FONT_SIZE), bg=BG_TOP).pack(side=tk.LEFT, padx=(0, 6))
  1677. qty_entry = tk.Entry(
  1678. entry_row,
  1679. textvariable=qty_var,
  1680. width=12,
  1681. font=get_font(FONT_SIZE),
  1682. bg="white",
  1683. justify=tk.RIGHT,
  1684. )
  1685. qty_entry.pack(side=tk.LEFT, padx=4)
  1686. def current_qty() -> int:
  1687. s = (qty_var.get() or "").strip().replace(",", "")
  1688. if not s:
  1689. return 0
  1690. try:
  1691. return max(0, int(s))
  1692. except ValueError:
  1693. return 0
  1694. def reset_qty() -> None:
  1695. qty_var.set("0")
  1696. ttk.Button(entry_row, text="重置", command=reset_qty, width=8).pack(side=tk.LEFT, padx=8)
  1697. def add(n: int) -> None:
  1698. qty_var.set(str(current_qty() + n))
  1699. def confirm() -> None:
  1700. q = current_qty()
  1701. if q < 1:
  1702. messagebox.showwarning("標簽機", "請輸入需求數量或按 +50、+10、+5、+1。", parent=win)
  1703. return
  1704. result[0] = q
  1705. win.destroy()
  1706. btn_row1 = tk.Frame(win, bg=BG_TOP)
  1707. btn_row1.pack(pady=8)
  1708. for label, value in [("+50", 50), ("+10", 10), ("+5", 5), ("+1", 1)]:
  1709. def make_add(v: int):
  1710. return lambda: add(v)
  1711. ttk.Button(btn_row1, text=label, command=make_add(value), width=8).pack(side=tk.LEFT, padx=4)
  1712. ttk.Button(win, text="確認送出", command=confirm, width=14).pack(pady=12)
  1713. qty_entry.bind("<Return>", lambda e: confirm())
  1714. win.protocol("WM_DELETE_WINDOW", win.destroy)
  1715. win.wait_window()
  1716. return result[0]
  1717. def ask_bag_count(parent: tk.Tk) -> Optional[Tuple[int, bool]]:
  1718. """
  1719. When printer is 打袋機 DataFlex: qty with +按鈕 then 確認送出, or big bottom「C」for continuous.
  1720. Returns (count, continuous_print). If continuous_print is True, count is ignored (use 0).
  1721. None if cancelled.
  1722. """
  1723. result: list[Optional[Tuple[int, bool]]] = [None]
  1724. qty_var = tk.StringVar(value="0")
  1725. win = tk.Toplevel(parent)
  1726. win.title("打袋列印數量")
  1727. win.geometry("580x420")
  1728. win.transient(parent)
  1729. win.grab_set()
  1730. win.configure(bg=BG_TOP)
  1731. ttk.Label(win, text="列印多少個袋?", font=get_font(FONT_SIZE)).pack(pady=(12, 4))
  1732. entry_row = tk.Frame(win, bg=BG_TOP)
  1733. entry_row.pack(pady=8)
  1734. tk.Label(entry_row, text="需求數量:", font=get_font(FONT_SIZE), bg=BG_TOP).pack(side=tk.LEFT, padx=(0, 6))
  1735. qty_entry = tk.Entry(
  1736. entry_row,
  1737. textvariable=qty_var,
  1738. width=12,
  1739. font=get_font(FONT_SIZE),
  1740. bg="white",
  1741. justify=tk.RIGHT,
  1742. )
  1743. qty_entry.pack(side=tk.LEFT, padx=4)
  1744. def current_qty() -> int:
  1745. s = (qty_var.get() or "").strip().replace(",", "")
  1746. if not s:
  1747. return 0
  1748. try:
  1749. return max(0, int(s))
  1750. except ValueError:
  1751. return 0
  1752. def reset_qty() -> None:
  1753. qty_var.set("0")
  1754. ttk.Button(entry_row, text="重置", command=reset_qty, width=8).pack(side=tk.LEFT, padx=8)
  1755. def add(n: int) -> None:
  1756. qty_var.set(str(current_qty() + n))
  1757. def confirm() -> None:
  1758. q = current_qty()
  1759. if q < 1:
  1760. messagebox.showwarning("打袋機", "請輸入需求數量或按 +50、+10、+5、+1。", parent=win)
  1761. return
  1762. result[0] = (q, False)
  1763. win.destroy()
  1764. def start_continuous() -> None:
  1765. """Big C: continuous print until 停止; counter reset at job start."""
  1766. result[0] = (0, True)
  1767. win.destroy()
  1768. btn_row1 = tk.Frame(win, bg=BG_TOP)
  1769. btn_row1.pack(pady=8)
  1770. for label, value in [("+50", 50), ("+10", 10), ("+5", 5), ("+1", 1)]:
  1771. def make_add(v: int):
  1772. return lambda: add(v)
  1773. ttk.Button(btn_row1, text=label, command=make_add(value), width=8).pack(side=tk.LEFT, padx=4)
  1774. ttk.Button(win, text="確認送出", command=confirm, width=14).pack(pady=12)
  1775. qty_entry.bind("<Return>", lambda e: confirm())
  1776. sep = ttk.Separator(win, orient=tk.HORIZONTAL)
  1777. sep.pack(fill=tk.X, padx=16, pady=(4, 8))
  1778. bottom = tk.Frame(win, bg=BG_TOP)
  1779. bottom.pack(fill=tk.X, padx=12, pady=(0, 12))
  1780. tk.Label(
  1781. bottom,
  1782. text="連續出袋 · 每單開始重設計數 · 另開視窗按「停止列印」結束",
  1783. font=get_font(FONT_SIZE_META),
  1784. bg=BG_TOP,
  1785. fg="#333333",
  1786. wraplength=540,
  1787. justify=tk.CENTER,
  1788. ).pack(fill=tk.X, pady=(0, 6))
  1789. tk.Button(
  1790. bottom,
  1791. text="C(連續印)",
  1792. command=start_continuous,
  1793. font=(FONT_FAMILY, 38, "bold"),
  1794. bg="#2E7D32",
  1795. fg="white",
  1796. activebackground="#1B5E20",
  1797. activeforeground="white",
  1798. relief=tk.RAISED,
  1799. bd=4,
  1800. cursor="hand2",
  1801. padx=24,
  1802. pady=18,
  1803. ).pack(fill=tk.X)
  1804. win.protocol("WM_DELETE_WINDOW", win.destroy)
  1805. win.wait_window()
  1806. return result[0]
  1807. @dataclass(frozen=True)
  1808. class DataflexPrintSession:
  1809. """
  1810. Snapshot taken when the user starts DataFlex print (especially C 連續印).
  1811. The worker must use only this object — not grid row index, scroll position, or selection.
  1812. """
  1813. job_order_id: Optional[int]
  1814. job_code: str
  1815. item_code: str
  1816. item_name: str
  1817. label_text: str
  1818. zpl: str
  1819. printer_ip: str
  1820. printer_port: int
  1821. batch_display: str
  1822. def build_dataflex_print_session(
  1823. jo: dict,
  1824. batch: str,
  1825. zpl: str,
  1826. label_text: str,
  1827. printer_ip: str,
  1828. printer_port: int,
  1829. ) -> DataflexPrintSession:
  1830. jo_id = jo.get("id")
  1831. jo_code = (jo.get("code") or "").strip()
  1832. if not jo_code and jo_id is not None:
  1833. jo_code = f"#{jo_id}"
  1834. elif not jo_code:
  1835. jo_code = "—"
  1836. return DataflexPrintSession(
  1837. job_order_id=int(jo_id) if jo_id is not None else None,
  1838. job_code=jo_code,
  1839. item_code=(jo.get("itemCode") or "—").strip(),
  1840. item_name=(jo.get("itemName") or "—").strip(),
  1841. label_text=label_text,
  1842. zpl=zpl,
  1843. printer_ip=printer_ip,
  1844. printer_port=printer_port,
  1845. batch_display=(batch or "—").strip(),
  1846. )
  1847. def run_dataflex_continuous_thread(
  1848. root: tk.Tk,
  1849. session: DataflexPrintSession,
  1850. stop_event: threading.Event,
  1851. stop_win: tk.Toplevel,
  1852. dataflex_lock: threading.Lock,
  1853. dataflex_busy_ref: list,
  1854. dataflex_stop_win_ref: list,
  1855. active_session_ref: list,
  1856. base_url: str,
  1857. set_status_message: Callable[[str, bool], None],
  1858. on_recorded: Callable[[], None],
  1859. ) -> None:
  1860. """Send bags in a loop until stop_event; all payload comes from session (in-memory snapshot)."""
  1861. def worker() -> None:
  1862. with dataflex_lock:
  1863. if dataflex_busy_ref[0]:
  1864. active_session_ref[0] = None
  1865. def _abort_start() -> None:
  1866. messagebox.showwarning(
  1867. "打袋機",
  1868. "請等待目前列印完成或先停止連續列印。",
  1869. )
  1870. dataflex_stop_win_ref[0] = None
  1871. try:
  1872. stop_win.destroy()
  1873. except tk.TclError:
  1874. pass
  1875. root.after(0, _abort_start)
  1876. return
  1877. dataflex_busy_ref[0] = True
  1878. ip = session.printer_ip
  1879. port = session.printer_port
  1880. zpl = session.zpl
  1881. label_text = session.label_text
  1882. printed = 0
  1883. error_shown = False
  1884. try:
  1885. send_dataflex_start_job_reset(ip, port, force=True)
  1886. while not stop_event.is_set():
  1887. send_dataflex_label_with_recovery(ip, port, zpl)
  1888. printed += 1
  1889. if DATAFLEX_UI_PROGRESS_EVERY > 0 and (
  1890. printed == 1 or printed % DATAFLEX_UI_PROGRESS_EVERY == 0
  1891. ):
  1892. p = printed
  1893. root.after(
  1894. 0,
  1895. lambda p=p, jc=session.job_code: set_status_message(
  1896. f"連續打袋 · 工單 {jc}… 已印 {p} 張",
  1897. is_error=False,
  1898. ),
  1899. )
  1900. if (
  1901. DATAFLEX_VERIFY_EVERY_LABELS > 0
  1902. and printed % DATAFLEX_VERIFY_EVERY_LABELS == 0
  1903. ):
  1904. recover_dataflex_if_host_fault(ip, port)
  1905. if (
  1906. DATAFLEX_COOLDOWN_EVERY_LABELS > 0
  1907. and printed % DATAFLEX_COOLDOWN_EVERY_LABELS == 0
  1908. ):
  1909. _sleep_interruptible(stop_event, max(0.0, DATAFLEX_COOLDOWN_SEC))
  1910. if (
  1911. DATAFLEX_THERMAL_REST_EVERY_LABELS > 0
  1912. and printed % DATAFLEX_THERMAL_REST_EVERY_LABELS == 0
  1913. ):
  1914. _sleep_interruptible(stop_event, max(0.0, DATAFLEX_THERMAL_REST_SEC))
  1915. _sleep_interruptible(stop_event, DATAFLEX_INTER_LABEL_DELAY_SEC)
  1916. except ConnectionRefusedError:
  1917. error_shown = True
  1918. root.after(
  1919. 0,
  1920. lambda: set_status_message(
  1921. f"無法連線至 {ip}:{port},請確認印表機已開機且 IP 正確。",
  1922. is_error=True,
  1923. ),
  1924. )
  1925. except socket.timeout:
  1926. error_shown = True
  1927. root.after(
  1928. 0,
  1929. lambda: set_status_message(
  1930. f"連線逾時 ({ip}:{port}),請檢查網路與連接埠。",
  1931. is_error=True,
  1932. ),
  1933. )
  1934. except OSError as err:
  1935. error_shown = True
  1936. root.after(
  1937. 0,
  1938. lambda e=err: set_status_message(f"列印失敗:{e}", is_error=True),
  1939. )
  1940. except RuntimeError as err:
  1941. error_shown = True
  1942. root.after(
  1943. 0,
  1944. lambda e=err: set_status_message(f"打袋機錯誤:{e}", is_error=True),
  1945. )
  1946. except Exception as err:
  1947. error_shown = True
  1948. root.after(
  1949. 0,
  1950. lambda e=err: set_status_message(f"打袋機例外:{e}", is_error=True),
  1951. )
  1952. finally:
  1953. with dataflex_lock:
  1954. dataflex_busy_ref[0] = False
  1955. active_session_ref[0] = None
  1956. def _done() -> None:
  1957. dataflex_stop_win_ref[0] = None
  1958. try:
  1959. if os.name == "nt":
  1960. stop_win.attributes("-topmost", False)
  1961. except tk.TclError:
  1962. pass
  1963. try:
  1964. stop_win.destroy()
  1965. except tk.TclError:
  1966. pass
  1967. jc = session.job_code
  1968. if printed > 0:
  1969. set_status_message(
  1970. f"連續列印結束:工單 {jc} · {label_text},已印 {printed} 張",
  1971. is_error=False,
  1972. )
  1973. if session.job_order_id is not None:
  1974. try:
  1975. submit_job_order_print_submit(
  1976. base_url,
  1977. session.job_order_id,
  1978. printed,
  1979. "DATAFLEX",
  1980. )
  1981. on_recorded()
  1982. except requests.RequestException as ex:
  1983. messagebox.showwarning(
  1984. "打袋機",
  1985. f"列印可能已完成,但伺服器記錄失敗(可再試):{ex}",
  1986. )
  1987. elif not error_shown:
  1988. set_status_message("連續列印未印出或已取消", is_error=True)
  1989. root.after(0, _done)
  1990. threading.Thread(target=worker, daemon=True).start()
  1991. def _sleep_interruptible(stop_event: threading.Event, total_sec: float) -> None:
  1992. """Sleep up to total_sec but return early if stop_event is set."""
  1993. end = time.perf_counter() + total_sec
  1994. while time.perf_counter() < end:
  1995. if stop_event.is_set():
  1996. return
  1997. remaining = end - time.perf_counter()
  1998. if remaining <= 0:
  1999. break
  2000. time.sleep(min(0.05, remaining))
  2001. def open_dataflex_stop_window(
  2002. parent: tk.Tk,
  2003. stop_event: threading.Event,
  2004. stop_win_ref: list,
  2005. session: DataflexPrintSession,
  2006. ) -> tk.Toplevel:
  2007. """
  2008. Small window with 停止列印 for DataFlex continuous mode (non-modal so stop stays usable).
  2009. Stays above other dialogs (e.g. 標籤機 quantity) via periodic lift + optional topmost on Windows,
  2010. so switching printer and printing labels does not hide the stop control. Ref is cleared on destroy.
  2011. Job details come from the in-memory session snapshot, not the grid selection.
  2012. """
  2013. win = tk.Toplevel(parent)
  2014. win.title("打袋機連續列印")
  2015. win.geometry("480x240")
  2016. # On Windows, transient(root) can hide this Toplevel when the menubutton / printer row
  2017. # updates (e.g. switching to 激光機); keep transient only on non-Windows.
  2018. if os.name != "nt":
  2019. win.transient(parent)
  2020. win.configure(bg=BG_TOP)
  2021. stop_win_ref[0] = win
  2022. if os.name == "nt":
  2023. try:
  2024. win.attributes("-topmost", True)
  2025. except tk.TclError:
  2026. pass
  2027. tk.Label(
  2028. win,
  2029. text="連續列印進行中(內容以按下 C 時的工單為準,與列表捲動/日期無關)",
  2030. font=get_font(FONT_SIZE_META),
  2031. bg=BG_TOP,
  2032. wraplength=440,
  2033. justify=tk.CENTER,
  2034. ).pack(pady=(12, 6))
  2035. detail = (
  2036. f"工單:{session.job_code}\n"
  2037. f"品號:{session.item_code}\n"
  2038. f"品名:{session.item_name}\n"
  2039. f"批次/批號:{session.label_text}"
  2040. )
  2041. tk.Label(
  2042. win,
  2043. text=detail,
  2044. font=get_font(FONT_SIZE),
  2045. bg=BG_TOP,
  2046. fg="#111111",
  2047. wraplength=440,
  2048. justify=tk.LEFT,
  2049. anchor=tk.W,
  2050. ).pack(padx=16, pady=(0, 8), fill=tk.X)
  2051. def clear_topmost() -> None:
  2052. if os.name == "nt":
  2053. try:
  2054. win.attributes("-topmost", False)
  2055. except tk.TclError:
  2056. pass
  2057. def stop() -> None:
  2058. stop_event.set()
  2059. stop_win_ref[0] = None
  2060. clear_topmost()
  2061. try:
  2062. win.destroy()
  2063. except tk.TclError:
  2064. pass
  2065. def periodic_lift() -> None:
  2066. if stop_win_ref[0] is not win:
  2067. return
  2068. try:
  2069. if not win.winfo_exists():
  2070. return
  2071. win.lift()
  2072. if os.name == "nt":
  2073. win.attributes("-topmost", True)
  2074. except tk.TclError:
  2075. return
  2076. parent.after(4000, periodic_lift)
  2077. tk.Button(
  2078. win,
  2079. text="停止列印",
  2080. command=stop,
  2081. font=get_font(FONT_SIZE_BUTTONS),
  2082. bg=BG_STATUS_ERROR,
  2083. fg=FG_STATUS_ERROR,
  2084. padx=20,
  2085. pady=10,
  2086. ).pack(pady=12)
  2087. win.protocol("WM_DELETE_WINDOW", stop)
  2088. parent.after(500, periodic_lift)
  2089. return win
  2090. def main() -> None:
  2091. settings = load_settings()
  2092. base_url_ref = [build_base_url(settings["api_ip"], settings["api_port"])]
  2093. root = tk.Tk()
  2094. root.title(f"FP-MTMS Bag4 v{APP_VERSION} 打袋機")
  2095. root.geometry("1120x960")
  2096. root.minsize(480, 360)
  2097. root.configure(bg=BG_ROOT)
  2098. # Style: larger font for aged users; light blue theme
  2099. style = ttk.Style()
  2100. try:
  2101. style.configure(".", font=get_font(FONT_SIZE), background=BG_TOP)
  2102. style.configure("TButton", font=get_font(FONT_SIZE_BUTTONS), background=BG_TOP)
  2103. style.configure("TLabel", font=get_font(FONT_SIZE), background=BG_TOP)
  2104. style.configure("TEntry", font=get_font(FONT_SIZE))
  2105. style.configure("TFrame", background=BG_TOP)
  2106. # TCombobox field (if other combos use ttk later)
  2107. style.configure("TCombobox", font=get_font(FONT_SIZE_COMBO))
  2108. except tk.TclError:
  2109. pass
  2110. # Status bar at top: connection state (no popup on error)
  2111. status_frame = tk.Frame(root, bg=BG_STATUS_ERROR, padx=12, pady=6)
  2112. status_frame.pack(fill=tk.X)
  2113. status_lbl = tk.Label(
  2114. status_frame,
  2115. text="連接不到服務器",
  2116. font=get_font(FONT_SIZE_BUTTONS),
  2117. bg=BG_STATUS_ERROR,
  2118. fg=FG_STATUS_ERROR,
  2119. anchor=tk.CENTER,
  2120. )
  2121. status_lbl.pack(fill=tk.X)
  2122. def set_status_ok():
  2123. status_frame.configure(bg=BG_STATUS_OK)
  2124. status_lbl.configure(text="連接正常", bg=BG_STATUS_OK, fg=FG_STATUS_OK)
  2125. def set_status_error():
  2126. status_frame.configure(bg=BG_STATUS_ERROR)
  2127. status_lbl.configure(text="連接不到服務器", bg=BG_STATUS_ERROR, fg=FG_STATUS_ERROR)
  2128. def set_status_message(msg: str, is_error: bool = False) -> None:
  2129. """Show a message on the status bar."""
  2130. if is_error:
  2131. status_frame.configure(bg=BG_STATUS_ERROR)
  2132. status_lbl.configure(text=msg, bg=BG_STATUS_ERROR, fg=FG_STATUS_ERROR)
  2133. else:
  2134. status_frame.configure(bg=BG_STATUS_OK)
  2135. status_lbl.configure(text=msg, bg=BG_STATUS_OK, fg=FG_STATUS_OK)
  2136. # Laser: keep connection open for repeated sends; close when switching away
  2137. laser_conn_ref: list = [None]
  2138. laser_send_busy_ref: list = [False]
  2139. # DataFlex: shared lock so fixed-qty and continuous jobs do not overlap (independent of laser/label)
  2140. dataflex_lock = threading.Lock()
  2141. dataflex_busy_ref: list = [False]
  2142. # Suppress transient DataFlex "disconnected" UI while we intentionally reset/print.
  2143. dataflex_status_grace_until_ref: list[float] = [0.0]
  2144. # 標籤機: own lock so label jobs do not overlap; does not block DataFlex or laser
  2145. label_lock = threading.Lock()
  2146. label_busy_ref: list = [False]
  2147. # DataFlex continuous: stop Toplevel ref so we can lift it after other dialogs
  2148. dataflex_stop_win_ref: list = [None]
  2149. # In-memory job snapshot for C 連續印 (not tied to grid row position after start)
  2150. active_dataflex_session_ref: list[Optional[DataflexPrintSession]] = [None]
  2151. def lift_dataflex_stop_if_running() -> None:
  2152. """After closing another dialog (e.g. 標籤印數), bring the stop panel forward again."""
  2153. w = dataflex_stop_win_ref[0]
  2154. if w is None:
  2155. return
  2156. try:
  2157. if w.winfo_exists():
  2158. w.lift()
  2159. if os.name == "nt":
  2160. w.attributes("-topmost", True)
  2161. except tk.TclError:
  2162. pass
  2163. def hold_dataflex_status_ok(seconds: float) -> None:
  2164. until = time.time() + max(0.0, seconds)
  2165. if until > dataflex_status_grace_until_ref[0]:
  2166. dataflex_status_grace_until_ref[0] = until
  2167. # Top: left [前一天] [date] [後一天] | right [printer dropdown]
  2168. top = tk.Frame(root, padx=12, pady=12, bg=BG_TOP)
  2169. top.pack(fill=tk.X)
  2170. date_var = tk.StringVar(value=date.today().isoformat())
  2171. printer_options = ["打袋機 DataFlex", "標簽機", "激光機"]
  2172. printer_var = tk.StringVar(value=printer_options[0])
  2173. def go_prev_day() -> None:
  2174. try:
  2175. d = date.fromisoformat(date_var.get().strip())
  2176. date_var.set((d - timedelta(days=1)).isoformat())
  2177. load_job_orders(from_user_date_change=True)
  2178. except ValueError:
  2179. date_var.set(date.today().isoformat())
  2180. load_job_orders(from_user_date_change=True)
  2181. def go_next_day() -> None:
  2182. try:
  2183. d = date.fromisoformat(date_var.get().strip())
  2184. date_var.set((d + timedelta(days=1)).isoformat())
  2185. load_job_orders(from_user_date_change=True)
  2186. except ValueError:
  2187. date_var.set(date.today().isoformat())
  2188. load_job_orders(from_user_date_change=True)
  2189. # 前一天 (previous day) with left arrow icon
  2190. btn_prev = ttk.Button(top, text="◀ 前一天", command=go_prev_day)
  2191. btn_prev.pack(side=tk.LEFT, padx=(0, 8))
  2192. # Date field (no "日期:" label); shorter width
  2193. date_entry = tk.Entry(
  2194. top,
  2195. textvariable=date_var,
  2196. font=get_font(FONT_SIZE),
  2197. width=10,
  2198. bg="white",
  2199. )
  2200. date_entry.pack(side=tk.LEFT, padx=(0, 8), ipady=4)
  2201. # 後一天 (next day) with right arrow icon
  2202. btn_next = ttk.Button(top, text="後一天 ▶", command=go_next_day)
  2203. btn_next.pack(side=tk.LEFT, padx=(0, 8))
  2204. # Top right: Setup button + printer selection
  2205. right_frame = tk.Frame(top, bg=BG_TOP)
  2206. right_frame.pack(side=tk.RIGHT)
  2207. ttk.Button(right_frame, text="設定", command=lambda: open_setup_window(root, settings, base_url_ref)).pack(
  2208. side=tk.LEFT, padx=(0, 12)
  2209. )
  2210. def on_dataflex_host_reset() -> None:
  2211. """Send ~JA/~RO/~JR to clear E1005 latch without turning the printer off."""
  2212. ip = (settings.get("dabag_ip") or "").strip()
  2213. port_str = (settings.get("dabag_port") or "3008").strip()
  2214. if not ip:
  2215. messagebox.showwarning("打袋機", "請先在「設定」填寫打袋機 IP。")
  2216. return
  2217. try:
  2218. port = int(port_str)
  2219. except ValueError:
  2220. port = 3008
  2221. hold_dataflex_status_ok(12.0)
  2222. def worker() -> None:
  2223. try:
  2224. send_dataflex_job_counter_reset(ip, port, force=True)
  2225. root.after(
  2226. 0,
  2227. lambda: messagebox.showinfo(
  2228. "打袋機",
  2229. "已送出主機重設(緩衝清除/計數/軟重設)。\n"
  2230. "若畫面仍顯示 E1005,請再按一次或關機重開。",
  2231. ),
  2232. )
  2233. except OSError as ex:
  2234. root.after(
  2235. 0,
  2236. lambda e=str(ex): messagebox.showerror(
  2237. "打袋機",
  2238. f"連線失敗,無法重設:{e}",
  2239. ),
  2240. )
  2241. threading.Thread(target=worker, daemon=True).start()
  2242. ttk.Button(right_frame, text="打袋重設", command=on_dataflex_host_reset).pack(
  2243. side=tk.LEFT, padx=(0, 8)
  2244. )
  2245. # 列印機 label: green when printer connected, red when not (checked periodically)
  2246. printer_status_lbl = tk.Label(
  2247. right_frame,
  2248. text="列印機:",
  2249. font=get_font(FONT_SIZE),
  2250. bg=BG_STATUS_ERROR,
  2251. fg="black",
  2252. padx=6,
  2253. pady=2,
  2254. )
  2255. printer_status_lbl.pack(side=tk.LEFT, padx=(0, 4))
  2256. # tk.OptionMenu (not ttk.Combobox): on Windows the ttk dropdown uses OS font and stays tiny;
  2257. # OptionMenu's menu supports font= for the open list.
  2258. printer_combo = tk.OptionMenu(right_frame, printer_var, *printer_options)
  2259. _combo_font = get_font(FONT_SIZE_COMBO)
  2260. printer_combo.configure(
  2261. font=_combo_font,
  2262. bg=BG_TOP,
  2263. fg="black",
  2264. activebackground=BG_TOP,
  2265. activeforeground="black",
  2266. width=14,
  2267. anchor="w",
  2268. highlightthickness=0,
  2269. bd=1,
  2270. relief=tk.GROOVE,
  2271. )
  2272. printer_combo["menu"].configure(font=_combo_font, tearoff=0)
  2273. printer_combo.pack(side=tk.LEFT)
  2274. printer_after_ref = [None]
  2275. def set_printer_status_ok():
  2276. printer_status_lbl.configure(bg=BG_STATUS_OK, fg=FG_STATUS_OK)
  2277. def set_printer_status_error():
  2278. printer_status_lbl.configure(bg=BG_STATUS_ERROR, fg=FG_STATUS_ERROR)
  2279. def check_printer() -> None:
  2280. if printer_after_ref[0] is not None:
  2281. root.after_cancel(printer_after_ref[0])
  2282. printer_after_ref[0] = None
  2283. if printer_var.get() == "打袋機 DataFlex":
  2284. if (
  2285. dataflex_busy_ref[0]
  2286. or time.time() < dataflex_status_grace_until_ref[0]
  2287. or time.time() < _DATAFLEX_RECOVERY_GRACE_UNTIL
  2288. ):
  2289. set_printer_status_ok()
  2290. printer_after_ref[0] = root.after(5000, check_printer)
  2291. return
  2292. ok = try_printer_connection(printer_var.get(), settings)
  2293. if ok:
  2294. set_printer_status_ok()
  2295. printer_after_ref[0] = root.after(PRINTER_CHECK_MS, check_printer)
  2296. else:
  2297. set_printer_status_error()
  2298. printer_after_ref[0] = root.after(PRINTER_RETRY_MS, check_printer)
  2299. def on_printer_selection_changed(*args) -> None:
  2300. check_printer()
  2301. if printer_var.get() != "激光機":
  2302. if laser_conn_ref[0] is not None:
  2303. try:
  2304. laser_conn_ref[0].close()
  2305. except Exception:
  2306. pass
  2307. laser_conn_ref[0] = None
  2308. # DataFlex continuous stop panel can drop behind after OptionMenu closes; re-lift for any choice.
  2309. root.after(100, lift_dataflex_stop_if_running)
  2310. printer_var.trace_add("write", on_printer_selection_changed)
  2311. def open_setup_window(parent_win: tk.Tk, sett: dict, base_url_ref_list: list) -> None:
  2312. """Modal setup: API IP/port, 打袋機/激光機 IP+port, 標簽機 COM port."""
  2313. d = tk.Toplevel(parent_win)
  2314. d.title("設定")
  2315. d.geometry("440x520")
  2316. d.transient(parent_win)
  2317. d.grab_set()
  2318. d.configure(bg=BG_TOP)
  2319. f = tk.Frame(d, padx=16, pady=16, bg=BG_TOP)
  2320. f.pack(fill=tk.BOTH, expand=True)
  2321. grid_row = [0] # use list so inner function can update
  2322. def _ensure_dot_in_entry(entry: tk.Entry) -> None:
  2323. """Allow typing dot (.) in Entry when IME or layout blocks it (e.g. 192.168.17.27)."""
  2324. def on_key(event):
  2325. if event.keysym in ("period", "decimal"):
  2326. pos = entry.index(tk.INSERT)
  2327. entry.insert(tk.INSERT, ".")
  2328. return "break"
  2329. entry.bind("<KeyPress>", on_key)
  2330. def add_section(label_text: str, key_ip: str | None, key_port: str | None, key_single: str | None):
  2331. out = []
  2332. ttk.Label(f, text=label_text, font=get_font(FONT_SIZE_BUTTONS)).grid(
  2333. row=grid_row[0], column=0, columnspan=2, sticky=tk.W, pady=(8, 2)
  2334. )
  2335. grid_row[0] += 1
  2336. if key_single:
  2337. ttk.Label(
  2338. f,
  2339. text="列印機名稱 (Windows):",
  2340. ).grid(
  2341. row=grid_row[0],
  2342. column=0,
  2343. sticky=tk.W,
  2344. pady=2,
  2345. )
  2346. var = tk.StringVar(value=sett.get(key_single, ""))
  2347. e = tk.Entry(f, textvariable=var, width=22, font=get_font(FONT_SIZE), bg="white")
  2348. e.grid(row=grid_row[0], column=1, sticky=tk.W, pady=2)
  2349. _ensure_dot_in_entry(e)
  2350. grid_row[0] += 1
  2351. return [(key_single, var)]
  2352. if key_ip:
  2353. ttk.Label(f, text="IP:").grid(row=grid_row[0], column=0, sticky=tk.W, pady=2)
  2354. var_ip = tk.StringVar(value=sett.get(key_ip, ""))
  2355. e_ip = tk.Entry(f, textvariable=var_ip, width=22, font=get_font(FONT_SIZE), bg="white")
  2356. e_ip.grid(row=grid_row[0], column=1, sticky=tk.W, pady=2)
  2357. _ensure_dot_in_entry(e_ip)
  2358. grid_row[0] += 1
  2359. out.append((key_ip, var_ip))
  2360. if key_port:
  2361. ttk.Label(f, text="Port:").grid(row=grid_row[0], column=0, sticky=tk.W, pady=2)
  2362. var_port = tk.StringVar(value=sett.get(key_port, ""))
  2363. e_port = tk.Entry(f, textvariable=var_port, width=12, font=get_font(FONT_SIZE), bg="white")
  2364. e_port.grid(row=grid_row[0], column=1, sticky=tk.W, pady=2)
  2365. _ensure_dot_in_entry(e_port)
  2366. grid_row[0] += 1
  2367. out.append((key_port, var_port))
  2368. return out
  2369. all_vars = []
  2370. all_vars.extend(add_section("API 伺服器", "api_ip", "api_port", None))
  2371. all_vars.extend(add_section("打袋機 DataFlex", "dabag_ip", "dabag_port", None))
  2372. all_vars.extend(add_section("激光機", "laser_ip", "laser_port", None))
  2373. all_vars.extend(add_section("標簽機 (USB)", None, None, "label_com"))
  2374. def on_save():
  2375. for key, var in all_vars:
  2376. sett[key] = var.get().strip()
  2377. save_settings(sett)
  2378. base_url_ref_list[0] = build_base_url(sett["api_ip"], sett["api_port"])
  2379. d.destroy()
  2380. btn_f = tk.Frame(d, bg=BG_TOP)
  2381. btn_f.pack(pady=12)
  2382. ttk.Button(btn_f, text="儲存", command=on_save).pack(side=tk.LEFT, padx=4)
  2383. ttk.Button(btn_f, text="取消", command=d.destroy).pack(side=tk.LEFT, padx=4)
  2384. d.wait_window()
  2385. job_orders_frame = tk.Frame(root, bg=BG_LIST)
  2386. job_orders_frame.pack(fill=tk.BOTH, expand=True, padx=12, pady=12)
  2387. search_var = tk.StringVar()
  2388. search_frame = tk.Frame(job_orders_frame, bg=BG_LIST)
  2389. search_frame.pack(fill=tk.X, pady=(0, 6))
  2390. tk.Label(
  2391. search_frame,
  2392. text="搜尋品號/工單/批號:",
  2393. font=get_font(FONT_SIZE_QTY),
  2394. bg=BG_LIST,
  2395. fg="black",
  2396. ).pack(side=tk.LEFT, padx=(0, 6))
  2397. search_entry = tk.Entry(
  2398. search_frame,
  2399. textvariable=search_var,
  2400. width=32,
  2401. font=get_font(FONT_SIZE_QTY),
  2402. bg="white",
  2403. )
  2404. search_entry.pack(side=tk.LEFT, fill=tk.X, expand=True, padx=(0, 8))
  2405. # Scrollable area for buttons
  2406. canvas = tk.Canvas(job_orders_frame, highlightthickness=0, bg=BG_LIST)
  2407. scrollbar = ttk.Scrollbar(job_orders_frame, orient=tk.VERTICAL, command=canvas.yview)
  2408. inner = tk.Frame(canvas, bg=BG_LIST)
  2409. win_id = canvas.create_window((0, 0), window=inner, anchor=tk.NW)
  2410. canvas.configure(yscrollcommand=scrollbar.set)
  2411. def _on_inner_configure(event):
  2412. canvas.configure(scrollregion=canvas.bbox("all"))
  2413. def _on_canvas_configure(event):
  2414. canvas.itemconfig(win_id, width=event.width)
  2415. inner.bind("<Configure>", _on_inner_configure)
  2416. canvas.bind("<Configure>", _on_canvas_configure)
  2417. # Mouse wheel: default Tk scroll speed (one unit per notch)
  2418. def _on_mousewheel(event):
  2419. if getattr(event, "delta", None) is not None:
  2420. canvas.yview_scroll(int(-1 * (event.delta / 120)), "units")
  2421. elif event.num == 5:
  2422. canvas.yview_scroll(1, "units")
  2423. elif event.num == 4:
  2424. canvas.yview_scroll(-1, "units")
  2425. canvas.bind("<MouseWheel>", _on_mousewheel)
  2426. inner.bind("<MouseWheel>", _on_mousewheel)
  2427. canvas.bind("<Button-4>", _on_mousewheel)
  2428. canvas.bind("<Button-5>", _on_mousewheel)
  2429. inner.bind("<Button-4>", _on_mousewheel)
  2430. inner.bind("<Button-5>", _on_mousewheel)
  2431. scrollbar.pack(side=tk.RIGHT, fill=tk.Y)
  2432. canvas.pack(side=tk.LEFT, fill=tk.BOTH, expand=True)
  2433. # Track which row is highlighted (selected for printing) and which job id
  2434. selected_row_holder = [None] # [tk.Frame | None]
  2435. selected_jo_id_ref = [None] # [int | None] job order id for selection preservation
  2436. last_data_ref = [None] # [list | None] last successful fetch for current date
  2437. last_plan_start_ref = [date.today()] # plan date for the current list (search filter uses same)
  2438. after_id_ref = [None] # [str | None] root.after id to cancel retry/refresh
  2439. def _data_equal(a: Optional[list], b: Optional[list]) -> bool:
  2440. if a is None or b is None:
  2441. return a is b
  2442. if len(a) != len(b):
  2443. return False
  2444. for x, y in zip(a, b):
  2445. if x.get("id") != y.get("id"):
  2446. return False
  2447. for k in ("bagPrintedQty", "labelPrintedQty", "laserPrintedQty"):
  2448. if x.get(k) != y.get(k):
  2449. return False
  2450. return True
  2451. def _build_list_from_data(data: list, plan_start: date, preserve_selection: bool) -> None:
  2452. selected_row_holder[0] = None
  2453. year = plan_start.year
  2454. selected_id = selected_jo_id_ref[0] if preserve_selection else None
  2455. found_row = None
  2456. for jo in data:
  2457. jo_id = jo.get("id")
  2458. raw_batch = batch_no(year, jo_id) if jo_id is not None else "—"
  2459. lot_no_val = jo.get("lotNo")
  2460. batch = (lot_no_val or "—").strip() if lot_no_val else "—"
  2461. jo_no_display = (jo.get("code") or "").strip()
  2462. if not jo_no_display and jo_id is not None:
  2463. jo_no_display = raw_batch
  2464. elif not jo_no_display:
  2465. jo_no_display = "—"
  2466. # Line 1: job order no.; line 2: 需求 + 已印(袋/標/激)on one row for compact scrolling
  2467. head_line = f"工單:{jo_no_display}"
  2468. item_code = jo.get("itemCode") or "—"
  2469. item_name = jo.get("itemName") or "—"
  2470. req_qty = jo.get("reqQty")
  2471. qty_str = format_qty(req_qty)
  2472. bag_pq = _printed_qty_int(jo.get("bagPrintedQty"))
  2473. label_pq = _printed_qty_int(jo.get("labelPrintedQty"))
  2474. laser_pq = _printed_qty_int(jo.get("laserPrintedQty"))
  2475. meta_line = (
  2476. f"需求:{qty_str} "
  2477. f"已印 袋{bag_pq:,} 標{label_pq:,} 激{laser_pq:,}"
  2478. )
  2479. # Columns: fixed-width left | fixed-width 品號 | 品名 (expand)
  2480. row = tk.Frame(
  2481. inner,
  2482. bg=BG_ROW,
  2483. relief=tk.RAISED,
  2484. bd=2,
  2485. cursor="hand2",
  2486. padx=10,
  2487. pady=LIST_ROW_IPADY,
  2488. )
  2489. row.pack(fill=tk.X, pady=LIST_ROW_PADY)
  2490. left = tk.Frame(row, bg=BG_ROW, width=LEFT_COL_WIDTH_PX)
  2491. left.pack_propagate(False)
  2492. left.pack(side=tk.LEFT, anchor=tk.NW, fill=tk.Y)
  2493. batch_lbl = tk.Label(
  2494. left,
  2495. text=head_line,
  2496. font=get_font(FONT_SIZE_BUTTONS),
  2497. bg=BG_ROW,
  2498. fg="black",
  2499. )
  2500. batch_lbl.pack(anchor=tk.W)
  2501. meta_lbl = tk.Label(
  2502. left,
  2503. text=meta_line,
  2504. font=get_font(FONT_SIZE_META),
  2505. bg=BG_ROW,
  2506. fg="#222222",
  2507. anchor=tk.W,
  2508. justify=tk.LEFT,
  2509. wraplength=LEFT_COL_WIDTH_PX - 8,
  2510. )
  2511. meta_lbl.pack(anchor=tk.W)
  2512. code_col = tk.Frame(row, bg=BG_ROW, width=CODE_COL_WIDTH_PX)
  2513. code_col.pack_propagate(False)
  2514. code_col.pack(side=tk.LEFT, anchor=tk.NW, fill=tk.Y, padx=(6, 2))
  2515. code_lbl = tk.Label(
  2516. code_col,
  2517. text=item_code,
  2518. font=get_font(FONT_SIZE_ITEM_CODE),
  2519. bg=BG_ROW,
  2520. fg="black",
  2521. wraplength=ITEM_CODE_WRAP,
  2522. justify=tk.LEFT,
  2523. anchor=tk.NW,
  2524. )
  2525. code_lbl.pack(anchor=tk.NW)
  2526. name_col = tk.Frame(row, bg=BG_ROW)
  2527. name_col.pack(side=tk.LEFT, fill=tk.BOTH, expand=True, anchor=tk.NW)
  2528. name_lbl = tk.Label(
  2529. name_col,
  2530. text=item_name or "—",
  2531. font=get_font(FONT_SIZE_ITEM_NAME),
  2532. bg=BG_ROW,
  2533. fg="black",
  2534. wraplength=ITEM_NAME_WRAP,
  2535. justify=tk.LEFT,
  2536. anchor=tk.NW,
  2537. )
  2538. name_lbl.pack(anchor=tk.NW)
  2539. def _on_click(e, j=jo, b=batch, r=row):
  2540. if (
  2541. printer_var.get() == "打袋機 DataFlex"
  2542. and dataflex_busy_ref[0]
  2543. and active_dataflex_session_ref[0] is not None
  2544. ):
  2545. s = active_dataflex_session_ref[0]
  2546. messagebox.showwarning(
  2547. "打袋機",
  2548. f"連續列印進行中,請先按「停止列印」。\n\n"
  2549. f"工單:{s.job_code}\n"
  2550. f"品號:{s.item_code}\n"
  2551. f"品名:{s.item_name}",
  2552. )
  2553. return
  2554. if selected_row_holder[0] is not None:
  2555. set_row_highlight(selected_row_holder[0], False)
  2556. set_row_highlight(r, True)
  2557. selected_row_holder[0] = r
  2558. selected_jo_id_ref[0] = j.get("id")
  2559. if printer_var.get() == "打袋機 DataFlex":
  2560. ip = (settings.get("dabag_ip") or "").strip()
  2561. port_str = (settings.get("dabag_port") or "3008").strip()
  2562. try:
  2563. port = int(port_str)
  2564. except ValueError:
  2565. port = 3008
  2566. if not ip:
  2567. messagebox.showerror("打袋機", "請在設定中填寫打袋機 DataFlex 的 IP。")
  2568. else:
  2569. hold_dataflex_status_ok(12.0)
  2570. def _after_row_select_reset() -> None:
  2571. bag_ans = ask_bag_count(root)
  2572. if bag_ans is not None:
  2573. n, continuous = bag_ans
  2574. hold_dataflex_status_ok(12.0)
  2575. item_code = j.get("itemCode") or "—"
  2576. item_name = j.get("itemName") or "—"
  2577. item_id = j.get("itemId")
  2578. stock_in_line_id = j.get("stockInLineId")
  2579. lot_no = j.get("lotNo")
  2580. zpl = generate_zpl_dataflex(
  2581. b,
  2582. item_code,
  2583. item_name,
  2584. item_id=item_id,
  2585. stock_in_line_id=stock_in_line_id,
  2586. lot_no=lot_no,
  2587. job_order_id=j.get("id"),
  2588. expiry_text=job_expiry_zpl_text(j),
  2589. )
  2590. label_text = (lot_no or b).strip()
  2591. if continuous:
  2592. if dataflex_busy_ref[0]:
  2593. messagebox.showwarning(
  2594. "打袋機",
  2595. "請等待目前列印完成或先停止連續列印。",
  2596. )
  2597. return
  2598. session = build_dataflex_print_session(
  2599. j,
  2600. b,
  2601. zpl,
  2602. label_text,
  2603. ip,
  2604. port,
  2605. )
  2606. active_dataflex_session_ref[0] = session
  2607. stop_ev = threading.Event()
  2608. stop_win = open_dataflex_stop_window(
  2609. root,
  2610. stop_ev,
  2611. dataflex_stop_win_ref,
  2612. session,
  2613. )
  2614. run_dataflex_continuous_thread(
  2615. root=root,
  2616. session=session,
  2617. stop_event=stop_ev,
  2618. stop_win=stop_win,
  2619. dataflex_lock=dataflex_lock,
  2620. dataflex_busy_ref=dataflex_busy_ref,
  2621. dataflex_stop_win_ref=dataflex_stop_win_ref,
  2622. active_session_ref=active_dataflex_session_ref,
  2623. base_url=base_url_ref[0],
  2624. set_status_message=set_status_message,
  2625. on_recorded=lambda: load_job_orders(
  2626. from_user_date_change=False
  2627. ),
  2628. )
  2629. else:
  2630. run_dataflex_fixed_qty_thread(
  2631. root=root,
  2632. dataflex_lock=dataflex_lock,
  2633. dataflex_busy_ref=dataflex_busy_ref,
  2634. ip=ip,
  2635. port=port,
  2636. n=n,
  2637. zpl=zpl,
  2638. label_text=label_text,
  2639. jo_id=j.get("id"),
  2640. base_url=base_url_ref[0],
  2641. set_status_message=set_status_message,
  2642. on_recorded=lambda: load_job_orders(
  2643. from_user_date_change=False
  2644. ),
  2645. )
  2646. def _row_select_reset_worker() -> None:
  2647. try:
  2648. send_dataflex_start_job_reset(ip, port, force=True)
  2649. except OSError as ex:
  2650. root.after(
  2651. 0,
  2652. lambda e=str(ex): messagebox.showwarning(
  2653. "打袋機",
  2654. f"點選工單時重設批次計數失敗(仍可比對數量):{e}",
  2655. ),
  2656. )
  2657. root.after(0, _after_row_select_reset)
  2658. threading.Thread(target=_row_select_reset_worker, daemon=True).start()
  2659. elif printer_var.get() == "標簽機":
  2660. com = (settings.get("label_com") or "").strip()
  2661. if not com:
  2662. messagebox.showerror("標簽機", "請在設定中填寫標簽機名稱 (例如:TSC TTP-246M Pro)。")
  2663. else:
  2664. count = ask_label_count(root)
  2665. lift_dataflex_stop_if_running()
  2666. if count is not None:
  2667. item_code = j.get("itemCode") or "—"
  2668. item_name = j.get("itemName") or "—"
  2669. item_id = j.get("itemId")
  2670. stock_in_line_id = j.get("stockInLineId")
  2671. lot_no = j.get("lotNo")
  2672. n = count
  2673. try:
  2674. # Always render to image (Chinese OK), then send as ZPL graphic (^GFA).
  2675. # This is more reliable than Windows GDI and works for both Windows printer name and COM.
  2676. if not _HAS_PIL_QR:
  2677. raise RuntimeError("請先安裝 Pillow + qrcode(pip install Pillow qrcode[pil])。")
  2678. label_img = render_label_to_image(
  2679. b, item_code, item_name,
  2680. item_id=item_id, stock_in_line_id=stock_in_line_id,
  2681. lot_no=lot_no,
  2682. expiry_text=job_expiry_zpl_text(j),
  2683. )
  2684. zpl_img = _image_to_zpl_gfa(label_img)
  2685. run_label_print_batch_thread(
  2686. root=root,
  2687. label_lock=label_lock,
  2688. label_busy_ref=label_busy_ref,
  2689. com=com,
  2690. zpl_img=zpl_img,
  2691. n=n,
  2692. jo_id=j.get("id"),
  2693. base_url=base_url_ref[0],
  2694. set_status_message=set_status_message,
  2695. on_recorded=lambda: load_job_orders(
  2696. from_user_date_change=False
  2697. ),
  2698. )
  2699. except Exception as err:
  2700. messagebox.showerror("標簽機", f"列印失敗:{err}")
  2701. elif printer_var.get() == "激光機":
  2702. ip = (settings.get("laser_ip") or "").strip()
  2703. port_str = (settings.get("laser_port") or "45678").strip()
  2704. try:
  2705. port = int(port_str)
  2706. except ValueError:
  2707. port = 45678
  2708. if not ip:
  2709. set_status_message("請在設定中填寫激光機的 IP。", is_error=True)
  2710. else:
  2711. item_id = j.get("itemId")
  2712. stock_in_line_id = j.get("stockInLineId")
  2713. item_code_val = j.get("itemCode") or ""
  2714. item_name_val = j.get("itemName") or ""
  2715. run_laser_row_send_thread(
  2716. root=root,
  2717. laser_conn_ref=laser_conn_ref,
  2718. laser_busy_ref=laser_send_busy_ref,
  2719. ip=ip,
  2720. port=port,
  2721. item_id=item_id,
  2722. stock_in_line_id=stock_in_line_id,
  2723. item_code=item_code_val,
  2724. item_name=item_name_val,
  2725. set_status_message=set_status_message,
  2726. base_url=base_url_ref[0],
  2727. job_order_id=j.get("id"),
  2728. on_recorded=lambda: load_job_orders(from_user_date_change=False),
  2729. expiry_text=job_expiry_laser_param(j),
  2730. )
  2731. for w in (
  2732. row,
  2733. left,
  2734. batch_lbl,
  2735. meta_lbl,
  2736. code_col,
  2737. code_lbl,
  2738. name_col,
  2739. name_lbl,
  2740. ):
  2741. w.bind("<Button-1>", _on_click)
  2742. w.bind("<MouseWheel>", _on_mousewheel)
  2743. w.bind("<Button-4>", _on_mousewheel)
  2744. w.bind("<Button-5>", _on_mousewheel)
  2745. if preserve_selection and selected_id is not None and jo.get("id") == selected_id:
  2746. found_row = row
  2747. if found_row is not None:
  2748. set_row_highlight(found_row, True)
  2749. selected_row_holder[0] = found_row
  2750. def refresh_visible_list() -> None:
  2751. """Re-apply search filter to last fetched rows without hitting the API."""
  2752. raw = last_data_ref[0]
  2753. if raw is None:
  2754. return
  2755. ps = last_plan_start_ref[0]
  2756. needle = search_var.get().strip()
  2757. shown = _filter_job_orders_by_search(raw, needle) if needle else raw
  2758. for w in inner.winfo_children():
  2759. w.destroy()
  2760. _build_list_from_data(shown, ps, preserve_selection=True)
  2761. search_entry.bind("<KeyRelease>", lambda e: refresh_visible_list())
  2762. def load_job_orders(from_user_date_change: bool = False) -> None:
  2763. if after_id_ref[0] is not None:
  2764. root.after_cancel(after_id_ref[0])
  2765. after_id_ref[0] = None
  2766. date_str = date_var.get().strip()
  2767. try:
  2768. plan_start = date.fromisoformat(date_str)
  2769. except ValueError:
  2770. messagebox.showerror("日期錯誤", f"請使用 yyyy-MM-dd 格式。目前:{date_str}")
  2771. return
  2772. if from_user_date_change:
  2773. selected_row_holder[0] = None
  2774. selected_jo_id_ref[0] = None
  2775. try:
  2776. data = fetch_job_orders(base_url_ref[0], plan_start)
  2777. except requests.RequestException:
  2778. set_status_error()
  2779. after_id_ref[0] = root.after(RETRY_MS, lambda: load_job_orders(from_user_date_change=False))
  2780. return
  2781. set_status_ok()
  2782. old_data = last_data_ref[0]
  2783. last_data_ref[0] = data
  2784. last_plan_start_ref[0] = plan_start
  2785. data_changed = not _data_equal(old_data, data)
  2786. if data_changed or from_user_date_change:
  2787. printing_busy = (
  2788. dataflex_busy_ref[0]
  2789. or label_busy_ref[0]
  2790. or laser_send_busy_ref[0]
  2791. )
  2792. # Do not destroy/rebuild all rows while printing — that removes click bindings and
  2793. # can hide DataFlex「停止列印」. Retry until idle (one deferred pass at a time).
  2794. if printing_busy and not from_user_date_change:
  2795. after_id_ref[0] = root.after(
  2796. JOB_LIST_DEFER_WHILE_PRINTING_MS,
  2797. lambda: load_job_orders(from_user_date_change=False),
  2798. )
  2799. else:
  2800. # Rebuild list: clear and rebuild from current data (last_data_ref already updated)
  2801. for w in inner.winfo_children():
  2802. w.destroy()
  2803. preserve = not from_user_date_change
  2804. needle = search_var.get().strip()
  2805. shown = _filter_job_orders_by_search(data, needle) if needle else data
  2806. _build_list_from_data(shown, plan_start, preserve_selection=preserve)
  2807. if from_user_date_change:
  2808. canvas.yview_moveto(0)
  2809. if JOB_LIST_AUTO_REFRESH_MS > 0:
  2810. after_id_ref[0] = root.after(
  2811. JOB_LIST_AUTO_REFRESH_MS,
  2812. lambda: load_job_orders(from_user_date_change=False),
  2813. )
  2814. # Load default (today) on start; then start printer connection check
  2815. root.after(100, lambda: load_job_orders(from_user_date_change=True))
  2816. root.after(300, check_printer)
  2817. root.mainloop()
  2818. def _startup_error_log_path() -> str:
  2819. if getattr(sys, "frozen", False):
  2820. base = os.path.dirname(sys.executable)
  2821. else:
  2822. base = os.path.dirname(os.path.abspath(__file__))
  2823. return os.path.join(base, "bag4_startup_error.log")
  2824. if __name__ == "__main__":
  2825. try:
  2826. main()
  2827. except SystemExit:
  2828. raise
  2829. except Exception:
  2830. import traceback
  2831. log_path = _startup_error_log_path()
  2832. try:
  2833. with open(log_path, "w", encoding="utf-8") as f:
  2834. traceback.print_exc(file=f)
  2835. except OSError:
  2836. log_path = "(could not write log file)"
  2837. msg = f"Bag4 啟動失敗,詳情已寫入:\n{log_path}"
  2838. print(msg, file=sys.stderr)
  2839. traceback.print_exc()
  2840. try:
  2841. _err_root = tk.Tk()
  2842. _err_root.withdraw()
  2843. messagebox.showerror("Bag4", msg)
  2844. _err_root.destroy()
  2845. except Exception:
  2846. pass
  2847. if getattr(sys, "frozen", False):
  2848. try:
  2849. input("按 Enter 關閉…")
  2850. except (EOFError, KeyboardInterrupt):
  2851. pass
  2852. sys.exit(1)