Nelze vybrat více než 25 témat Téma musí začínat písmenem nebo číslem, může obsahovat pomlčky („-“) a může být dlouhé až 35 znaků.
 
 
 
 
 

1452 řádky
56 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 tempfile
  14. import threading
  15. import time
  16. import tkinter as tk
  17. from datetime import date, datetime, timedelta
  18. from tkinter import messagebox, ttk
  19. from typing import Callable, Optional
  20. import requests
  21. try:
  22. import serial
  23. except ImportError:
  24. serial = None # type: ignore
  25. try:
  26. import win32print # type: ignore[import]
  27. import win32ui # type: ignore[import]
  28. import win32con # type: ignore[import]
  29. import win32gui # type: ignore[import]
  30. except ImportError:
  31. win32print = None # type: ignore[assignment]
  32. win32ui = None # type: ignore[assignment]
  33. win32con = None # type: ignore[assignment]
  34. win32gui = None # type: ignore[assignment]
  35. try:
  36. from PIL import Image, ImageDraw, ImageFont
  37. import qrcode
  38. _HAS_PIL_QR = True
  39. except ImportError:
  40. Image = None # type: ignore[assignment]
  41. ImageDraw = None # type: ignore[assignment]
  42. ImageFont = None # type: ignore[assignment]
  43. qrcode = None # type: ignore[assignment]
  44. _HAS_PIL_QR = False
  45. DEFAULT_BASE_URL = os.environ.get("FPSMS_BASE_URL", "http://localhost:8090/api")
  46. # When run as PyInstaller exe, save settings next to the exe; otherwise next to script
  47. if getattr(sys, "frozen", False):
  48. _SETTINGS_DIR = os.path.dirname(sys.executable)
  49. else:
  50. _SETTINGS_DIR = os.path.dirname(os.path.abspath(__file__))
  51. # Bag2 has its own settings file so it doesn't share with Bag1.
  52. SETTINGS_FILE = os.path.join(_SETTINGS_DIR, "bag2_settings.json")
  53. LASER_COUNTER_FILE = os.path.join(_SETTINGS_DIR, "last_batch_count.txt")
  54. DEFAULT_SETTINGS = {
  55. "api_ip": "localhost",
  56. "api_port": "8090",
  57. "dabag_ip": "",
  58. "dabag_port": "3008",
  59. "laser_ip": "192.168.17.10",
  60. "laser_port": "45678",
  61. # For 標簽機 on Windows, this is the Windows printer name, e.g. "TSC TTP-246M Pro"
  62. "label_com": "TSC TTP-246M Pro",
  63. }
  64. def load_settings() -> dict:
  65. """Load settings from JSON file; return defaults if missing or invalid."""
  66. try:
  67. if os.path.isfile(SETTINGS_FILE):
  68. with open(SETTINGS_FILE, "r", encoding="utf-8") as f:
  69. data = json.load(f)
  70. return {**DEFAULT_SETTINGS, **data}
  71. except Exception:
  72. pass
  73. return dict(DEFAULT_SETTINGS)
  74. def save_settings(settings: dict) -> None:
  75. """Save settings to JSON file."""
  76. with open(SETTINGS_FILE, "w", encoding="utf-8") as f:
  77. json.dump(settings, f, indent=2, ensure_ascii=False)
  78. def build_base_url(api_ip: str, api_port: str) -> str:
  79. ip = (api_ip or "localhost").strip()
  80. port = (api_port or "8090").strip()
  81. return f"http://{ip}:{port}/api"
  82. def try_printer_connection(printer_name: str, sett: dict) -> bool:
  83. """Try to connect to the selected printer (TCP IP:port or COM). Returns True if OK."""
  84. if printer_name == "打袋機 DataFlex":
  85. ip = (sett.get("dabag_ip") or "").strip()
  86. port_str = (sett.get("dabag_port") or "9100").strip()
  87. if not ip:
  88. return False
  89. try:
  90. port = int(port_str)
  91. s = socket.create_connection((ip, port), timeout=PRINTER_SOCKET_TIMEOUT)
  92. s.close()
  93. return True
  94. except (socket.error, ValueError, OSError):
  95. return False
  96. if printer_name == "激光機":
  97. ip = (sett.get("laser_ip") or "").strip()
  98. port_str = (sett.get("laser_port") or "45678").strip()
  99. if not ip:
  100. return False
  101. try:
  102. port = int(port_str)
  103. s = socket.create_connection((ip, port), timeout=PRINTER_SOCKET_TIMEOUT)
  104. s.close()
  105. return True
  106. except (socket.error, ValueError, OSError):
  107. return False
  108. if printer_name == "標簽機":
  109. target = (sett.get("label_com") or "").strip()
  110. if not target:
  111. return False
  112. # On Windows, allow using a Windows printer name (e.g. "TSC TTP-246M Pro")
  113. # as an alternative to a COM port. If it doesn't look like a COM port,
  114. # try opening it via the Windows print spooler.
  115. if os.name == "nt" and not target.upper().startswith("COM"):
  116. if win32print is None:
  117. return False
  118. try:
  119. handle = win32print.OpenPrinter(target)
  120. win32print.ClosePrinter(handle)
  121. return True
  122. except Exception:
  123. return False
  124. # Fallback: treat as serial COM port (original behaviour)
  125. if serial is None:
  126. return False
  127. try:
  128. ser = serial.Serial(target, timeout=1)
  129. ser.close()
  130. return True
  131. except (serial.SerialException, OSError):
  132. return False
  133. return False
  134. # Larger font for aged users (point size)
  135. FONT_SIZE = 16
  136. FONT_SIZE_BUTTONS = 15
  137. FONT_SIZE_QTY = 12 # smaller for 數量 under batch no.
  138. FONT_SIZE_ITEM = 20 # item code and item name (larger for readability)
  139. FONT_FAMILY = "Microsoft JhengHei UI" # Traditional Chinese; fallback to TkDefaultFont
  140. FONT_SIZE_ITEM_CODE = 20 # item code (larger for readability)
  141. FONT_SIZE_ITEM_NAME = 26 # item name (bigger than item code)
  142. # Column widths: item code own column; item name at least double, wraps in its column
  143. ITEM_CODE_WRAP = 140 # item code column width (long codes wrap under code only)
  144. ITEM_NAME_WRAP = 640 # item name column (double width), wraps under name only
  145. # Light blue theme (softer than pure grey)
  146. BG_TOP = "#E8F4FC"
  147. BG_LIST = "#D4E8F7"
  148. BG_ROOT = "#E1F0FF"
  149. BG_ROW = "#C5E1F5"
  150. BG_ROW_SELECTED = "#6BB5FF" # highlighted when selected (for printing)
  151. # Connection status bar
  152. BG_STATUS_ERROR = "#FFCCCB" # red background when disconnected
  153. FG_STATUS_ERROR = "#B22222" # red text
  154. BG_STATUS_OK = "#90EE90" # light green when connected
  155. FG_STATUS_OK = "#006400" # green text
  156. RETRY_MS = 30 * 1000 # 30 seconds reconnect
  157. REFRESH_MS = 60 * 1000 # 60 seconds refresh when connected
  158. PRINTER_CHECK_MS = 60 * 1000 # 1 minute when printer OK
  159. PRINTER_RETRY_MS = 30 * 1000 # 30 seconds when printer failed
  160. PRINTER_SOCKET_TIMEOUT = 3
  161. DATAFLEX_SEND_TIMEOUT = 10 # seconds when sending ZPL to DataFlex
  162. def _zpl_escape(s: str) -> str:
  163. """Escape text for ZPL ^FD...^FS (backslash and caret)."""
  164. return s.replace("\\", "\\\\").replace("^", "\\^")
  165. def generate_zpl_dataflex(
  166. batch_no: str,
  167. item_code: str,
  168. item_name: str,
  169. item_id: Optional[int] = None,
  170. stock_in_line_id: Optional[int] = None,
  171. lot_no: Optional[str] = None,
  172. font_regular: str = "E:STXihei.ttf",
  173. font_bold: str = "E:STXihei.ttf",
  174. ) -> str:
  175. """
  176. Row 1 (from zero): QR code, then item name (rotated 90°).
  177. Row 2: Batch/lot (left), item code (right).
  178. Label and QR use lotNo from API when present, else batch_no (Bxxxxx).
  179. """
  180. desc = _zpl_escape((item_name or "—").strip())
  181. code = _zpl_escape((item_code or "—").strip())
  182. label_line = (lot_no or batch_no or "").strip()
  183. label_esc = _zpl_escape(label_line)
  184. # QR payload: prefer JSON {"itemId":..., "stockInLineId":...} when both present; else fall back to lot/batch text
  185. if item_id is not None and stock_in_line_id is not None:
  186. qr_value = _zpl_escape(json.dumps({"itemId": item_id, "stockInLineId": stock_in_line_id}))
  187. else:
  188. qr_value = _zpl_escape(label_line if label_line else batch_no.strip())
  189. return f"""^XA
  190. ^CI28
  191. ^PW700
  192. ^LL500
  193. ^PO N
  194. ^FO10,20
  195. ^BQR,4,7^FD{qr_value}^FS
  196. ^FO170,20
  197. ^A@R,72,72,{font_regular}^FD{desc}^FS
  198. ^FO0,290
  199. ^A@R,72,72,{font_regular}^FD{label_esc}^FS
  200. ^FO75,290
  201. ^A@R,88,88,{font_bold}^FD{code}^FS
  202. ^XZ"""
  203. def generate_zpl_label_small(
  204. batch_no: str,
  205. item_code: str,
  206. item_name: str,
  207. item_id: Optional[int] = None,
  208. stock_in_line_id: Optional[int] = None,
  209. lot_no: Optional[str] = None,
  210. font: str = "MingLiUHKSCS",
  211. ) -> str:
  212. """
  213. ZPL for 標簽機. Row 1: item name. Row 2: QR left | item code + lot no (or batch) right.
  214. QR contains {"itemId": xxx, "stockInLineId": xxx} when both present; else batch_no.
  215. Unicode (^CI28); font set for Big-5 (e.g. MingLiUHKSCS).
  216. """
  217. desc = _zpl_escape((item_name or "—").strip())
  218. code = _zpl_escape((item_code or "—").strip())
  219. label_line2 = (lot_no or batch_no or "—").strip()
  220. label_line2_esc = _zpl_escape(label_line2)
  221. if item_id is not None and stock_in_line_id is not None:
  222. qr_data = _zpl_escape(json.dumps({"itemId": item_id, "stockInLineId": stock_in_line_id}))
  223. else:
  224. qr_data = f"QA,{batch_no}"
  225. return f"""^XA
  226. ^CI28
  227. ^PW500
  228. ^LL500
  229. ^FO10,15
  230. ^FB480,3,0,L,0
  231. ^A@N,38,38,{font}^FD{desc}^FS
  232. ^FO10,110
  233. ^BQN,2,6^FD{qr_data}^FS
  234. ^FO150,110
  235. ^A@N,48,48,{font}^FD{code}^FS
  236. ^FO150,175
  237. ^A@N,40,40,{font}^FD{label_line2_esc}^FS
  238. ^XZ"""
  239. # Label image size (pixels) for 標簽機 image printing; words drawn bigger for readability
  240. LABEL_IMAGE_W = 520
  241. LABEL_IMAGE_H = 520
  242. LABEL_PADDING = 20
  243. LABEL_FONT_NAME_SIZE = 38 # item name (bigger)
  244. LABEL_FONT_CODE_SIZE = 44 # item code (bigger)
  245. LABEL_FONT_BATCH_SIZE = 30 # batch/lot line
  246. LABEL_QR_SIZE = 140 # QR module size in pixels
  247. def _get_chinese_font(size: int) -> Optional["ImageFont.FreeTypeFont"]:
  248. """Return a Chinese-capable font for PIL, or None to use default."""
  249. if ImageFont is None:
  250. return None
  251. # Prefer Traditional Chinese fonts on Windows
  252. for name in ("Microsoft JhengHei UI", "Microsoft JhengHei", "MingLiU", "SimHei", "Microsoft YaHei", "SimSun"):
  253. try:
  254. return ImageFont.truetype(name, size)
  255. except (OSError, IOError):
  256. continue
  257. try:
  258. return ImageFont.load_default()
  259. except Exception:
  260. return None
  261. def render_label_to_image(
  262. batch_no: str,
  263. item_code: str,
  264. item_name: str,
  265. item_id: Optional[int] = None,
  266. stock_in_line_id: Optional[int] = None,
  267. lot_no: Optional[str] = None,
  268. ) -> "Image.Image":
  269. """
  270. Render 標簽機 label as a PIL Image (white bg, black text + QR).
  271. Use this image for printing so Chinese displays correctly; words are drawn bigger.
  272. Requires Pillow and qrcode. Raises RuntimeError if not available.
  273. """
  274. if not _HAS_PIL_QR or Image is None or qrcode is None:
  275. raise RuntimeError("Pillow and qrcode are required for image labels. Run: pip install Pillow qrcode[pil]")
  276. img = Image.new("RGB", (LABEL_IMAGE_W, LABEL_IMAGE_H), "white")
  277. draw = ImageDraw.Draw(img)
  278. # QR payload (same as ZPL)
  279. if item_id is not None and stock_in_line_id is not None:
  280. qr_data = json.dumps({"itemId": item_id, "stockInLineId": stock_in_line_id})
  281. else:
  282. qr_data = f"QA,{batch_no}"
  283. # Draw QR top-left area
  284. qr = qrcode.QRCode(box_size=4, border=2)
  285. qr.add_data(qr_data)
  286. qr.make(fit=True)
  287. qr_img = qr.make_image(fill_color="black", back_color="white")
  288. _resample = getattr(Image, "Resampling", Image).NEAREST
  289. qr_img = qr_img.resize((LABEL_QR_SIZE, LABEL_QR_SIZE), _resample)
  290. img.paste(qr_img, (LABEL_PADDING, LABEL_PADDING))
  291. # Fonts (bigger for readability)
  292. font_name = _get_chinese_font(LABEL_FONT_NAME_SIZE)
  293. font_code = _get_chinese_font(LABEL_FONT_CODE_SIZE)
  294. font_batch = _get_chinese_font(LABEL_FONT_BATCH_SIZE)
  295. x_right = LABEL_PADDING + LABEL_QR_SIZE + LABEL_PADDING
  296. y_line = LABEL_PADDING
  297. # Line 1: item name (wrap within remaining width)
  298. name_str = (item_name or "—").strip()
  299. max_name_w = LABEL_IMAGE_W - x_right - LABEL_PADDING
  300. if font_name:
  301. # Wrap by text width (Pillow 8+ textbbox) or by char count
  302. def _wrap_text(text: str, font, max_width: int) -> list:
  303. if hasattr(draw, "textbbox"):
  304. words = list(text)
  305. lines, line = [], []
  306. for c in words:
  307. line.append(c)
  308. bbox = draw.textbbox((0, 0), "".join(line), font=font)
  309. if bbox[2] - bbox[0] > max_width and len(line) > 1:
  310. lines.append("".join(line[:-1]))
  311. line = [line[-1]]
  312. if line:
  313. lines.append("".join(line))
  314. return lines
  315. # Fallback: ~12 chars per line for Chinese
  316. chunk = 12
  317. return [text[i : i + chunk] for i in range(0, len(text), chunk)]
  318. lines = _wrap_text(name_str, font_name, max_name_w)
  319. for i, ln in enumerate(lines):
  320. draw.text((x_right, y_line + i * (LABEL_FONT_NAME_SIZE + 4)), ln, font=font_name, fill="black")
  321. y_line += len(lines) * (LABEL_FONT_NAME_SIZE + 4) + 8
  322. else:
  323. draw.text((x_right, y_line), name_str[:30], fill="black")
  324. y_line += LABEL_FONT_NAME_SIZE + 12
  325. # Item code (bigger)
  326. code_str = (item_code or "—").strip()
  327. if font_code:
  328. draw.text((x_right, y_line), code_str, font=font_code, fill="black")
  329. else:
  330. draw.text((x_right, y_line), code_str, fill="black")
  331. y_line += LABEL_FONT_CODE_SIZE + 6
  332. # Batch/lot line
  333. batch_str = (lot_no or batch_no or "—").strip()
  334. if font_batch:
  335. draw.text((x_right, y_line), batch_str, font=font_batch, fill="black")
  336. else:
  337. draw.text((x_right, y_line), batch_str, fill="black")
  338. return img
  339. def send_image_to_label_printer(printer_name: str, pil_image: "Image.Image") -> None:
  340. """
  341. Send a PIL Image to 標簽機 via Windows GDI (so Chinese and graphics print correctly).
  342. Only supported when target is a Windows printer name (not COM port). Requires pywin32.
  343. """
  344. dest = (printer_name or "").strip()
  345. if not dest:
  346. raise ValueError("Label printer destination is empty.")
  347. if os.name != "nt" or dest.upper().startswith("COM"):
  348. raise RuntimeError("Image printing is only supported for a Windows printer name (e.g. TSC TTP-246M Pro).")
  349. if win32print is None or win32ui is None or win32con is None or win32gui is None:
  350. raise RuntimeError("pywin32 is required. Run: pip install pywin32")
  351. # Draw image to printer DC via temp BMP (GDI uses BMP)
  352. with tempfile.NamedTemporaryFile(suffix=".bmp", delete=False) as f:
  353. tmp_bmp = f.name
  354. try:
  355. pil_image.save(tmp_bmp, "BMP")
  356. hbm = win32gui.LoadImage(
  357. 0, tmp_bmp, win32con.IMAGE_BITMAP, 0, 0,
  358. win32con.LR_LOADFROMFILE | win32con.LR_CREATEDIBSECTION,
  359. )
  360. if hbm == 0:
  361. raise RuntimeError("Failed to load label image as bitmap.")
  362. dc = win32ui.CreateDC()
  363. dc.CreatePrinterDC(dest)
  364. dc.StartDoc("FPSMS Label")
  365. dc.StartPage()
  366. try:
  367. mem_dc = win32ui.CreateDCFromHandle(win32gui.CreateCompatibleDC(dc.GetSafeHdc()))
  368. # PyCBitmap.FromHandle works across pywin32 versions
  369. bmp = getattr(win32ui, "CreateBitmapFromHandle", lambda h: win32ui.PyCBitmap.FromHandle(h))(hbm)
  370. mem_dc.SelectObject(bmp)
  371. bmp_w = pil_image.width
  372. bmp_h = pil_image.height
  373. dc.StretchBlt((0, 0), (bmp_w, bmp_h), mem_dc, (0, 0), (bmp_w, bmp_h), win32con.SRCCOPY)
  374. finally:
  375. win32gui.DeleteObject(hbm)
  376. dc.EndPage()
  377. dc.EndDoc()
  378. finally:
  379. try:
  380. os.unlink(tmp_bmp)
  381. except OSError:
  382. pass
  383. def run_dataflex_continuous_print(
  384. root: tk.Tk,
  385. ip: str,
  386. port: int,
  387. zpl: str,
  388. label_text: str,
  389. set_status_message: Callable[[str, bool], None],
  390. ) -> None:
  391. """
  392. Run DataFlex continuous print (up to 100) in a background thread.
  393. Shows a small window with "已列印: N" and a 停止 button; user can stop anytime.
  394. """
  395. stop_event = threading.Event()
  396. win = tk.Toplevel(root)
  397. win.title("打袋機 連續列印")
  398. win.geometry("320x140")
  399. win.transient(root)
  400. win.configure(bg=BG_TOP)
  401. tk.Label(win, text="連續列印中,按「停止」結束", font=get_font(FONT_SIZE), bg=BG_TOP).pack(pady=(12, 4))
  402. count_lbl = tk.Label(win, text="已列印: 0", font=get_font(FONT_SIZE), bg=BG_TOP)
  403. count_lbl.pack(pady=4)
  404. def on_stop():
  405. stop_event.set()
  406. ttk.Button(win, text="停止", command=on_stop, width=12).pack(pady=8)
  407. win.protocol("WM_DELETE_WINDOW", lambda: (stop_event.set(), win.destroy()))
  408. def worker():
  409. sent = 0
  410. try:
  411. for _ in range(100):
  412. if stop_event.is_set():
  413. break
  414. send_zpl_to_dataflex(ip, port, zpl)
  415. sent += 1
  416. root.after(0, lambda s=sent: count_lbl.configure(text=f"已列印: {s}"))
  417. if stop_event.is_set():
  418. break
  419. time.sleep(2)
  420. except ConnectionRefusedError:
  421. root.after(0, lambda: set_status_message(f"無法連線至 {ip}:{port},請確認印表機已開機且 IP 正確。", is_error=True))
  422. except socket.timeout:
  423. root.after(0, lambda: set_status_message(f"連線逾時 ({ip}:{port}),請檢查網路與連接埠。", is_error=True))
  424. except OSError as err:
  425. root.after(0, lambda e=err: set_status_message(f"列印失敗:{e}", is_error=True))
  426. else:
  427. if stop_event.is_set():
  428. root.after(0, lambda: set_status_message(f"已送出列印:批次 {label_text} x {sent} 張 (已停止)", is_error=False))
  429. else:
  430. root.after(0, lambda: set_status_message(f"已送出列印:批次 {label_text} x {sent} 張 (連續完成)", is_error=False))
  431. root.after(0, win.destroy)
  432. threading.Thread(target=worker, daemon=True).start()
  433. def send_zpl_to_dataflex(ip: str, port: int, zpl: str) -> None:
  434. """Send ZPL to DataFlex printer via TCP. Raises on connection/send error."""
  435. sock = socket.socket(socket.AF_INET, socket.SOCK_STREAM)
  436. sock.settimeout(DATAFLEX_SEND_TIMEOUT)
  437. try:
  438. sock.connect((ip, port))
  439. sock.sendall(zpl.encode("utf-8"))
  440. finally:
  441. sock.close()
  442. def send_zpl_to_label_printer(target: str, zpl: str) -> None:
  443. """
  444. Send ZPL to 標簽機.
  445. On Windows, if target is not a COM port (e.g. "TSC TTP-246M Pro"),
  446. send raw ZPL to the named Windows printer via the spooler.
  447. Otherwise, treat target as a serial COM port (original behaviour).
  448. """
  449. dest = (target or "").strip()
  450. if not dest:
  451. raise ValueError("Label printer destination is empty.")
  452. # Unicode (^CI28); send UTF-8 to 標簽機
  453. raw_bytes = zpl.encode("utf-8")
  454. # Windows printer name path (USB printer installed as normal printer)
  455. if os.name == "nt" and not dest.upper().startswith("COM"):
  456. if win32print is None:
  457. raise RuntimeError("pywin32 not installed. Run: pip install pywin32")
  458. handle = win32print.OpenPrinter(dest)
  459. try:
  460. job = win32print.StartDocPrinter(handle, 1, ("FPSMS Label", None, "RAW"))
  461. win32print.StartPagePrinter(handle)
  462. win32print.WritePrinter(handle, raw_bytes)
  463. win32print.EndPagePrinter(handle)
  464. win32print.EndDocPrinter(handle)
  465. finally:
  466. win32print.ClosePrinter(handle)
  467. return
  468. # Fallback: serial COM port
  469. if serial is None:
  470. raise RuntimeError("pyserial not installed. Run: pip install pyserial")
  471. ser = serial.Serial(dest, timeout=5)
  472. try:
  473. ser.write(raw_bytes)
  474. finally:
  475. ser.close()
  476. def load_laser_last_count() -> tuple[int, Optional[str]]:
  477. """Load last batch count and date from laser counter file. Returns (count, date_str)."""
  478. if not os.path.exists(LASER_COUNTER_FILE):
  479. return 0, None
  480. try:
  481. with open(LASER_COUNTER_FILE, "r", encoding="utf-8") as f:
  482. lines = f.read().strip().splitlines()
  483. if len(lines) >= 2:
  484. return int(lines[1].strip()), lines[0].strip()
  485. except Exception:
  486. pass
  487. return 0, None
  488. def save_laser_last_count(date_str: str, count: int) -> None:
  489. """Save laser batch count and date to file."""
  490. try:
  491. with open(LASER_COUNTER_FILE, "w", encoding="utf-8") as f:
  492. f.write(f"{date_str}\n{count}")
  493. except Exception:
  494. pass
  495. LASER_PUSH_INTERVAL = 2 # seconds between pushes (like sample script)
  496. def laser_push_loop(
  497. ip: str,
  498. port: int,
  499. stop_event: threading.Event,
  500. root: tk.Tk,
  501. on_error: Callable[[str], None],
  502. ) -> None:
  503. """
  504. Run in a background thread: persistent connection to EZCAD, push B{yymmdd}{count:03d};;
  505. every LASER_PUSH_INTERVAL seconds. Resets count each new day. Uses counter file.
  506. """
  507. conn = None
  508. push_count, last_saved_date = load_laser_last_count()
  509. while not stop_event.is_set():
  510. try:
  511. if conn is None:
  512. conn = socket.socket(socket.AF_INET, socket.SOCK_STREAM)
  513. conn.settimeout(0.4)
  514. conn.connect((ip, port))
  515. now = datetime.now()
  516. today_str = now.strftime("%y%m%d")
  517. if last_saved_date != today_str:
  518. push_count = 1
  519. last_saved_date = today_str
  520. batch = f"B{today_str}{push_count:03d}"
  521. reply = f"{batch};;"
  522. conn.sendall(reply.encode("utf-8"))
  523. save_laser_last_count(today_str, push_count)
  524. rlist, _, _ = select.select([conn], [], [], 0.4)
  525. if rlist:
  526. data = conn.recv(4096)
  527. if not data:
  528. conn.close()
  529. conn = None
  530. push_count += 1
  531. for _ in range(int(LASER_PUSH_INTERVAL * 2)):
  532. if stop_event.is_set():
  533. break
  534. time.sleep(0.5)
  535. except socket.timeout:
  536. pass
  537. except Exception as e:
  538. if conn:
  539. try:
  540. conn.close()
  541. except Exception:
  542. pass
  543. conn = None
  544. try:
  545. root.after(0, lambda msg=str(e): on_error(msg))
  546. except Exception:
  547. pass
  548. for _ in range(6):
  549. if stop_event.is_set():
  550. break
  551. time.sleep(0.5)
  552. if conn:
  553. try:
  554. conn.close()
  555. except Exception:
  556. pass
  557. def send_job_to_laser(
  558. conn_ref: list,
  559. ip: str,
  560. port: int,
  561. item_id: Optional[int],
  562. stock_in_line_id: Optional[int],
  563. item_code: str,
  564. item_name: str,
  565. ) -> tuple[bool, str]:
  566. """
  567. Send to laser. Standard format: {"itemId": xxx, "stockInLineId": xxx}.
  568. conn_ref: [socket or None] - reused across calls; closed only when switching printer.
  569. When both item_id and stock_in_line_id present, sends JSON; else fallback: 0;item_code;item_name;;
  570. Returns (success, message).
  571. """
  572. if item_id is not None and stock_in_line_id is not None:
  573. reply = json.dumps({"itemId": item_id, "stockInLineId": stock_in_line_id})
  574. else:
  575. code_str = (item_code or "").strip().replace(";", ",")
  576. name_str = (item_name or "").strip().replace(";", ",")
  577. reply = f"0;{code_str};{name_str};;"
  578. conn = conn_ref[0]
  579. try:
  580. if conn is None:
  581. conn = socket.socket(socket.AF_INET, socket.SOCK_STREAM)
  582. conn.settimeout(3.0)
  583. conn.connect((ip, port))
  584. conn_ref[0] = conn
  585. conn.settimeout(3.0)
  586. conn.sendall(reply.encode("utf-8"))
  587. conn.settimeout(0.5)
  588. try:
  589. data = conn.recv(4096)
  590. if data:
  591. ack = data.decode("utf-8", errors="ignore").strip().lower()
  592. if "receive" in ack and "invalid" not in ack:
  593. return True, f"已送出激光機:{reply}(已確認)"
  594. except socket.timeout:
  595. pass
  596. return True, f"已送出激光機:{reply}"
  597. except (ConnectionRefusedError, socket.timeout, OSError) as e:
  598. if conn_ref[0] is not None:
  599. try:
  600. conn_ref[0].close()
  601. except Exception:
  602. pass
  603. conn_ref[0] = None
  604. if isinstance(e, ConnectionRefusedError):
  605. return False, f"無法連線至 {ip}:{port},請確認激光機已開機且 IP 正確。"
  606. if isinstance(e, socket.timeout):
  607. return False, f"連線逾時 ({ip}:{port}),請檢查網路與連接埠。"
  608. return False, f"激光機送出失敗:{e}"
  609. def send_job_to_laser_with_retry(
  610. conn_ref: list,
  611. ip: str,
  612. port: int,
  613. item_id: Optional[int],
  614. stock_in_line_id: Optional[int],
  615. item_code: str,
  616. item_name: str,
  617. ) -> tuple[bool, str]:
  618. """Send job to laser; on failure, retry once. Returns (success, message)."""
  619. ok, msg = send_job_to_laser(conn_ref, ip, port, item_id, stock_in_line_id, item_code, item_name)
  620. if ok:
  621. return True, msg
  622. ok2, msg2 = send_job_to_laser(conn_ref, ip, port, item_id, stock_in_line_id, item_code, item_name)
  623. return ok2, msg2
  624. def format_qty(val) -> str:
  625. """Format quantity: integer without .0, with thousand separator."""
  626. if val is None:
  627. return "—"
  628. try:
  629. n = float(val)
  630. if n == int(n):
  631. return f"{int(n):,}"
  632. return f"{n:,.2f}".rstrip("0").rstrip(".")
  633. except (TypeError, ValueError):
  634. return str(val)
  635. def batch_no(year: int, job_order_id: int) -> str:
  636. """Batch no.: B + 4-digit year + jobOrderId zero-padded to 6 digits."""
  637. return f"B{year}{job_order_id:06d}"
  638. def get_font(size: int = FONT_SIZE, bold: bool = False) -> tuple:
  639. try:
  640. return (FONT_FAMILY, size, "bold" if bold else "normal")
  641. except Exception:
  642. return ("TkDefaultFont", size, "bold" if bold else "normal")
  643. def fetch_job_orders(base_url: str, plan_start: date) -> list:
  644. """Call GET /py/job-orders and return the JSON list."""
  645. url = f"{base_url.rstrip('/')}/py/job-orders"
  646. params = {"planStart": plan_start.isoformat()}
  647. resp = requests.get(url, params=params, timeout=30)
  648. resp.raise_for_status()
  649. return resp.json()
  650. def set_row_highlight(row_frame: tk.Frame, selected: bool) -> None:
  651. """Set row and all its child widgets to selected or normal background."""
  652. bg = BG_ROW_SELECTED if selected else BG_ROW
  653. row_frame.configure(bg=bg)
  654. for w in row_frame.winfo_children():
  655. if isinstance(w, (tk.Frame, tk.Label)):
  656. w.configure(bg=bg)
  657. for c in w.winfo_children():
  658. if isinstance(c, tk.Label):
  659. c.configure(bg=bg)
  660. def on_job_order_click(jo: dict, batch: str) -> None:
  661. """Show message and highlight row (keeps printing to selected printer)."""
  662. item_code = jo.get("itemCode") or "—"
  663. item_name = jo.get("itemName") or "—"
  664. messagebox.showinfo(
  665. "工單",
  666. f'已點選:批次 {batch}\n品號 {item_code} {item_name}',
  667. )
  668. def ask_laser_count(parent: tk.Tk) -> Optional[int]:
  669. """
  670. When printer is 激光機, ask how many times to send (like DataFlex).
  671. Returns count (>= 1), or -1 for continuous (C), or None if cancelled.
  672. """
  673. result: list = [None]
  674. count_ref = [0]
  675. continuous_ref = [False]
  676. win = tk.Toplevel(parent)
  677. win.title("激光機送出數量")
  678. win.geometry("580x230") # wider so 連續 (C) button is fully visible
  679. win.transient(parent)
  680. win.grab_set()
  681. win.configure(bg=BG_TOP)
  682. ttk.Label(win, text="送出多少次?", font=get_font(FONT_SIZE)).pack(pady=(12, 4))
  683. count_lbl = tk.Label(win, text="數量: 0", font=get_font(FONT_SIZE), bg=BG_TOP)
  684. count_lbl.pack(pady=4)
  685. def update_display():
  686. if continuous_ref[0]:
  687. count_lbl.configure(text="數量: 連續 (C)")
  688. else:
  689. count_lbl.configure(text=f"數量: {count_ref[0]}")
  690. def add(n: int):
  691. continuous_ref[0] = False
  692. count_ref[0] = max(0, count_ref[0] + n)
  693. update_display()
  694. def set_continuous():
  695. continuous_ref[0] = True
  696. update_display()
  697. def confirm():
  698. if continuous_ref[0]:
  699. result[0] = -1
  700. elif count_ref[0] < 1:
  701. messagebox.showwarning("激光機", "請先按 +50、+10、+5 或 +1 選擇數量。", parent=win)
  702. return
  703. else:
  704. result[0] = count_ref[0]
  705. win.destroy()
  706. btn_row = tk.Frame(win, bg=BG_TOP)
  707. btn_row.pack(pady=8)
  708. for label, value in [("+50", 50), ("+10", 10), ("+5", 5), ("+1", 1)]:
  709. def make_add(v: int):
  710. return lambda: add(v)
  711. ttk.Button(btn_row, text=label, command=make_add(value), width=8).pack(side=tk.LEFT, padx=4)
  712. ttk.Button(btn_row, text="連續 (C)", command=set_continuous, width=12).pack(side=tk.LEFT, padx=4)
  713. ttk.Button(win, text="確認送出", command=confirm, width=14).pack(pady=12)
  714. win.protocol("WM_DELETE_WINDOW", win.destroy)
  715. win.wait_window()
  716. return result[0]
  717. def ask_label_count(parent: tk.Tk) -> Optional[int]:
  718. """
  719. When printer is 標簽機, ask how many labels to print (same style as 打袋機):
  720. +50, +10, +5, +1, C (continuous), then 確認送出.
  721. Returns count (>= 1), or -1 for continuous (C), or None if cancelled.
  722. """
  723. result: list[Optional[int]] = [None]
  724. count_ref = [0]
  725. continuous_ref = [False]
  726. win = tk.Toplevel(parent)
  727. win.title("標簽列印數量")
  728. # Wider so all buttons (especially 連續) are fully visible
  729. win.geometry("580x230")
  730. win.transient(parent)
  731. win.grab_set()
  732. win.configure(bg=BG_TOP)
  733. ttk.Label(win, text="列印多少張標簽?", font=get_font(FONT_SIZE)).pack(pady=(12, 4))
  734. count_lbl = tk.Label(win, text="列印數量: 0", font=get_font(FONT_SIZE), bg=BG_TOP)
  735. count_lbl.pack(pady=4)
  736. def update_display():
  737. if continuous_ref[0]:
  738. count_lbl.configure(text="列印數量: 連續 (C)")
  739. else:
  740. count_lbl.configure(text=f"列印數量: {count_ref[0]}")
  741. def add(n: int):
  742. continuous_ref[0] = False
  743. count_ref[0] = max(0, count_ref[0] + n)
  744. update_display()
  745. def set_continuous():
  746. continuous_ref[0] = True
  747. update_display()
  748. def confirm():
  749. if continuous_ref[0]:
  750. result[0] = -1
  751. elif count_ref[0] < 1:
  752. messagebox.showwarning("標簽機", "請先按 +50、+10、+5 或 +1 選擇數量。", parent=win)
  753. return
  754. else:
  755. result[0] = count_ref[0]
  756. win.destroy()
  757. btn_row1 = tk.Frame(win, bg=BG_TOP)
  758. btn_row1.pack(pady=8)
  759. for label, value in [("+50", 50), ("+10", 10), ("+5", 5), ("+1", 1)]:
  760. def make_add(v: int):
  761. return lambda: add(v)
  762. ttk.Button(btn_row1, text=label, command=make_add(value), width=8).pack(side=tk.LEFT, padx=4)
  763. ttk.Button(btn_row1, text="連續 (C)", command=set_continuous, width=12).pack(side=tk.LEFT, padx=4)
  764. ttk.Button(win, text="確認送出", command=confirm, width=14).pack(pady=12)
  765. win.protocol("WM_DELETE_WINDOW", win.destroy)
  766. win.wait_window()
  767. return result[0]
  768. def ask_bag_count(parent: tk.Tk) -> Optional[int]:
  769. """
  770. When printer is 打袋機 DataFlex, ask how many bags: +50, +10, +5, +1, C, then 確認送出.
  771. Returns count (>= 1), or -1 for continuous (C), or None if cancelled.
  772. """
  773. result: list[Optional[int]] = [None]
  774. count_ref = [0]
  775. continuous_ref = [False]
  776. win = tk.Toplevel(parent)
  777. win.title("打袋列印數量")
  778. win.geometry("580x230") # wider so 連續 (C) button is fully visible
  779. win.transient(parent)
  780. win.grab_set()
  781. win.configure(bg=BG_TOP)
  782. ttk.Label(win, text="列印多少個袋?", font=get_font(FONT_SIZE)).pack(pady=(12, 4))
  783. count_lbl = tk.Label(win, text="列印數量: 0", font=get_font(FONT_SIZE), bg=BG_TOP)
  784. count_lbl.pack(pady=4)
  785. def update_display():
  786. if continuous_ref[0]:
  787. count_lbl.configure(text="列印數量: 連續 (C)")
  788. else:
  789. count_lbl.configure(text=f"列印數量: {count_ref[0]}")
  790. def add(n: int):
  791. continuous_ref[0] = False
  792. count_ref[0] = max(0, count_ref[0] + n)
  793. update_display()
  794. def set_continuous():
  795. continuous_ref[0] = True
  796. update_display()
  797. def confirm():
  798. if continuous_ref[0]:
  799. result[0] = -1
  800. elif count_ref[0] < 1:
  801. messagebox.showwarning("打袋機", "請先按 +50、+10、+5 或 +1 選擇數量。", parent=win)
  802. return
  803. else:
  804. result[0] = count_ref[0]
  805. win.destroy()
  806. btn_row1 = tk.Frame(win, bg=BG_TOP)
  807. btn_row1.pack(pady=8)
  808. for label, value in [("+50", 50), ("+10", 10), ("+5", 5), ("+1", 1)]:
  809. def make_add(v: int):
  810. return lambda: add(v)
  811. ttk.Button(btn_row1, text=label, command=make_add(value), width=8).pack(side=tk.LEFT, padx=4)
  812. ttk.Button(btn_row1, text="連續 (C)", command=set_continuous, width=12).pack(side=tk.LEFT, padx=4)
  813. ttk.Button(win, text="確認送出", command=confirm, width=14).pack(pady=12)
  814. win.protocol("WM_DELETE_WINDOW", win.destroy)
  815. win.wait_window()
  816. return result[0]
  817. def main() -> None:
  818. settings = load_settings()
  819. base_url_ref = [build_base_url(settings["api_ip"], settings["api_port"])]
  820. root = tk.Tk()
  821. root.title("FP-MTMS Bag v1.1 打袋機")
  822. root.geometry("1120x960")
  823. root.minsize(480, 360)
  824. root.configure(bg=BG_ROOT)
  825. # Style: larger font for aged users; light blue theme
  826. style = ttk.Style()
  827. try:
  828. style.configure(".", font=get_font(FONT_SIZE), background=BG_TOP)
  829. style.configure("TButton", font=get_font(FONT_SIZE_BUTTONS), background=BG_TOP)
  830. style.configure("TLabel", font=get_font(FONT_SIZE), background=BG_TOP)
  831. style.configure("TEntry", font=get_font(FONT_SIZE))
  832. style.configure("TFrame", background=BG_TOP)
  833. except tk.TclError:
  834. pass
  835. # Status bar at top: connection state (no popup on error)
  836. status_frame = tk.Frame(root, bg=BG_STATUS_ERROR, padx=12, pady=6)
  837. status_frame.pack(fill=tk.X)
  838. status_lbl = tk.Label(
  839. status_frame,
  840. text="連接不到服務器",
  841. font=get_font(FONT_SIZE_BUTTONS),
  842. bg=BG_STATUS_ERROR,
  843. fg=FG_STATUS_ERROR,
  844. anchor=tk.CENTER,
  845. )
  846. status_lbl.pack(fill=tk.X)
  847. def set_status_ok():
  848. status_frame.configure(bg=BG_STATUS_OK)
  849. status_lbl.configure(text="連接正常", bg=BG_STATUS_OK, fg=FG_STATUS_OK)
  850. def set_status_error():
  851. status_frame.configure(bg=BG_STATUS_ERROR)
  852. status_lbl.configure(text="連接不到服務器", bg=BG_STATUS_ERROR, fg=FG_STATUS_ERROR)
  853. def set_status_message(msg: str, is_error: bool = False) -> None:
  854. """Show a message on the status bar."""
  855. if is_error:
  856. status_frame.configure(bg=BG_STATUS_ERROR)
  857. status_lbl.configure(text=msg, bg=BG_STATUS_ERROR, fg=FG_STATUS_ERROR)
  858. else:
  859. status_frame.configure(bg=BG_STATUS_OK)
  860. status_lbl.configure(text=msg, bg=BG_STATUS_OK, fg=FG_STATUS_OK)
  861. # Laser: keep connection open for repeated sends; close when switching away
  862. laser_conn_ref: list = [None]
  863. laser_thread_ref: list = [None]
  864. laser_stop_ref: list = [None]
  865. # Top: left [前一天] [date] [後一天] | right [printer dropdown]
  866. top = tk.Frame(root, padx=12, pady=12, bg=BG_TOP)
  867. top.pack(fill=tk.X)
  868. date_var = tk.StringVar(value=date.today().isoformat())
  869. printer_options = ["打袋機 DataFlex", "標簽機", "激光機"]
  870. printer_var = tk.StringVar(value=printer_options[0])
  871. def go_prev_day() -> None:
  872. try:
  873. d = date.fromisoformat(date_var.get().strip())
  874. date_var.set((d - timedelta(days=1)).isoformat())
  875. load_job_orders(from_user_date_change=True)
  876. except ValueError:
  877. date_var.set(date.today().isoformat())
  878. load_job_orders(from_user_date_change=True)
  879. def go_next_day() -> None:
  880. try:
  881. d = date.fromisoformat(date_var.get().strip())
  882. date_var.set((d + timedelta(days=1)).isoformat())
  883. load_job_orders(from_user_date_change=True)
  884. except ValueError:
  885. date_var.set(date.today().isoformat())
  886. load_job_orders(from_user_date_change=True)
  887. # 前一天 (previous day) with left arrow icon
  888. btn_prev = ttk.Button(top, text="◀ 前一天", command=go_prev_day)
  889. btn_prev.pack(side=tk.LEFT, padx=(0, 8))
  890. # Date field (no "日期:" label); shorter width
  891. date_entry = tk.Entry(
  892. top,
  893. textvariable=date_var,
  894. font=get_font(FONT_SIZE),
  895. width=10,
  896. bg="white",
  897. )
  898. date_entry.pack(side=tk.LEFT, padx=(0, 8), ipady=4)
  899. # 後一天 (next day) with right arrow icon
  900. btn_next = ttk.Button(top, text="後一天 ▶", command=go_next_day)
  901. btn_next.pack(side=tk.LEFT, padx=(0, 8))
  902. # Top right: Setup button + printer selection
  903. right_frame = tk.Frame(top, bg=BG_TOP)
  904. right_frame.pack(side=tk.RIGHT)
  905. ttk.Button(right_frame, text="設定", command=lambda: open_setup_window(root, settings, base_url_ref)).pack(
  906. side=tk.LEFT, padx=(0, 12)
  907. )
  908. # 列印機 label: green when printer connected, red when not (checked periodically)
  909. printer_status_lbl = tk.Label(
  910. right_frame,
  911. text="列印機:",
  912. font=get_font(FONT_SIZE),
  913. bg=BG_STATUS_ERROR,
  914. fg="black",
  915. padx=6,
  916. pady=2,
  917. )
  918. printer_status_lbl.pack(side=tk.LEFT, padx=(0, 4))
  919. printer_combo = ttk.Combobox(
  920. right_frame,
  921. textvariable=printer_var,
  922. values=printer_options,
  923. state="readonly",
  924. width=14,
  925. font=get_font(FONT_SIZE),
  926. )
  927. printer_combo.pack(side=tk.LEFT)
  928. printer_after_ref = [None]
  929. def set_printer_status_ok():
  930. printer_status_lbl.configure(bg=BG_STATUS_OK, fg=FG_STATUS_OK)
  931. def set_printer_status_error():
  932. printer_status_lbl.configure(bg=BG_STATUS_ERROR, fg=FG_STATUS_ERROR)
  933. def check_printer() -> None:
  934. if printer_after_ref[0] is not None:
  935. root.after_cancel(printer_after_ref[0])
  936. printer_after_ref[0] = None
  937. ok = try_printer_connection(printer_var.get(), settings)
  938. if ok:
  939. set_printer_status_ok()
  940. printer_after_ref[0] = root.after(PRINTER_CHECK_MS, check_printer)
  941. else:
  942. set_printer_status_error()
  943. printer_after_ref[0] = root.after(PRINTER_RETRY_MS, check_printer)
  944. def on_printer_selection_changed(*args) -> None:
  945. check_printer()
  946. if printer_var.get() != "激光機":
  947. if laser_stop_ref[0] is not None:
  948. laser_stop_ref[0].set()
  949. if laser_conn_ref[0] is not None:
  950. try:
  951. laser_conn_ref[0].close()
  952. except Exception:
  953. pass
  954. laser_conn_ref[0] = None
  955. printer_var.trace_add("write", on_printer_selection_changed)
  956. def open_setup_window(parent_win: tk.Tk, sett: dict, base_url_ref_list: list) -> None:
  957. """Modal setup: API IP/port, 打袋機/激光機 IP+port, 標簽機 COM port."""
  958. d = tk.Toplevel(parent_win)
  959. d.title("設定")
  960. d.geometry("440x520")
  961. d.transient(parent_win)
  962. d.grab_set()
  963. d.configure(bg=BG_TOP)
  964. f = tk.Frame(d, padx=16, pady=16, bg=BG_TOP)
  965. f.pack(fill=tk.BOTH, expand=True)
  966. grid_row = [0] # use list so inner function can update
  967. def _ensure_dot_in_entry(entry: tk.Entry) -> None:
  968. """Allow typing dot (.) in Entry when IME or layout blocks it (e.g. 192.168.17.27)."""
  969. def on_key(event):
  970. if event.keysym in ("period", "decimal"):
  971. pos = entry.index(tk.INSERT)
  972. entry.insert(tk.INSERT, ".")
  973. return "break"
  974. entry.bind("<KeyPress>", on_key)
  975. def add_section(label_text: str, key_ip: str | None, key_port: str | None, key_single: str | None):
  976. out = []
  977. ttk.Label(f, text=label_text, font=get_font(FONT_SIZE_BUTTONS)).grid(
  978. row=grid_row[0], column=0, columnspan=2, sticky=tk.W, pady=(8, 2)
  979. )
  980. grid_row[0] += 1
  981. if key_single:
  982. ttk.Label(
  983. f,
  984. text="列印機名稱 (Windows):",
  985. ).grid(
  986. row=grid_row[0],
  987. column=0,
  988. sticky=tk.W,
  989. pady=2,
  990. )
  991. var = tk.StringVar(value=sett.get(key_single, ""))
  992. e = tk.Entry(f, textvariable=var, width=22, font=get_font(FONT_SIZE), bg="white")
  993. e.grid(row=grid_row[0], column=1, sticky=tk.W, pady=2)
  994. _ensure_dot_in_entry(e)
  995. grid_row[0] += 1
  996. return [(key_single, var)]
  997. if key_ip:
  998. ttk.Label(f, text="IP:").grid(row=grid_row[0], column=0, sticky=tk.W, pady=2)
  999. var_ip = tk.StringVar(value=sett.get(key_ip, ""))
  1000. e_ip = tk.Entry(f, textvariable=var_ip, width=22, font=get_font(FONT_SIZE), bg="white")
  1001. e_ip.grid(row=grid_row[0], column=1, sticky=tk.W, pady=2)
  1002. _ensure_dot_in_entry(e_ip)
  1003. grid_row[0] += 1
  1004. out.append((key_ip, var_ip))
  1005. if key_port:
  1006. ttk.Label(f, text="Port:").grid(row=grid_row[0], column=0, sticky=tk.W, pady=2)
  1007. var_port = tk.StringVar(value=sett.get(key_port, ""))
  1008. e_port = tk.Entry(f, textvariable=var_port, width=12, font=get_font(FONT_SIZE), bg="white")
  1009. e_port.grid(row=grid_row[0], column=1, sticky=tk.W, pady=2)
  1010. _ensure_dot_in_entry(e_port)
  1011. grid_row[0] += 1
  1012. out.append((key_port, var_port))
  1013. return out
  1014. all_vars = []
  1015. all_vars.extend(add_section("API 伺服器", "api_ip", "api_port", None))
  1016. all_vars.extend(add_section("打袋機 DataFlex", "dabag_ip", "dabag_port", None))
  1017. all_vars.extend(add_section("激光機", "laser_ip", "laser_port", None))
  1018. all_vars.extend(add_section("標簽機 (USB)", None, None, "label_com"))
  1019. def on_save():
  1020. for key, var in all_vars:
  1021. sett[key] = var.get().strip()
  1022. save_settings(sett)
  1023. base_url_ref_list[0] = build_base_url(sett["api_ip"], sett["api_port"])
  1024. d.destroy()
  1025. btn_f = tk.Frame(d, bg=BG_TOP)
  1026. btn_f.pack(pady=12)
  1027. ttk.Button(btn_f, text="儲存", command=on_save).pack(side=tk.LEFT, padx=4)
  1028. ttk.Button(btn_f, text="取消", command=d.destroy).pack(side=tk.LEFT, padx=4)
  1029. d.wait_window()
  1030. job_orders_frame = tk.Frame(root, bg=BG_LIST)
  1031. job_orders_frame.pack(fill=tk.BOTH, expand=True, padx=12, pady=12)
  1032. # Scrollable area for buttons
  1033. canvas = tk.Canvas(job_orders_frame, highlightthickness=0, bg=BG_LIST)
  1034. scrollbar = ttk.Scrollbar(job_orders_frame, orient=tk.VERTICAL, command=canvas.yview)
  1035. inner = tk.Frame(canvas, bg=BG_LIST)
  1036. win_id = canvas.create_window((0, 0), window=inner, anchor=tk.NW)
  1037. canvas.configure(yscrollcommand=scrollbar.set)
  1038. def _on_inner_configure(event):
  1039. canvas.configure(scrollregion=canvas.bbox("all"))
  1040. def _on_canvas_configure(event):
  1041. canvas.itemconfig(win_id, width=event.width)
  1042. inner.bind("<Configure>", _on_inner_configure)
  1043. canvas.bind("<Configure>", _on_canvas_configure)
  1044. # Mouse wheel: make scroll work when hovering over canvas or the list (inner/buttons)
  1045. def _on_mousewheel(event):
  1046. if getattr(event, "delta", None) is not None:
  1047. canvas.yview_scroll(int(-1 * (event.delta / 120)), "units")
  1048. elif event.num == 5:
  1049. canvas.yview_scroll(1, "units")
  1050. elif event.num == 4:
  1051. canvas.yview_scroll(-1, "units")
  1052. canvas.bind("<MouseWheel>", _on_mousewheel)
  1053. inner.bind("<MouseWheel>", _on_mousewheel)
  1054. canvas.bind("<Button-4>", _on_mousewheel)
  1055. canvas.bind("<Button-5>", _on_mousewheel)
  1056. inner.bind("<Button-4>", _on_mousewheel)
  1057. inner.bind("<Button-5>", _on_mousewheel)
  1058. scrollbar.pack(side=tk.RIGHT, fill=tk.Y)
  1059. canvas.pack(side=tk.LEFT, fill=tk.BOTH, expand=True)
  1060. # Track which row is highlighted (selected for printing) and which job id
  1061. selected_row_holder = [None] # [tk.Frame | None]
  1062. selected_jo_id_ref = [None] # [int | None] job order id for selection preservation
  1063. last_data_ref = [None] # [list | None] last successful fetch for current date
  1064. after_id_ref = [None] # [str | None] root.after id to cancel retry/refresh
  1065. def _data_equal(a: Optional[list], b: Optional[list]) -> bool:
  1066. if a is None or b is None:
  1067. return a is b
  1068. if len(a) != len(b):
  1069. return False
  1070. ids_a = [x.get("id") for x in a]
  1071. ids_b = [x.get("id") for x in b]
  1072. return ids_a == ids_b
  1073. def _build_list_from_data(data: list, plan_start: date, preserve_selection: bool) -> None:
  1074. selected_row_holder[0] = None
  1075. year = plan_start.year
  1076. selected_id = selected_jo_id_ref[0] if preserve_selection else None
  1077. found_row = None
  1078. for jo in data:
  1079. jo_id = jo.get("id")
  1080. raw_batch = batch_no(year, jo_id) if jo_id is not None else "—"
  1081. lot_no_val = jo.get("lotNo")
  1082. batch = (lot_no_val or "—").strip() if lot_no_val else "—"
  1083. item_code = jo.get("itemCode") or "—"
  1084. item_name = jo.get("itemName") or "—"
  1085. req_qty = jo.get("reqQty")
  1086. qty_str = format_qty(req_qty)
  1087. # Three columns: lotNo/batch+數量 | item code (own column) | item name (≥2× width, wraps in column)
  1088. row = tk.Frame(inner, bg=BG_ROW, relief=tk.RAISED, bd=2, cursor="hand2", padx=12, pady=10)
  1089. row.pack(fill=tk.X, pady=4)
  1090. left = tk.Frame(row, bg=BG_ROW)
  1091. left.pack(side=tk.LEFT, anchor=tk.NW)
  1092. batch_lbl = tk.Label(
  1093. left,
  1094. text=batch,
  1095. font=get_font(FONT_SIZE_BUTTONS),
  1096. bg=BG_ROW,
  1097. fg="black",
  1098. )
  1099. batch_lbl.pack(anchor=tk.W)
  1100. qty_lbl = None
  1101. if qty_str != "—":
  1102. qty_lbl = tk.Label(
  1103. left,
  1104. text=f"數量:{qty_str}",
  1105. font=get_font(FONT_SIZE_QTY),
  1106. bg=BG_ROW,
  1107. fg="black",
  1108. )
  1109. qty_lbl.pack(anchor=tk.W)
  1110. # Column 2: item code only, bigger font, wraps in its own column
  1111. code_lbl = tk.Label(
  1112. row,
  1113. text=item_code,
  1114. font=get_font(FONT_SIZE_ITEM_CODE),
  1115. bg=BG_ROW,
  1116. fg="black",
  1117. wraplength=ITEM_CODE_WRAP,
  1118. justify=tk.LEFT,
  1119. anchor=tk.NW,
  1120. )
  1121. code_lbl.pack(side=tk.LEFT, anchor=tk.NW, padx=(12, 8))
  1122. # Column 3: item name only, bigger font, at least double width, wraps under its own column
  1123. name_lbl = tk.Label(
  1124. row,
  1125. text=item_name or "—",
  1126. font=get_font(FONT_SIZE_ITEM_NAME),
  1127. bg=BG_ROW,
  1128. fg="black",
  1129. wraplength=ITEM_NAME_WRAP,
  1130. justify=tk.LEFT,
  1131. anchor=tk.NW,
  1132. )
  1133. name_lbl.pack(side=tk.LEFT, fill=tk.BOTH, expand=True, anchor=tk.NW)
  1134. def _on_click(e, j=jo, b=batch, r=row):
  1135. if selected_row_holder[0] is not None:
  1136. set_row_highlight(selected_row_holder[0], False)
  1137. set_row_highlight(r, True)
  1138. selected_row_holder[0] = r
  1139. selected_jo_id_ref[0] = j.get("id")
  1140. if printer_var.get() == "打袋機 DataFlex":
  1141. ip = (settings.get("dabag_ip") or "").strip()
  1142. port_str = (settings.get("dabag_port") or "3008").strip()
  1143. try:
  1144. port = int(port_str)
  1145. except ValueError:
  1146. port = 3008
  1147. if not ip:
  1148. messagebox.showerror("打袋機", "請在設定中填寫打袋機 DataFlex 的 IP。")
  1149. else:
  1150. count = ask_bag_count(root)
  1151. if count is not None:
  1152. item_code = j.get("itemCode") or "—"
  1153. item_name = j.get("itemName") or "—"
  1154. item_id = j.get("itemId")
  1155. stock_in_line_id = j.get("stockInLineId")
  1156. lot_no = j.get("lotNo")
  1157. zpl = generate_zpl_dataflex(
  1158. b,
  1159. item_code,
  1160. item_name,
  1161. item_id=item_id,
  1162. stock_in_line_id=stock_in_line_id,
  1163. lot_no=lot_no,
  1164. )
  1165. label_text = (lot_no or b).strip()
  1166. if count == -1:
  1167. run_dataflex_continuous_print(root, ip, port, zpl, label_text, set_status_message)
  1168. else:
  1169. n = count
  1170. try:
  1171. for i in range(n):
  1172. send_zpl_to_dataflex(ip, port, zpl)
  1173. if i < n - 1:
  1174. time.sleep(2)
  1175. set_status_message(f"已送出列印:批次 {label_text} x {n} 張", is_error=False)
  1176. except ConnectionRefusedError:
  1177. set_status_message(f"無法連線至 {ip}:{port},請確認印表機已開機且 IP 正確。", is_error=True)
  1178. except socket.timeout:
  1179. set_status_message(f"連線逾時 ({ip}:{port}),請檢查網路與連接埠。", is_error=True)
  1180. except OSError as err:
  1181. set_status_message(f"列印失敗:{err}", is_error=True)
  1182. elif printer_var.get() == "標簽機":
  1183. com = (settings.get("label_com") or "").strip()
  1184. if not com:
  1185. messagebox.showerror("標簽機", "請在設定中填寫標簽機名稱 (例如:TSC TTP-246M Pro)。")
  1186. else:
  1187. count = ask_label_count(root)
  1188. if count is not None:
  1189. item_code = j.get("itemCode") or "—"
  1190. item_name = j.get("itemName") or "—"
  1191. item_id = j.get("itemId")
  1192. stock_in_line_id = j.get("stockInLineId")
  1193. lot_no = j.get("lotNo")
  1194. n = 100 if count == -1 else count
  1195. try:
  1196. # Prefer image printing so Chinese displays correctly; words are bigger
  1197. if _HAS_PIL_QR and os.name == "nt" and not com.upper().startswith("COM"):
  1198. label_img = render_label_to_image(
  1199. b, item_code, item_name,
  1200. item_id=item_id, stock_in_line_id=stock_in_line_id,
  1201. lot_no=lot_no,
  1202. )
  1203. for i in range(n):
  1204. send_image_to_label_printer(com, label_img)
  1205. if i < n - 1:
  1206. time.sleep(0.5)
  1207. else:
  1208. zpl = generate_zpl_label_small(
  1209. b, item_code, item_name,
  1210. item_id=item_id, stock_in_line_id=stock_in_line_id,
  1211. lot_no=lot_no,
  1212. )
  1213. for i in range(n):
  1214. send_zpl_to_label_printer(com, zpl)
  1215. if i < n - 1:
  1216. time.sleep(0.5)
  1217. msg = f"已送出列印:{n} 張標簽" if count != -1 else f"已送出列印:{n} 張標簽 (連續)"
  1218. messagebox.showinfo("標簽機", msg)
  1219. except Exception as err:
  1220. messagebox.showerror("標簽機", f"列印失敗:{err}")
  1221. elif printer_var.get() == "激光機":
  1222. ip = (settings.get("laser_ip") or "").strip()
  1223. port_str = (settings.get("laser_port") or "45678").strip()
  1224. try:
  1225. port = int(port_str)
  1226. except ValueError:
  1227. port = 45678
  1228. if not ip:
  1229. set_status_message("請在設定中填寫激光機的 IP。", is_error=True)
  1230. else:
  1231. count = ask_laser_count(root)
  1232. if count is not None:
  1233. item_id = j.get("itemId")
  1234. stock_in_line_id = j.get("stockInLineId")
  1235. item_code_val = j.get("itemCode") or ""
  1236. item_name_val = j.get("itemName") or ""
  1237. n = 100 if count == -1 else count
  1238. sent = 0
  1239. for i in range(n):
  1240. ok, msg = send_job_to_laser_with_retry(
  1241. laser_conn_ref, ip, port,
  1242. item_id, stock_in_line_id,
  1243. item_code_val, item_name_val,
  1244. )
  1245. if ok:
  1246. sent += 1
  1247. else:
  1248. set_status_message(f"已送出 {sent} 次,第 {sent + 1} 次失敗:{msg}", is_error=True)
  1249. break
  1250. if i < n - 1:
  1251. time.sleep(0.2)
  1252. if sent == n:
  1253. set_status_message(f"已送出激光機:{sent} 次", is_error=False)
  1254. for w in (row, left, batch_lbl, code_lbl, name_lbl):
  1255. w.bind("<Button-1>", _on_click)
  1256. w.bind("<MouseWheel>", _on_mousewheel)
  1257. w.bind("<Button-4>", _on_mousewheel)
  1258. w.bind("<Button-5>", _on_mousewheel)
  1259. if qty_lbl is not None:
  1260. qty_lbl.bind("<Button-1>", _on_click)
  1261. qty_lbl.bind("<MouseWheel>", _on_mousewheel)
  1262. qty_lbl.bind("<Button-4>", _on_mousewheel)
  1263. qty_lbl.bind("<Button-5>", _on_mousewheel)
  1264. if preserve_selection and selected_id is not None and jo.get("id") == selected_id:
  1265. found_row = row
  1266. if found_row is not None:
  1267. set_row_highlight(found_row, True)
  1268. selected_row_holder[0] = found_row
  1269. def load_job_orders(from_user_date_change: bool = False) -> None:
  1270. if after_id_ref[0] is not None:
  1271. root.after_cancel(after_id_ref[0])
  1272. after_id_ref[0] = None
  1273. date_str = date_var.get().strip()
  1274. try:
  1275. plan_start = date.fromisoformat(date_str)
  1276. except ValueError:
  1277. messagebox.showerror("日期錯誤", f"請使用 yyyy-MM-dd 格式。目前:{date_str}")
  1278. return
  1279. if from_user_date_change:
  1280. selected_row_holder[0] = None
  1281. selected_jo_id_ref[0] = None
  1282. try:
  1283. data = fetch_job_orders(base_url_ref[0], plan_start)
  1284. except requests.RequestException:
  1285. set_status_error()
  1286. after_id_ref[0] = root.after(RETRY_MS, lambda: load_job_orders(from_user_date_change=False))
  1287. return
  1288. set_status_ok()
  1289. old_data = last_data_ref[0]
  1290. last_data_ref[0] = data
  1291. data_changed = not _data_equal(old_data, data)
  1292. if data_changed or from_user_date_change:
  1293. # Rebuild list: clear and rebuild from current data (last_data_ref already updated)
  1294. for w in inner.winfo_children():
  1295. w.destroy()
  1296. preserve = not from_user_date_change
  1297. _build_list_from_data(data, plan_start, preserve_selection=preserve)
  1298. if from_user_date_change:
  1299. canvas.yview_moveto(0)
  1300. after_id_ref[0] = root.after(REFRESH_MS, lambda: load_job_orders(from_user_date_change=False))
  1301. # Load default (today) on start; then start printer connection check
  1302. root.after(100, lambda: load_job_orders(from_user_date_change=True))
  1303. root.after(300, check_printer)
  1304. root.mainloop()
  1305. if __name__ == "__main__":
  1306. main()