Non puoi selezionare più di 25 argomenti Gli argomenti devono iniziare con una lettera o un numero, possono includere trattini ('-') e possono essere lunghi fino a 35 caratteri.
 
 
 
 
 

1053 righe
40 KiB

  1. #!/usr/bin/env python3
  2. """
  3. Bag1 – GUI to show FPSMS job orders by plan date.
  4. Uses the public API GET /py/job-orders (no login required).
  5. UI tuned for aged users: larger font, Traditional Chinese labels, prev/next date.
  6. Run: python Bag1.py
  7. """
  8. import json
  9. import os
  10. import select
  11. import socket
  12. import sys
  13. import threading
  14. import time
  15. import tkinter as tk
  16. from datetime import date, datetime, timedelta
  17. from tkinter import messagebox, ttk
  18. from typing import Callable, Optional
  19. import requests
  20. try:
  21. import serial
  22. except ImportError:
  23. serial = None # type: ignore
  24. DEFAULT_BASE_URL = os.environ.get("FPSMS_BASE_URL", "http://localhost:8090/api")
  25. # When run as PyInstaller exe, save settings next to the exe; otherwise next to script
  26. if getattr(sys, "frozen", False):
  27. _SETTINGS_DIR = os.path.dirname(sys.executable)
  28. else:
  29. _SETTINGS_DIR = os.path.dirname(os.path.abspath(__file__))
  30. SETTINGS_FILE = os.path.join(_SETTINGS_DIR, "bag1_settings.json")
  31. LASER_COUNTER_FILE = os.path.join(_SETTINGS_DIR, "last_batch_count.txt")
  32. DEFAULT_SETTINGS = {
  33. "api_ip": "localhost",
  34. "api_port": "8090",
  35. "dabag_ip": "",
  36. "dabag_port": "3008",
  37. "laser_ip": "192.168.17.10",
  38. "laser_port": "45678",
  39. "label_com": "COM3",
  40. }
  41. def load_settings() -> dict:
  42. """Load settings from JSON file; return defaults if missing or invalid."""
  43. try:
  44. if os.path.isfile(SETTINGS_FILE):
  45. with open(SETTINGS_FILE, "r", encoding="utf-8") as f:
  46. data = json.load(f)
  47. return {**DEFAULT_SETTINGS, **data}
  48. except Exception:
  49. pass
  50. return dict(DEFAULT_SETTINGS)
  51. def save_settings(settings: dict) -> None:
  52. """Save settings to JSON file."""
  53. with open(SETTINGS_FILE, "w", encoding="utf-8") as f:
  54. json.dump(settings, f, indent=2, ensure_ascii=False)
  55. def build_base_url(api_ip: str, api_port: str) -> str:
  56. ip = (api_ip or "localhost").strip()
  57. port = (api_port or "8090").strip()
  58. return f"http://{ip}:{port}/api"
  59. def try_printer_connection(printer_name: str, sett: dict) -> bool:
  60. """Try to connect to the selected printer (TCP IP:port or COM). Returns True if OK."""
  61. if printer_name == "打袋機 DataFlex":
  62. ip = (sett.get("dabag_ip") or "").strip()
  63. port_str = (sett.get("dabag_port") or "9100").strip()
  64. if not ip:
  65. return False
  66. try:
  67. port = int(port_str)
  68. s = socket.create_connection((ip, port), timeout=PRINTER_SOCKET_TIMEOUT)
  69. s.close()
  70. return True
  71. except (socket.error, ValueError, OSError):
  72. return False
  73. if printer_name == "激光機":
  74. ip = (sett.get("laser_ip") or "").strip()
  75. port_str = (sett.get("laser_port") or "45678").strip()
  76. if not ip:
  77. return False
  78. try:
  79. port = int(port_str)
  80. s = socket.create_connection((ip, port), timeout=PRINTER_SOCKET_TIMEOUT)
  81. s.close()
  82. return True
  83. except (socket.error, ValueError, OSError):
  84. return False
  85. if printer_name == "標簽機":
  86. if serial is None:
  87. return False
  88. com = (sett.get("label_com") or "").strip()
  89. if not com:
  90. return False
  91. try:
  92. ser = serial.Serial(com, timeout=1)
  93. ser.close()
  94. return True
  95. except (serial.SerialException, OSError):
  96. return False
  97. return False
  98. # Larger font for aged users (point size)
  99. FONT_SIZE = 16
  100. FONT_SIZE_BUTTONS = 15
  101. FONT_SIZE_QTY = 12 # smaller for 數量 under batch no.
  102. FONT_SIZE_ITEM = 20 # item code and item name (larger for readability)
  103. FONT_FAMILY = "Microsoft JhengHei UI" # Traditional Chinese; fallback to TkDefaultFont
  104. # Column widths: item code own column; item name at least double, wraps in its column
  105. ITEM_CODE_WRAP = 140 # item code column width (long codes wrap under code only)
  106. ITEM_NAME_WRAP = 640 # item name column (double width), wraps under name only
  107. # Light blue theme (softer than pure grey)
  108. BG_TOP = "#E8F4FC"
  109. BG_LIST = "#D4E8F7"
  110. BG_ROOT = "#E1F0FF"
  111. BG_ROW = "#C5E1F5"
  112. BG_ROW_SELECTED = "#6BB5FF" # highlighted when selected (for printing)
  113. # Connection status bar
  114. BG_STATUS_ERROR = "#FFCCCB" # red background when disconnected
  115. FG_STATUS_ERROR = "#B22222" # red text
  116. BG_STATUS_OK = "#90EE90" # light green when connected
  117. FG_STATUS_OK = "#006400" # green text
  118. RETRY_MS = 30 * 1000 # 30 seconds reconnect
  119. REFRESH_MS = 60 * 1000 # 60 seconds refresh when connected
  120. PRINTER_CHECK_MS = 60 * 1000 # 1 minute when printer OK
  121. PRINTER_RETRY_MS = 30 * 1000 # 30 seconds when printer failed
  122. PRINTER_SOCKET_TIMEOUT = 3
  123. DATAFLEX_SEND_TIMEOUT = 10 # seconds when sending ZPL to DataFlex
  124. def _zpl_escape(s: str) -> str:
  125. """Escape text for ZPL ^FD...^FS (backslash and caret)."""
  126. return s.replace("\\", "\\\\").replace("^", "\\^")
  127. def generate_zpl_dataflex(
  128. batch_no: str,
  129. item_code: str,
  130. item_name: str,
  131. item_id: Optional[int] = None,
  132. stock_in_line_id: Optional[int] = None,
  133. lot_no: Optional[str] = None,
  134. font_regular: str = "E:STXihei.ttf",
  135. font_bold: str = "E:STXihei.ttf",
  136. ) -> str:
  137. """
  138. Generate ZPL for DataFlex label (53 mm media, 90° rotated).
  139. QR contains {"itemId": xxx, "stockInLineId": xxx} when both present; else QA,{batch_no}.
  140. Label shows lotNo when present, else batch_no.
  141. """
  142. desc = _zpl_escape((item_name or "—").strip())
  143. code = _zpl_escape((item_code or "—").strip())
  144. label_line2 = (lot_no or batch_no or "—").strip()
  145. batch = _zpl_escape(label_line2)
  146. if item_id is not None and stock_in_line_id is not None:
  147. qr_data = _zpl_escape(json.dumps({"itemId": item_id, "stockInLineId": stock_in_line_id}))
  148. else:
  149. qr_data = f"QA,{batch_no}"
  150. return f"""^XA
  151. ^PW420
  152. ^LL780
  153. ^PO N
  154. ^CI28
  155. ^FO70,70
  156. ^A@R,60,60,{font_regular}^FD{desc}^FS
  157. ^FO220,70
  158. ^A@R,50,50,{font_bold}^FD{code}^FS
  159. ^FO310,70
  160. ^A@R,45,45,{font_bold}^FD批次: {batch}^FS
  161. ^FO150,420
  162. ^BQN,2,6^FD{qr_data}^FS
  163. ^XZ"""
  164. def generate_zpl_label_small(
  165. batch_no: str,
  166. item_code: str,
  167. item_name: str,
  168. item_id: Optional[int] = None,
  169. stock_in_line_id: Optional[int] = None,
  170. lot_no: Optional[str] = None,
  171. font: str = "ARIALR.TTF",
  172. ) -> str:
  173. """
  174. ZPL for 標簽機. Row 1: item name. Row 2: QR left | item code + lot no (or batch) right.
  175. QR contains {"itemId": xxx, "stockInLineId": xxx} when both present; else batch_no.
  176. Font: ARIALR.TTF.
  177. """
  178. desc = _zpl_escape((item_name or "—").strip())
  179. code = _zpl_escape((item_code or "—").strip())
  180. label_line2 = (lot_no or batch_no or "—").strip()
  181. label_line2_esc = _zpl_escape(label_line2)
  182. if item_id is not None and stock_in_line_id is not None:
  183. qr_data = _zpl_escape(json.dumps({"itemId": item_id, "stockInLineId": stock_in_line_id}))
  184. else:
  185. qr_data = f"QA,{batch_no}"
  186. return f"""^XA
  187. ^CI28
  188. ^PW500
  189. ^LL500
  190. ^FO10,15
  191. ^FB480,3,0,L,0
  192. ^A@N,38,38,{font}^FD{desc}^FS
  193. ^FO10,110
  194. ^BQN,2,6^FD{qr_data}^FS
  195. ^FO150,110
  196. ^A@N,48,48,{font}^FD{code}^FS
  197. ^FO150,175
  198. ^A@N,40,40,{font}^FD{label_line2_esc}^FS
  199. ^XZ"""
  200. def send_zpl_to_dataflex(ip: str, port: int, zpl: str) -> None:
  201. """Send ZPL to DataFlex printer via TCP. Raises on connection/send error."""
  202. sock = socket.socket(socket.AF_INET, socket.SOCK_STREAM)
  203. sock.settimeout(DATAFLEX_SEND_TIMEOUT)
  204. try:
  205. sock.connect((ip, port))
  206. sock.sendall(zpl.encode("utf-8"))
  207. finally:
  208. sock.close()
  209. def send_zpl_to_label_printer(com_port: str, zpl: str) -> None:
  210. """Send ZPL to 標簽機 via serial COM port. Raises on error."""
  211. if serial is None:
  212. raise RuntimeError("pyserial not installed. Run: pip install pyserial")
  213. ser = serial.Serial(com_port, timeout=5)
  214. try:
  215. ser.write(zpl.encode("utf-8"))
  216. finally:
  217. ser.close()
  218. def load_laser_last_count() -> tuple[int, Optional[str]]:
  219. """Load last batch count and date from laser counter file. Returns (count, date_str)."""
  220. if not os.path.exists(LASER_COUNTER_FILE):
  221. return 0, None
  222. try:
  223. with open(LASER_COUNTER_FILE, "r", encoding="utf-8") as f:
  224. lines = f.read().strip().splitlines()
  225. if len(lines) >= 2:
  226. return int(lines[1].strip()), lines[0].strip()
  227. except Exception:
  228. pass
  229. return 0, None
  230. def save_laser_last_count(date_str: str, count: int) -> None:
  231. """Save laser batch count and date to file."""
  232. try:
  233. with open(LASER_COUNTER_FILE, "w", encoding="utf-8") as f:
  234. f.write(f"{date_str}\n{count}")
  235. except Exception:
  236. pass
  237. LASER_PUSH_INTERVAL = 2 # seconds between pushes (like sample script)
  238. def laser_push_loop(
  239. ip: str,
  240. port: int,
  241. stop_event: threading.Event,
  242. root: tk.Tk,
  243. on_error: Callable[[str], None],
  244. ) -> None:
  245. """
  246. Run in a background thread: persistent connection to EZCAD, push B{yymmdd}{count:03d};;
  247. every LASER_PUSH_INTERVAL seconds. Resets count each new day. Uses counter file.
  248. """
  249. conn = None
  250. push_count, last_saved_date = load_laser_last_count()
  251. while not stop_event.is_set():
  252. try:
  253. if conn is None:
  254. conn = socket.socket(socket.AF_INET, socket.SOCK_STREAM)
  255. conn.settimeout(0.4)
  256. conn.connect((ip, port))
  257. now = datetime.now()
  258. today_str = now.strftime("%y%m%d")
  259. if last_saved_date != today_str:
  260. push_count = 1
  261. last_saved_date = today_str
  262. batch = f"B{today_str}{push_count:03d}"
  263. reply = f"{batch};;"
  264. conn.sendall(reply.encode("utf-8"))
  265. save_laser_last_count(today_str, push_count)
  266. rlist, _, _ = select.select([conn], [], [], 0.4)
  267. if rlist:
  268. data = conn.recv(4096)
  269. if not data:
  270. conn.close()
  271. conn = None
  272. push_count += 1
  273. for _ in range(int(LASER_PUSH_INTERVAL * 2)):
  274. if stop_event.is_set():
  275. break
  276. time.sleep(0.5)
  277. except socket.timeout:
  278. pass
  279. except Exception as e:
  280. if conn:
  281. try:
  282. conn.close()
  283. except Exception:
  284. pass
  285. conn = None
  286. try:
  287. root.after(0, lambda msg=str(e): on_error(msg))
  288. except Exception:
  289. pass
  290. for _ in range(6):
  291. if stop_event.is_set():
  292. break
  293. time.sleep(0.5)
  294. if conn:
  295. try:
  296. conn.close()
  297. except Exception:
  298. pass
  299. def send_job_to_laser(
  300. conn_ref: list,
  301. ip: str,
  302. port: int,
  303. item_id: Optional[int],
  304. stock_in_line_id: Optional[int],
  305. item_code: str,
  306. item_name: str,
  307. ) -> tuple[bool, str]:
  308. """
  309. Send to laser. Standard format: {"itemId": xxx, "stockInLineId": xxx}.
  310. conn_ref: [socket or None] - reused across calls; closed only when switching printer.
  311. When both item_id and stock_in_line_id present, sends JSON; else fallback: 0;item_code;item_name;;
  312. Returns (success, message).
  313. """
  314. if item_id is not None and stock_in_line_id is not None:
  315. reply = json.dumps({"itemId": item_id, "stockInLineId": stock_in_line_id})
  316. else:
  317. code_str = (item_code or "").strip().replace(";", ",")
  318. name_str = (item_name or "").strip().replace(";", ",")
  319. reply = f"0;{code_str};{name_str};;"
  320. conn = conn_ref[0]
  321. try:
  322. if conn is None:
  323. conn = socket.socket(socket.AF_INET, socket.SOCK_STREAM)
  324. conn.settimeout(3.0)
  325. conn.connect((ip, port))
  326. conn_ref[0] = conn
  327. conn.settimeout(3.0)
  328. conn.sendall(reply.encode("utf-8"))
  329. conn.settimeout(0.5)
  330. try:
  331. data = conn.recv(4096)
  332. if data:
  333. ack = data.decode("utf-8", errors="ignore").strip().lower()
  334. if "receive" in ack and "invalid" not in ack:
  335. return True, f"已送出激光機:{reply}(已確認)"
  336. except socket.timeout:
  337. pass
  338. return True, f"已送出激光機:{reply}"
  339. except (ConnectionRefusedError, socket.timeout, OSError) as e:
  340. if conn_ref[0] is not None:
  341. try:
  342. conn_ref[0].close()
  343. except Exception:
  344. pass
  345. conn_ref[0] = None
  346. if isinstance(e, ConnectionRefusedError):
  347. return False, f"無法連線至 {ip}:{port},請確認激光機已開機且 IP 正確。"
  348. if isinstance(e, socket.timeout):
  349. return False, f"連線逾時 ({ip}:{port}),請檢查網路與連接埠。"
  350. return False, f"激光機送出失敗:{e}"
  351. def send_job_to_laser_with_retry(
  352. conn_ref: list,
  353. ip: str,
  354. port: int,
  355. item_id: Optional[int],
  356. stock_in_line_id: Optional[int],
  357. item_code: str,
  358. item_name: str,
  359. ) -> tuple[bool, str]:
  360. """Send job to laser; on failure, retry once. Returns (success, message)."""
  361. ok, msg = send_job_to_laser(conn_ref, ip, port, item_id, stock_in_line_id, item_code, item_name)
  362. if ok:
  363. return True, msg
  364. ok2, msg2 = send_job_to_laser(conn_ref, ip, port, item_id, stock_in_line_id, item_code, item_name)
  365. return ok2, msg2
  366. def format_qty(val) -> str:
  367. """Format quantity: integer without .0, with thousand separator."""
  368. if val is None:
  369. return "—"
  370. try:
  371. n = float(val)
  372. if n == int(n):
  373. return f"{int(n):,}"
  374. return f"{n:,.2f}".rstrip("0").rstrip(".")
  375. except (TypeError, ValueError):
  376. return str(val)
  377. def batch_no(year: int, job_order_id: int) -> str:
  378. """Batch no.: B + 4-digit year + jobOrderId zero-padded to 6 digits."""
  379. return f"B{year}{job_order_id:06d}"
  380. def get_font(size: int = FONT_SIZE, bold: bool = False) -> tuple:
  381. try:
  382. return (FONT_FAMILY, size, "bold" if bold else "normal")
  383. except Exception:
  384. return ("TkDefaultFont", size, "bold" if bold else "normal")
  385. def fetch_job_orders(base_url: str, plan_start: date) -> list:
  386. """Call GET /py/job-orders and return the JSON list."""
  387. url = f"{base_url.rstrip('/')}/py/job-orders"
  388. params = {"planStart": plan_start.isoformat()}
  389. resp = requests.get(url, params=params, timeout=30)
  390. resp.raise_for_status()
  391. return resp.json()
  392. def set_row_highlight(row_frame: tk.Frame, selected: bool) -> None:
  393. """Set row and all its child widgets to selected or normal background."""
  394. bg = BG_ROW_SELECTED if selected else BG_ROW
  395. row_frame.configure(bg=bg)
  396. for w in row_frame.winfo_children():
  397. if isinstance(w, (tk.Frame, tk.Label)):
  398. w.configure(bg=bg)
  399. for c in w.winfo_children():
  400. if isinstance(c, tk.Label):
  401. c.configure(bg=bg)
  402. def on_job_order_click(jo: dict, batch: str) -> None:
  403. """Show message and highlight row (keeps printing to selected printer)."""
  404. item_code = jo.get("itemCode") or "—"
  405. item_name = jo.get("itemName") or "—"
  406. messagebox.showinfo(
  407. "工單",
  408. f'已點選:批次 {batch}\n品號 {item_code} {item_name}',
  409. )
  410. def ask_laser_count(parent: tk.Tk) -> Optional[int]:
  411. """
  412. When printer is 激光機, ask how many times to send (like DataFlex).
  413. Returns count (>= 1), or -1 for continuous (C), or None if cancelled.
  414. """
  415. result: list = [None]
  416. count_ref = [0]
  417. continuous_ref = [False]
  418. win = tk.Toplevel(parent)
  419. win.title("激光機送出數量")
  420. win.geometry("420x200")
  421. win.transient(parent)
  422. win.grab_set()
  423. win.configure(bg=BG_TOP)
  424. ttk.Label(win, text="送出多少次?", font=get_font(FONT_SIZE)).pack(pady=(12, 4))
  425. count_lbl = tk.Label(win, text="數量: 0", font=get_font(FONT_SIZE), bg=BG_TOP)
  426. count_lbl.pack(pady=4)
  427. def update_display():
  428. if continuous_ref[0]:
  429. count_lbl.configure(text="數量: 連續 (C)")
  430. else:
  431. count_lbl.configure(text=f"數量: {count_ref[0]}")
  432. def add(n: int):
  433. continuous_ref[0] = False
  434. count_ref[0] = max(0, count_ref[0] + n)
  435. update_display()
  436. def set_continuous():
  437. continuous_ref[0] = True
  438. update_display()
  439. def confirm():
  440. if continuous_ref[0]:
  441. result[0] = -1
  442. elif count_ref[0] < 1:
  443. messagebox.showwarning("激光機", "請先按 +50、+10、+5 或 +1 選擇數量。", parent=win)
  444. return
  445. else:
  446. result[0] = count_ref[0]
  447. win.destroy()
  448. btn_row = tk.Frame(win, bg=BG_TOP)
  449. btn_row.pack(pady=8)
  450. for label, value in [("+50", 50), ("+10", 10), ("+5", 5), ("+1", 1)]:
  451. def make_add(v: int):
  452. return lambda: add(v)
  453. ttk.Button(btn_row, text=label, command=make_add(value), width=8).pack(side=tk.LEFT, padx=4)
  454. ttk.Button(btn_row, text="C", command=set_continuous, width=8).pack(side=tk.LEFT, padx=4)
  455. ttk.Button(win, text="確認送出", command=confirm, width=14).pack(pady=12)
  456. win.protocol("WM_DELETE_WINDOW", win.destroy)
  457. win.wait_window()
  458. return result[0]
  459. def ask_label_count(parent: tk.Tk) -> Optional[str]:
  460. """
  461. When printer is 標簽機, ask how many labels to print.
  462. Returns "1", "10", "50", "100", "C" (continuous), or None if cancelled.
  463. """
  464. result = [None] # mutable so inner callback can set it
  465. win = tk.Toplevel(parent)
  466. win.title("標簽列印數量")
  467. win.geometry("360x180")
  468. win.transient(parent)
  469. win.grab_set()
  470. win.configure(bg=BG_TOP)
  471. ttk.Label(win, text="列印多少張標簽?", font=get_font(FONT_SIZE)).pack(pady=(16, 12))
  472. btn_frame = tk.Frame(win, bg=BG_TOP)
  473. btn_frame.pack(pady=8)
  474. for label, value in [("1", "1"), ("10", "10"), ("50", "50"), ("100", "100"), ("連續 (C)", "C")]:
  475. def make_cmd(v):
  476. def cmd():
  477. result[0] = v
  478. win.destroy()
  479. return cmd
  480. ttk.Button(btn_frame, text=label, command=make_cmd(value), width=10).pack(side=tk.LEFT, padx=4)
  481. win.protocol("WM_DELETE_WINDOW", win.destroy)
  482. win.wait_window()
  483. return result[0]
  484. def main() -> None:
  485. settings = load_settings()
  486. base_url_ref = [build_base_url(settings["api_ip"], settings["api_port"])]
  487. root = tk.Tk()
  488. root.title("FP-MTMS Bag v1.1 打袋機")
  489. root.geometry("1120x960")
  490. root.minsize(480, 360)
  491. root.configure(bg=BG_ROOT)
  492. # Style: larger font for aged users; light blue theme
  493. style = ttk.Style()
  494. try:
  495. style.configure(".", font=get_font(FONT_SIZE), background=BG_TOP)
  496. style.configure("TButton", font=get_font(FONT_SIZE_BUTTONS), background=BG_TOP)
  497. style.configure("TLabel", font=get_font(FONT_SIZE), background=BG_TOP)
  498. style.configure("TEntry", font=get_font(FONT_SIZE))
  499. style.configure("TFrame", background=BG_TOP)
  500. except tk.TclError:
  501. pass
  502. # Status bar at top: connection state (no popup on error)
  503. status_frame = tk.Frame(root, bg=BG_STATUS_ERROR, padx=12, pady=6)
  504. status_frame.pack(fill=tk.X)
  505. status_lbl = tk.Label(
  506. status_frame,
  507. text="連接不到服務器",
  508. font=get_font(FONT_SIZE_BUTTONS),
  509. bg=BG_STATUS_ERROR,
  510. fg=FG_STATUS_ERROR,
  511. anchor=tk.CENTER,
  512. )
  513. status_lbl.pack(fill=tk.X)
  514. def set_status_ok():
  515. status_frame.configure(bg=BG_STATUS_OK)
  516. status_lbl.configure(text="連接正常", bg=BG_STATUS_OK, fg=FG_STATUS_OK)
  517. def set_status_error():
  518. status_frame.configure(bg=BG_STATUS_ERROR)
  519. status_lbl.configure(text="連接不到服務器", bg=BG_STATUS_ERROR, fg=FG_STATUS_ERROR)
  520. def set_status_message(msg: str, is_error: bool = False) -> None:
  521. """Show a message on the status bar."""
  522. if is_error:
  523. status_frame.configure(bg=BG_STATUS_ERROR)
  524. status_lbl.configure(text=msg, bg=BG_STATUS_ERROR, fg=FG_STATUS_ERROR)
  525. else:
  526. status_frame.configure(bg=BG_STATUS_OK)
  527. status_lbl.configure(text=msg, bg=BG_STATUS_OK, fg=FG_STATUS_OK)
  528. # Laser: keep connection open for repeated sends; close when switching away
  529. laser_conn_ref: list = [None]
  530. laser_thread_ref: list = [None]
  531. laser_stop_ref: list = [None]
  532. # Top: left [前一天] [date] [後一天] | right [printer dropdown]
  533. top = tk.Frame(root, padx=12, pady=12, bg=BG_TOP)
  534. top.pack(fill=tk.X)
  535. date_var = tk.StringVar(value=date.today().isoformat())
  536. printer_options = ["打袋機 DataFlex", "標簽機", "激光機"]
  537. printer_var = tk.StringVar(value=printer_options[0])
  538. def go_prev_day() -> None:
  539. try:
  540. d = date.fromisoformat(date_var.get().strip())
  541. date_var.set((d - timedelta(days=1)).isoformat())
  542. load_job_orders(from_user_date_change=True)
  543. except ValueError:
  544. date_var.set(date.today().isoformat())
  545. load_job_orders(from_user_date_change=True)
  546. def go_next_day() -> None:
  547. try:
  548. d = date.fromisoformat(date_var.get().strip())
  549. date_var.set((d + timedelta(days=1)).isoformat())
  550. load_job_orders(from_user_date_change=True)
  551. except ValueError:
  552. date_var.set(date.today().isoformat())
  553. load_job_orders(from_user_date_change=True)
  554. # 前一天 (previous day) with left arrow icon
  555. btn_prev = ttk.Button(top, text="◀ 前一天", command=go_prev_day)
  556. btn_prev.pack(side=tk.LEFT, padx=(0, 8))
  557. # Date field (no "日期:" label); shorter width
  558. date_entry = tk.Entry(
  559. top,
  560. textvariable=date_var,
  561. font=get_font(FONT_SIZE),
  562. width=10,
  563. bg="white",
  564. )
  565. date_entry.pack(side=tk.LEFT, padx=(0, 8), ipady=4)
  566. # 後一天 (next day) with right arrow icon
  567. btn_next = ttk.Button(top, text="後一天 ▶", command=go_next_day)
  568. btn_next.pack(side=tk.LEFT, padx=(0, 8))
  569. # Top right: Setup button + printer selection
  570. right_frame = tk.Frame(top, bg=BG_TOP)
  571. right_frame.pack(side=tk.RIGHT)
  572. ttk.Button(right_frame, text="設定", command=lambda: open_setup_window(root, settings, base_url_ref)).pack(
  573. side=tk.LEFT, padx=(0, 12)
  574. )
  575. # 列印機 label: green when printer connected, red when not (checked periodically)
  576. printer_status_lbl = tk.Label(
  577. right_frame,
  578. text="列印機:",
  579. font=get_font(FONT_SIZE),
  580. bg=BG_STATUS_ERROR,
  581. fg="black",
  582. padx=6,
  583. pady=2,
  584. )
  585. printer_status_lbl.pack(side=tk.LEFT, padx=(0, 4))
  586. printer_combo = ttk.Combobox(
  587. right_frame,
  588. textvariable=printer_var,
  589. values=printer_options,
  590. state="readonly",
  591. width=14,
  592. font=get_font(FONT_SIZE),
  593. )
  594. printer_combo.pack(side=tk.LEFT)
  595. printer_after_ref = [None]
  596. def set_printer_status_ok():
  597. printer_status_lbl.configure(bg=BG_STATUS_OK, fg=FG_STATUS_OK)
  598. def set_printer_status_error():
  599. printer_status_lbl.configure(bg=BG_STATUS_ERROR, fg=FG_STATUS_ERROR)
  600. def check_printer() -> None:
  601. if printer_after_ref[0] is not None:
  602. root.after_cancel(printer_after_ref[0])
  603. printer_after_ref[0] = None
  604. ok = try_printer_connection(printer_var.get(), settings)
  605. if ok:
  606. set_printer_status_ok()
  607. printer_after_ref[0] = root.after(PRINTER_CHECK_MS, check_printer)
  608. else:
  609. set_printer_status_error()
  610. printer_after_ref[0] = root.after(PRINTER_RETRY_MS, check_printer)
  611. def on_printer_selection_changed(*args) -> None:
  612. check_printer()
  613. if printer_var.get() != "激光機":
  614. if laser_stop_ref[0] is not None:
  615. laser_stop_ref[0].set()
  616. if laser_conn_ref[0] is not None:
  617. try:
  618. laser_conn_ref[0].close()
  619. except Exception:
  620. pass
  621. laser_conn_ref[0] = None
  622. printer_var.trace_add("write", on_printer_selection_changed)
  623. def open_setup_window(parent_win: tk.Tk, sett: dict, base_url_ref_list: list) -> None:
  624. """Modal setup: API IP/port, 打袋機/激光機 IP+port, 標簽機 COM port."""
  625. d = tk.Toplevel(parent_win)
  626. d.title("設定")
  627. d.geometry("440x520")
  628. d.transient(parent_win)
  629. d.grab_set()
  630. d.configure(bg=BG_TOP)
  631. f = tk.Frame(d, padx=16, pady=16, bg=BG_TOP)
  632. f.pack(fill=tk.BOTH, expand=True)
  633. grid_row = [0] # use list so inner function can update
  634. def _ensure_dot_in_entry(entry: tk.Entry) -> None:
  635. """Allow typing dot (.) in Entry when IME or layout blocks it (e.g. 192.168.17.27)."""
  636. def on_key(event):
  637. if event.keysym in ("period", "decimal"):
  638. pos = entry.index(tk.INSERT)
  639. entry.insert(tk.INSERT, ".")
  640. return "break"
  641. entry.bind("<KeyPress>", on_key)
  642. def add_section(label_text: str, key_ip: str | None, key_port: str | None, key_single: str | None):
  643. out = []
  644. ttk.Label(f, text=label_text, font=get_font(FONT_SIZE_BUTTONS)).grid(
  645. row=grid_row[0], column=0, columnspan=2, sticky=tk.W, pady=(8, 2)
  646. )
  647. grid_row[0] += 1
  648. if key_single:
  649. ttk.Label(f, text="COM:").grid(row=grid_row[0], column=0, sticky=tk.W, pady=2)
  650. var = tk.StringVar(value=sett.get(key_single, ""))
  651. e = tk.Entry(f, textvariable=var, width=14, font=get_font(FONT_SIZE), bg="white")
  652. e.grid(row=grid_row[0], column=1, sticky=tk.W, pady=2)
  653. _ensure_dot_in_entry(e)
  654. grid_row[0] += 1
  655. return [(key_single, var)]
  656. if key_ip:
  657. ttk.Label(f, text="IP:").grid(row=grid_row[0], column=0, sticky=tk.W, pady=2)
  658. var_ip = tk.StringVar(value=sett.get(key_ip, ""))
  659. e_ip = tk.Entry(f, textvariable=var_ip, width=22, font=get_font(FONT_SIZE), bg="white")
  660. e_ip.grid(row=grid_row[0], column=1, sticky=tk.W, pady=2)
  661. _ensure_dot_in_entry(e_ip)
  662. grid_row[0] += 1
  663. out.append((key_ip, var_ip))
  664. if key_port:
  665. ttk.Label(f, text="Port:").grid(row=grid_row[0], column=0, sticky=tk.W, pady=2)
  666. var_port = tk.StringVar(value=sett.get(key_port, ""))
  667. e_port = tk.Entry(f, textvariable=var_port, width=12, font=get_font(FONT_SIZE), bg="white")
  668. e_port.grid(row=grid_row[0], column=1, sticky=tk.W, pady=2)
  669. _ensure_dot_in_entry(e_port)
  670. grid_row[0] += 1
  671. out.append((key_port, var_port))
  672. return out
  673. all_vars = []
  674. all_vars.extend(add_section("API 伺服器", "api_ip", "api_port", None))
  675. all_vars.extend(add_section("打袋機 DataFlex", "dabag_ip", "dabag_port", None))
  676. all_vars.extend(add_section("激光機", "laser_ip", "laser_port", None))
  677. all_vars.extend(add_section("標簽機 COM 埠", None, None, "label_com"))
  678. def on_save():
  679. for key, var in all_vars:
  680. sett[key] = var.get().strip()
  681. save_settings(sett)
  682. base_url_ref_list[0] = build_base_url(sett["api_ip"], sett["api_port"])
  683. d.destroy()
  684. btn_f = tk.Frame(d, bg=BG_TOP)
  685. btn_f.pack(pady=12)
  686. ttk.Button(btn_f, text="儲存", command=on_save).pack(side=tk.LEFT, padx=4)
  687. ttk.Button(btn_f, text="取消", command=d.destroy).pack(side=tk.LEFT, padx=4)
  688. d.wait_window()
  689. job_orders_frame = tk.Frame(root, bg=BG_LIST)
  690. job_orders_frame.pack(fill=tk.BOTH, expand=True, padx=12, pady=12)
  691. # Scrollable area for buttons
  692. canvas = tk.Canvas(job_orders_frame, highlightthickness=0, bg=BG_LIST)
  693. scrollbar = ttk.Scrollbar(job_orders_frame, orient=tk.VERTICAL, command=canvas.yview)
  694. inner = tk.Frame(canvas, bg=BG_LIST)
  695. win_id = canvas.create_window((0, 0), window=inner, anchor=tk.NW)
  696. canvas.configure(yscrollcommand=scrollbar.set)
  697. def _on_inner_configure(event):
  698. canvas.configure(scrollregion=canvas.bbox("all"))
  699. def _on_canvas_configure(event):
  700. canvas.itemconfig(win_id, width=event.width)
  701. inner.bind("<Configure>", _on_inner_configure)
  702. canvas.bind("<Configure>", _on_canvas_configure)
  703. # Mouse wheel: make scroll work when hovering over canvas or the list (inner/buttons)
  704. def _on_mousewheel(event):
  705. if getattr(event, "delta", None) is not None:
  706. canvas.yview_scroll(int(-1 * (event.delta / 120)), "units")
  707. elif event.num == 5:
  708. canvas.yview_scroll(1, "units")
  709. elif event.num == 4:
  710. canvas.yview_scroll(-1, "units")
  711. canvas.bind("<MouseWheel>", _on_mousewheel)
  712. inner.bind("<MouseWheel>", _on_mousewheel)
  713. canvas.bind("<Button-4>", _on_mousewheel)
  714. canvas.bind("<Button-5>", _on_mousewheel)
  715. inner.bind("<Button-4>", _on_mousewheel)
  716. inner.bind("<Button-5>", _on_mousewheel)
  717. scrollbar.pack(side=tk.RIGHT, fill=tk.Y)
  718. canvas.pack(side=tk.LEFT, fill=tk.BOTH, expand=True)
  719. # Track which row is highlighted (selected for printing) and which job id
  720. selected_row_holder = [None] # [tk.Frame | None]
  721. selected_jo_id_ref = [None] # [int | None] job order id for selection preservation
  722. last_data_ref = [None] # [list | None] last successful fetch for current date
  723. after_id_ref = [None] # [str | None] root.after id to cancel retry/refresh
  724. def _data_equal(a: Optional[list], b: Optional[list]) -> bool:
  725. if a is None or b is None:
  726. return a is b
  727. if len(a) != len(b):
  728. return False
  729. ids_a = [x.get("id") for x in a]
  730. ids_b = [x.get("id") for x in b]
  731. return ids_a == ids_b
  732. def _build_list_from_data(data: list, plan_start: date, preserve_selection: bool) -> None:
  733. selected_row_holder[0] = None
  734. year = plan_start.year
  735. selected_id = selected_jo_id_ref[0] if preserve_selection else None
  736. found_row = None
  737. for jo in data:
  738. jo_id = jo.get("id")
  739. lot_no_val = jo.get("lotNo")
  740. batch = (lot_no_val or "—").strip() if lot_no_val else "—"
  741. item_code = jo.get("itemCode") or "—"
  742. item_name = jo.get("itemName") or "—"
  743. req_qty = jo.get("reqQty")
  744. qty_str = format_qty(req_qty)
  745. # Three columns: batch+數量 | item code (own column) | item name (≥2× width, wraps in column)
  746. row = tk.Frame(inner, bg=BG_ROW, relief=tk.RAISED, bd=2, cursor="hand2", padx=12, pady=10)
  747. row.pack(fill=tk.X, pady=4)
  748. left = tk.Frame(row, bg=BG_ROW)
  749. left.pack(side=tk.LEFT, anchor=tk.NW)
  750. batch_lbl = tk.Label(
  751. left,
  752. text=batch,
  753. font=get_font(FONT_SIZE_BUTTONS),
  754. bg=BG_ROW,
  755. fg="black",
  756. )
  757. batch_lbl.pack(anchor=tk.W)
  758. qty_lbl = None
  759. if qty_str != "—":
  760. qty_lbl = tk.Label(
  761. left,
  762. text=f"數量:{qty_str}",
  763. font=get_font(FONT_SIZE_QTY),
  764. bg=BG_ROW,
  765. fg="black",
  766. )
  767. qty_lbl.pack(anchor=tk.W)
  768. # Column 2: item code only, bigger font, wraps in its own column
  769. code_lbl = tk.Label(
  770. row,
  771. text=item_code,
  772. font=get_font(FONT_SIZE_ITEM),
  773. bg=BG_ROW,
  774. fg="black",
  775. wraplength=ITEM_CODE_WRAP,
  776. justify=tk.LEFT,
  777. anchor=tk.NW,
  778. )
  779. code_lbl.pack(side=tk.LEFT, anchor=tk.NW, padx=(12, 8))
  780. # Column 3: item name only, same bigger font, at least double width, wraps under its own column
  781. name_lbl = tk.Label(
  782. row,
  783. text=item_name or "—",
  784. font=get_font(FONT_SIZE_ITEM),
  785. bg=BG_ROW,
  786. fg="black",
  787. wraplength=ITEM_NAME_WRAP,
  788. justify=tk.LEFT,
  789. anchor=tk.NW,
  790. )
  791. name_lbl.pack(side=tk.LEFT, fill=tk.BOTH, expand=True, anchor=tk.NW)
  792. def _on_click(e, j=jo, b=batch, r=row):
  793. if selected_row_holder[0] is not None:
  794. set_row_highlight(selected_row_holder[0], False)
  795. set_row_highlight(r, True)
  796. selected_row_holder[0] = r
  797. selected_jo_id_ref[0] = j.get("id")
  798. if printer_var.get() == "打袋機 DataFlex":
  799. ip = (settings.get("dabag_ip") or "").strip()
  800. port_str = (settings.get("dabag_port") or "3008").strip()
  801. try:
  802. port = int(port_str)
  803. except ValueError:
  804. port = 3008
  805. if not ip:
  806. messagebox.showerror("打袋機", "請在設定中填寫打袋機 DataFlex 的 IP。")
  807. else:
  808. item_code = j.get("itemCode") or "—"
  809. item_name = j.get("itemName") or "—"
  810. item_id = j.get("itemId")
  811. stock_in_line_id = j.get("stockInLineId")
  812. lot_no = j.get("lotNo")
  813. zpl = generate_zpl_dataflex(
  814. b, item_code, item_name,
  815. item_id=item_id, stock_in_line_id=stock_in_line_id,
  816. lot_no=lot_no,
  817. )
  818. try:
  819. send_zpl_to_dataflex(ip, port, zpl)
  820. messagebox.showinfo("打袋機", f"已送出列印:批次 {b}")
  821. except ConnectionRefusedError:
  822. messagebox.showerror("打袋機", f"無法連線至 {ip}:{port},請確認印表機已開機且 IP 正確。")
  823. except socket.timeout:
  824. messagebox.showerror("打袋機", f"連線逾時 ({ip}:{port}),請檢查網路與連接埠。")
  825. except OSError as err:
  826. messagebox.showerror("打袋機", f"列印失敗:{err}")
  827. elif printer_var.get() == "標簽機":
  828. com = (settings.get("label_com") or "").strip()
  829. if not com:
  830. messagebox.showerror("標簽機", "請在設定中填寫標簽機 COM 埠。")
  831. else:
  832. count = ask_label_count(root)
  833. if count is not None:
  834. item_code = j.get("itemCode") or "—"
  835. item_name = j.get("itemName") or "—"
  836. item_id = j.get("itemId")
  837. stock_in_line_id = j.get("stockInLineId")
  838. lot_no = j.get("lotNo")
  839. zpl = generate_zpl_label_small(
  840. b, item_code, item_name,
  841. item_id=item_id, stock_in_line_id=stock_in_line_id,
  842. lot_no=lot_no,
  843. )
  844. n = 100 if count == "C" else int(count)
  845. try:
  846. for i in range(n):
  847. send_zpl_to_label_printer(com, zpl)
  848. if i < n - 1:
  849. time.sleep(0.5)
  850. msg = f"已送出列印:{n} 張標簽" if count != "C" else f"已送出列印:{n} 張標簽 (連續)"
  851. messagebox.showinfo("標簽機", msg)
  852. except Exception as err:
  853. messagebox.showerror("標簽機", f"列印失敗:{err}")
  854. elif printer_var.get() == "激光機":
  855. ip = (settings.get("laser_ip") or "").strip()
  856. port_str = (settings.get("laser_port") or "45678").strip()
  857. try:
  858. port = int(port_str)
  859. except ValueError:
  860. port = 45678
  861. if not ip:
  862. set_status_message("請在設定中填寫激光機的 IP。", is_error=True)
  863. else:
  864. count = ask_laser_count(root)
  865. if count is not None:
  866. item_id = j.get("itemId")
  867. stock_in_line_id = j.get("stockInLineId")
  868. item_code_val = j.get("itemCode") or ""
  869. item_name_val = j.get("itemName") or ""
  870. n = 100 if count == -1 else count
  871. sent = 0
  872. for i in range(n):
  873. ok, msg = send_job_to_laser_with_retry(
  874. laser_conn_ref, ip, port,
  875. item_id, stock_in_line_id,
  876. item_code_val, item_name_val,
  877. )
  878. if ok:
  879. sent += 1
  880. else:
  881. set_status_message(f"已送出 {sent} 次,第 {sent + 1} 次失敗:{msg}", is_error=True)
  882. break
  883. if i < n - 1:
  884. time.sleep(0.2)
  885. if sent == n:
  886. set_status_message(f"已送出激光機:{sent} 次", is_error=False)
  887. on_job_order_click(j, b)
  888. for w in (row, left, batch_lbl, code_lbl, name_lbl):
  889. w.bind("<Button-1>", _on_click)
  890. w.bind("<MouseWheel>", _on_mousewheel)
  891. w.bind("<Button-4>", _on_mousewheel)
  892. w.bind("<Button-5>", _on_mousewheel)
  893. if qty_lbl is not None:
  894. qty_lbl.bind("<Button-1>", _on_click)
  895. qty_lbl.bind("<MouseWheel>", _on_mousewheel)
  896. qty_lbl.bind("<Button-4>", _on_mousewheel)
  897. qty_lbl.bind("<Button-5>", _on_mousewheel)
  898. if preserve_selection and selected_id is not None and jo.get("id") == selected_id:
  899. found_row = row
  900. if found_row is not None:
  901. set_row_highlight(found_row, True)
  902. selected_row_holder[0] = found_row
  903. def load_job_orders(from_user_date_change: bool = False) -> None:
  904. if after_id_ref[0] is not None:
  905. root.after_cancel(after_id_ref[0])
  906. after_id_ref[0] = None
  907. date_str = date_var.get().strip()
  908. try:
  909. plan_start = date.fromisoformat(date_str)
  910. except ValueError:
  911. messagebox.showerror("日期錯誤", f"請使用 yyyy-MM-dd 格式。目前:{date_str}")
  912. return
  913. if from_user_date_change:
  914. selected_row_holder[0] = None
  915. selected_jo_id_ref[0] = None
  916. try:
  917. data = fetch_job_orders(base_url_ref[0], plan_start)
  918. except requests.RequestException:
  919. set_status_error()
  920. after_id_ref[0] = root.after(RETRY_MS, lambda: load_job_orders(from_user_date_change=False))
  921. return
  922. set_status_ok()
  923. old_data = last_data_ref[0]
  924. last_data_ref[0] = data
  925. data_changed = not _data_equal(old_data, data)
  926. if data_changed or from_user_date_change:
  927. # Rebuild list: clear and rebuild from current data (last_data_ref already updated)
  928. for w in inner.winfo_children():
  929. w.destroy()
  930. preserve = not from_user_date_change
  931. _build_list_from_data(data, plan_start, preserve_selection=preserve)
  932. if from_user_date_change:
  933. canvas.yview_moveto(0)
  934. after_id_ref[0] = root.after(REFRESH_MS, lambda: load_job_orders(from_user_date_change=False))
  935. # Load default (today) on start; then start printer connection check
  936. root.after(100, lambda: load_job_orders(from_user_date_change=True))
  937. root.after(300, check_printer)
  938. root.mainloop()
  939. if __name__ == "__main__":
  940. main()