No puede seleccionar más de 25 temas Los temas deben comenzar con una letra o número, pueden incluir guiones ('-') y pueden tener hasta 35 caracteres de largo.

hace 2 días
hace 3 días
hace 1 día
hace 3 días
hace 1 día
hace 3 días
hace 2 días
hace 2 días
hace 3 días
hace 2 días
hace 3 días
hace 2 días
hace 3 días
hace 2 días
hace 1 día
hace 2 días
hace 1 día
hace 2 días
hace 3 días
hace 1 día
hace 2 días
hace 1 día
hace 1 día
hace 1 día
hace 1 día
hace 1 día
hace 1 día
hace 1 día
hace 1 día
hace 1 día
hace 1 día
hace 3 días
hace 2 días
hace 1 día
hace 2 días
hace 1 día
hace 2 días
hace 1 día
hace 2 días
hace 1 día
hace 3 días
hace 1 día
hace 1 día
123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570571572573574575576577578579580581582583584585586587588589590591592593594595596597598599600601602603604605606607608609610611612613614615616617618619620621622623624625626627628629630631632633634635636637638639640641642643644645646647648649650651652653654655656657658659660661662663664665666667668669670671672673674675676677678679680681682683684685686687688689690691692693694695696697698699700701702703704705706707708709710711712713714715716717718719720721722723724725726727728729730731732733734735736737738739740741742743744745746747748749750751752753754755756757758759760761762763764765766767768769770771772773774775776777778779780781782783784785786787788789790791792793794795796797798799800801802
  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 socket
  11. import sys
  12. import time
  13. import tkinter as tk
  14. from datetime import date, timedelta
  15. from tkinter import messagebox, ttk
  16. from typing import Optional
  17. import requests
  18. try:
  19. import serial
  20. except ImportError:
  21. serial = None # type: ignore
  22. DEFAULT_BASE_URL = os.environ.get("FPSMS_BASE_URL", "http://localhost:8090/api")
  23. # When run as PyInstaller exe, save settings next to the exe; otherwise next to script
  24. if getattr(sys, "frozen", False):
  25. _SETTINGS_DIR = os.path.dirname(sys.executable)
  26. else:
  27. _SETTINGS_DIR = os.path.dirname(os.path.abspath(__file__))
  28. SETTINGS_FILE = os.path.join(_SETTINGS_DIR, "bag1_settings.json")
  29. DEFAULT_SETTINGS = {
  30. "api_ip": "localhost",
  31. "api_port": "8090",
  32. "dabag_ip": "",
  33. "dabag_port": "3008",
  34. "laser_ip": "",
  35. "laser_port": "9100",
  36. "label_com": "COM3",
  37. }
  38. def load_settings() -> dict:
  39. """Load settings from JSON file; return defaults if missing or invalid."""
  40. try:
  41. if os.path.isfile(SETTINGS_FILE):
  42. with open(SETTINGS_FILE, "r", encoding="utf-8") as f:
  43. data = json.load(f)
  44. return {**DEFAULT_SETTINGS, **data}
  45. except Exception:
  46. pass
  47. return dict(DEFAULT_SETTINGS)
  48. def save_settings(settings: dict) -> None:
  49. """Save settings to JSON file."""
  50. with open(SETTINGS_FILE, "w", encoding="utf-8") as f:
  51. json.dump(settings, f, indent=2, ensure_ascii=False)
  52. def build_base_url(api_ip: str, api_port: str) -> str:
  53. ip = (api_ip or "localhost").strip()
  54. port = (api_port or "8090").strip()
  55. return f"http://{ip}:{port}/api"
  56. def try_printer_connection(printer_name: str, sett: dict) -> bool:
  57. """Try to connect to the selected printer (TCP IP:port or COM). Returns True if OK."""
  58. if printer_name == "打袋機 DataFlex":
  59. ip = (sett.get("dabag_ip") or "").strip()
  60. port_str = (sett.get("dabag_port") or "9100").strip()
  61. if not ip:
  62. return False
  63. try:
  64. port = int(port_str)
  65. s = socket.create_connection((ip, port), timeout=PRINTER_SOCKET_TIMEOUT)
  66. s.close()
  67. return True
  68. except (socket.error, ValueError, OSError):
  69. return False
  70. if printer_name == "激光機":
  71. ip = (sett.get("laser_ip") or "").strip()
  72. port_str = (sett.get("laser_port") or "9100").strip()
  73. if not ip:
  74. return False
  75. try:
  76. port = int(port_str)
  77. s = socket.create_connection((ip, port), timeout=PRINTER_SOCKET_TIMEOUT)
  78. s.close()
  79. return True
  80. except (socket.error, ValueError, OSError):
  81. return False
  82. if printer_name == "標簽機":
  83. if serial is None:
  84. return False
  85. com = (sett.get("label_com") or "").strip()
  86. if not com:
  87. return False
  88. try:
  89. ser = serial.Serial(com, timeout=1)
  90. ser.close()
  91. return True
  92. except (serial.SerialException, OSError):
  93. return False
  94. return False
  95. # Larger font for aged users (point size)
  96. FONT_SIZE = 16
  97. FONT_SIZE_BUTTONS = 15
  98. FONT_SIZE_QTY = 12 # smaller for 數量 under batch no.
  99. FONT_SIZE_ITEM_CODE = 20 # item code (larger for readability)
  100. FONT_SIZE_ITEM_NAME = 26 # item name (bigger than item code)
  101. FONT_FAMILY = "Microsoft JhengHei UI" # Traditional Chinese; fallback to TkDefaultFont
  102. # Column widths: item code own column; item name at least double, wraps in its column
  103. ITEM_CODE_WRAP = 140 # item code column width (long codes wrap under code only)
  104. ITEM_NAME_WRAP = 640 # item name column (double width), wraps under name only
  105. # Light blue theme (softer than pure grey)
  106. BG_TOP = "#E8F4FC"
  107. BG_LIST = "#D4E8F7"
  108. BG_ROOT = "#E1F0FF"
  109. BG_ROW = "#C5E1F5"
  110. BG_ROW_SELECTED = "#6BB5FF" # highlighted when selected (for printing)
  111. # Connection status bar
  112. BG_STATUS_ERROR = "#FFCCCB" # red background when disconnected
  113. FG_STATUS_ERROR = "#B22222" # red text
  114. BG_STATUS_OK = "#90EE90" # light green when connected
  115. FG_STATUS_OK = "#006400" # green text
  116. RETRY_MS = 30 * 1000 # 30 seconds reconnect
  117. REFRESH_MS = 60 * 1000 # 60 seconds refresh when connected
  118. PRINTER_CHECK_MS = 60 * 1000 # 1 minute when printer OK
  119. PRINTER_RETRY_MS = 30 * 1000 # 30 seconds when printer failed
  120. PRINTER_SOCKET_TIMEOUT = 3
  121. DATAFLEX_SEND_TIMEOUT = 10 # seconds when sending ZPL to DataFlex
  122. DATE_AUTO_RESET_SEC = 5 * 60 # 5 minutes: if no manual date change, auto-set to today
  123. def _zpl_escape(s: str) -> str:
  124. """Escape text for ZPL ^FD...^FS (backslash and caret)."""
  125. return s.replace("\\", "\\\\").replace("^", "\\^")
  126. def _split_by_word_count(text: str, max_words: int = 8) -> list[str]:
  127. """Split text into segments of at most max_words (words = non-symbol chars; symbols not counted)."""
  128. segments = []
  129. current = []
  130. count = 0
  131. for c in text:
  132. if c.isalnum() or ("\u4e00" <= c <= "\u9fff") or ("\u3400" <= c <= "\u4dbf"):
  133. count += 1
  134. current.append(c)
  135. if count >= max_words:
  136. segments.append("".join(current))
  137. current = []
  138. count = 0
  139. else:
  140. current.append(c)
  141. if current:
  142. segments.append("".join(current))
  143. return segments if segments else [""]
  144. def generate_zpl_dataflex(
  145. batch_no: str,
  146. item_code: str,
  147. item_name: str,
  148. font_regular: str = "E:STXihei.ttf",
  149. font_bold: str = "E:STXihei.ttf",
  150. ) -> str:
  151. """
  152. Row 1 (from zero): QR code, then item name (rotated 90°).
  153. Row 2: Batch number (left), item code (right).
  154. """
  155. desc = _zpl_escape((item_name or "—").strip())
  156. code = _zpl_escape((item_code or "—").strip())
  157. batch_esc = _zpl_escape((batch_no or "").strip())
  158. return f"""^XA
  159. ^CI28
  160. ^PW700
  161. ^LL500
  162. ^PO N
  163. ^FO10,20
  164. ^BQR,4,7^FDQA,{batch_no}^FS
  165. ^FO170,20
  166. ^A@R,72,72,{font_regular}^FD{desc}^FS
  167. ^FO0,260
  168. ^A@R,72,72,{font_regular}^FD{batch_esc}^FS
  169. ^FO75,260
  170. ^A@R,88,88,{font_bold}^FD{code}^FS
  171. ^XZ"""
  172. def send_zpl_to_dataflex(ip: str, port: int, zpl: str) -> None:
  173. """Send ZPL to DataFlex printer via TCP. Raises on connection/send error."""
  174. sock = socket.socket(socket.AF_INET, socket.SOCK_STREAM)
  175. sock.settimeout(DATAFLEX_SEND_TIMEOUT)
  176. try:
  177. sock.connect((ip, port))
  178. sock.sendall(zpl.encode("utf-8"))
  179. finally:
  180. sock.close()
  181. def format_qty(val) -> str:
  182. """Format quantity: integer without .0, with thousand separator."""
  183. if val is None:
  184. return "—"
  185. try:
  186. n = float(val)
  187. if n == int(n):
  188. return f"{int(n):,}"
  189. return f"{n:,.2f}".rstrip("0").rstrip(".")
  190. except (TypeError, ValueError):
  191. return str(val)
  192. def batch_no(year: int, job_order_id: int) -> str:
  193. """Batch no.: B + 2-digit year + jobOrderId zero-padded to 6 digits."""
  194. short_year = year % 100
  195. return f"B{short_year:02d}{job_order_id:06d}"
  196. def get_font(size: int = FONT_SIZE, bold: bool = False) -> tuple:
  197. try:
  198. return (FONT_FAMILY, size, "bold" if bold else "normal")
  199. except Exception:
  200. return ("TkDefaultFont", size, "bold" if bold else "normal")
  201. def fetch_job_orders(base_url: str, plan_start: date) -> list:
  202. """Call GET /py/job-orders and return the JSON list."""
  203. url = f"{base_url.rstrip('/')}/py/job-orders"
  204. params = {"planStart": plan_start.isoformat()}
  205. resp = requests.get(url, params=params, timeout=30)
  206. resp.raise_for_status()
  207. return resp.json()
  208. def set_row_highlight(row_frame: tk.Frame, selected: bool) -> None:
  209. """Set row and all its child widgets to selected or normal background."""
  210. bg = BG_ROW_SELECTED if selected else BG_ROW
  211. row_frame.configure(bg=bg)
  212. for w in row_frame.winfo_children():
  213. if isinstance(w, (tk.Frame, tk.Label)):
  214. w.configure(bg=bg)
  215. for c in w.winfo_children():
  216. if isinstance(c, tk.Label):
  217. c.configure(bg=bg)
  218. def on_job_order_click(jo: dict, batch: str) -> None:
  219. """Row click handler (highlight already set; no popup)."""
  220. def ask_bag_count(parent: tk.Tk) -> Optional[int]:
  221. """
  222. When printer is 打袋機 DataFlex, ask how many bags: +50, +10, +5, +1, C, then 確認送出.
  223. Returns count (>= 1), or -1 for continuous (C), or None if cancelled.
  224. """
  225. result: list[Optional[int]] = [None]
  226. count_ref = [0]
  227. continuous_ref = [False]
  228. win = tk.Toplevel(parent)
  229. win.title("打袋列印數量")
  230. win.geometry("420x200")
  231. win.transient(parent)
  232. win.grab_set()
  233. win.configure(bg=BG_TOP)
  234. ttk.Label(win, text="列印多少個袋?", font=get_font(FONT_SIZE)).pack(pady=(12, 4))
  235. count_lbl = tk.Label(win, text="列印數量: 0", font=get_font(FONT_SIZE), bg=BG_TOP)
  236. count_lbl.pack(pady=4)
  237. def update_display():
  238. if continuous_ref[0]:
  239. count_lbl.configure(text="列印數量: 連續 (C)")
  240. else:
  241. count_lbl.configure(text=f"列印數量: {count_ref[0]}")
  242. def add(n: int):
  243. continuous_ref[0] = False
  244. count_ref[0] = max(0, count_ref[0] + n)
  245. update_display()
  246. def set_continuous():
  247. continuous_ref[0] = True
  248. update_display()
  249. def confirm():
  250. if continuous_ref[0]:
  251. result[0] = -1
  252. elif count_ref[0] < 1:
  253. messagebox.showwarning("打袋機", "請先按 +50、+10、+5 或 +1 選擇數量。", parent=win)
  254. return
  255. else:
  256. result[0] = count_ref[0]
  257. win.destroy()
  258. btn_row1 = tk.Frame(win, bg=BG_TOP)
  259. btn_row1.pack(pady=8)
  260. for label, value in [("+50", 50), ("+10", 10), ("+5", 5), ("+1", 1)]:
  261. def make_add(v: int):
  262. return lambda: add(v)
  263. ttk.Button(btn_row1, text=label, command=make_add(value), width=8).pack(side=tk.LEFT, padx=4)
  264. ttk.Button(btn_row1, text="C", command=set_continuous, width=8).pack(side=tk.LEFT, padx=4)
  265. ttk.Button(win, text="確認送出", command=confirm, width=14).pack(pady=12)
  266. win.protocol("WM_DELETE_WINDOW", win.destroy)
  267. win.wait_window()
  268. return result[0]
  269. def ask_label_count(parent: tk.Tk) -> Optional[str]:
  270. """
  271. When printer is 標簽機, ask how many labels to print.
  272. Returns "1", "10", "50", "100", "C" (continuous), or None if cancelled.
  273. """
  274. result = [None] # mutable so inner callback can set it
  275. win = tk.Toplevel(parent)
  276. win.title("標簽列印數量")
  277. win.geometry("360x180")
  278. win.transient(parent)
  279. win.grab_set()
  280. win.configure(bg=BG_TOP)
  281. ttk.Label(win, text="列印多少張標簽?", font=get_font(FONT_SIZE)).pack(pady=(16, 12))
  282. btn_frame = tk.Frame(win, bg=BG_TOP)
  283. btn_frame.pack(pady=8)
  284. for label, value in [("1", "1"), ("10", "10"), ("50", "50"), ("100", "100"), ("連續 (C)", "C")]:
  285. def make_cmd(v):
  286. def cmd():
  287. result[0] = v
  288. win.destroy()
  289. return cmd
  290. ttk.Button(btn_frame, text=label, command=make_cmd(value), width=10).pack(side=tk.LEFT, padx=4)
  291. win.protocol("WM_DELETE_WINDOW", win.destroy)
  292. win.wait_window()
  293. return result[0]
  294. def main() -> None:
  295. settings = load_settings()
  296. base_url_ref = [build_base_url(settings["api_ip"], settings["api_port"])]
  297. root = tk.Tk()
  298. root.title("FP-MTMS Bag v1.1 打袋機")
  299. root.geometry("1120x960")
  300. root.minsize(480, 360)
  301. root.configure(bg=BG_ROOT)
  302. # Style: larger font for aged users; light blue theme
  303. style = ttk.Style()
  304. try:
  305. style.configure(".", font=get_font(FONT_SIZE), background=BG_TOP)
  306. style.configure("TButton", font=get_font(FONT_SIZE_BUTTONS), background=BG_TOP)
  307. style.configure("TLabel", font=get_font(FONT_SIZE), background=BG_TOP)
  308. style.configure("TEntry", font=get_font(FONT_SIZE))
  309. style.configure("TFrame", background=BG_TOP)
  310. except tk.TclError:
  311. pass
  312. # Status bar at top: connection state (no popup on error)
  313. status_frame = tk.Frame(root, bg=BG_STATUS_ERROR, padx=12, pady=6)
  314. status_frame.pack(fill=tk.X)
  315. status_lbl = tk.Label(
  316. status_frame,
  317. text="連接不到服務器",
  318. font=get_font(FONT_SIZE_BUTTONS),
  319. bg=BG_STATUS_ERROR,
  320. fg=FG_STATUS_ERROR,
  321. anchor=tk.CENTER,
  322. )
  323. status_lbl.pack(fill=tk.X)
  324. def set_status_ok():
  325. status_frame.configure(bg=BG_STATUS_OK)
  326. status_lbl.configure(text="連接正常", bg=BG_STATUS_OK, fg=FG_STATUS_OK)
  327. def set_status_error():
  328. status_frame.configure(bg=BG_STATUS_ERROR)
  329. status_lbl.configure(text="連接不到服務器", bg=BG_STATUS_ERROR, fg=FG_STATUS_ERROR)
  330. def set_status_message(msg: str, is_error: bool = False):
  331. """Show a temporary message on the status bar."""
  332. if is_error:
  333. status_frame.configure(bg=BG_STATUS_ERROR)
  334. status_lbl.configure(text=msg, bg=BG_STATUS_ERROR, fg=FG_STATUS_ERROR)
  335. else:
  336. status_frame.configure(bg=BG_STATUS_OK)
  337. status_lbl.configure(text=msg, bg=BG_STATUS_OK, fg=FG_STATUS_OK)
  338. # Top: left [前一天] [date] [後一天] | right [printer dropdown]
  339. top = tk.Frame(root, padx=12, pady=12, bg=BG_TOP)
  340. top.pack(fill=tk.X)
  341. date_var = tk.StringVar(value=date.today().isoformat())
  342. printer_options = ["打袋機 DataFlex", "標簽機", "激光機"]
  343. printer_var = tk.StringVar(value=printer_options[0])
  344. last_manual_date_change_ref = [time.time()] # track when user last changed date manually
  345. def mark_manual_date_change():
  346. last_manual_date_change_ref[0] = time.time()
  347. def go_prev_day() -> None:
  348. try:
  349. d = date.fromisoformat(date_var.get().strip())
  350. date_var.set((d - timedelta(days=1)).isoformat())
  351. mark_manual_date_change()
  352. load_job_orders(from_user_date_change=True)
  353. except ValueError:
  354. date_var.set(date.today().isoformat())
  355. mark_manual_date_change()
  356. load_job_orders(from_user_date_change=True)
  357. def go_next_day() -> None:
  358. try:
  359. d = date.fromisoformat(date_var.get().strip())
  360. date_var.set((d + timedelta(days=1)).isoformat())
  361. mark_manual_date_change()
  362. load_job_orders(from_user_date_change=True)
  363. except ValueError:
  364. date_var.set(date.today().isoformat())
  365. mark_manual_date_change()
  366. load_job_orders(from_user_date_change=True)
  367. # 前一天 (previous day) with left arrow icon
  368. btn_prev = ttk.Button(top, text="◀ 前一天", command=go_prev_day)
  369. btn_prev.pack(side=tk.LEFT, padx=(0, 8))
  370. # Date field (no "日期:" label); shorter width
  371. date_entry = tk.Entry(
  372. top,
  373. textvariable=date_var,
  374. font=get_font(FONT_SIZE),
  375. width=10,
  376. bg="white",
  377. )
  378. date_entry.pack(side=tk.LEFT, padx=(0, 8), ipady=4)
  379. # Track manual typing in date field as user date change
  380. def on_date_entry_key(event):
  381. mark_manual_date_change()
  382. date_entry.bind("<Key>", on_date_entry_key)
  383. # 後一天 (next day) with right arrow icon
  384. btn_next = ttk.Button(top, text="後一天 ▶", command=go_next_day)
  385. btn_next.pack(side=tk.LEFT, padx=(0, 8))
  386. # Top right: Setup button + printer selection
  387. right_frame = tk.Frame(top, bg=BG_TOP)
  388. right_frame.pack(side=tk.RIGHT)
  389. ttk.Button(right_frame, text="設定", command=lambda: open_setup_window(root, settings, base_url_ref)).pack(
  390. side=tk.LEFT, padx=(0, 12)
  391. )
  392. # 列印機 label: green when printer connected, red when not (checked periodically)
  393. printer_status_lbl = tk.Label(
  394. right_frame,
  395. text="列印機:",
  396. font=get_font(FONT_SIZE),
  397. bg=BG_STATUS_ERROR,
  398. fg="black",
  399. padx=6,
  400. pady=2,
  401. )
  402. printer_status_lbl.pack(side=tk.LEFT, padx=(0, 4))
  403. printer_combo = ttk.Combobox(
  404. right_frame,
  405. textvariable=printer_var,
  406. values=printer_options,
  407. state="readonly",
  408. width=14,
  409. font=get_font(FONT_SIZE),
  410. )
  411. printer_combo.pack(side=tk.LEFT)
  412. printer_after_ref = [None]
  413. def set_printer_status_ok():
  414. printer_status_lbl.configure(bg=BG_STATUS_OK, fg=FG_STATUS_OK)
  415. def set_printer_status_error():
  416. printer_status_lbl.configure(bg=BG_STATUS_ERROR, fg=FG_STATUS_ERROR)
  417. def check_printer() -> None:
  418. if printer_after_ref[0] is not None:
  419. root.after_cancel(printer_after_ref[0])
  420. printer_after_ref[0] = None
  421. ok = try_printer_connection(printer_var.get(), settings)
  422. if ok:
  423. set_printer_status_ok()
  424. printer_after_ref[0] = root.after(PRINTER_CHECK_MS, check_printer)
  425. else:
  426. set_printer_status_error()
  427. printer_after_ref[0] = root.after(PRINTER_RETRY_MS, check_printer)
  428. def on_printer_selection_changed(*args) -> None:
  429. check_printer()
  430. printer_var.trace_add("write", on_printer_selection_changed)
  431. def open_setup_window(parent_win: tk.Tk, sett: dict, base_url_ref_list: list) -> None:
  432. """Modal setup: API IP/port, 打袋機/激光機 IP+port, 標簽機 COM port."""
  433. d = tk.Toplevel(parent_win)
  434. d.title("設定")
  435. d.geometry("440x520")
  436. d.transient(parent_win)
  437. d.grab_set()
  438. d.configure(bg=BG_TOP)
  439. f = tk.Frame(d, padx=16, pady=16, bg=BG_TOP)
  440. f.pack(fill=tk.BOTH, expand=True)
  441. grid_row = [0] # use list so inner function can update
  442. def _ensure_dot_in_entry(entry: tk.Entry) -> None:
  443. """Allow typing dot (.) in Entry when IME or layout blocks it (e.g. 192.168.17.27)."""
  444. def on_key(event):
  445. if event.keysym in ("period", "decimal"):
  446. pos = entry.index(tk.INSERT)
  447. entry.insert(tk.INSERT, ".")
  448. return "break"
  449. entry.bind("<KeyPress>", on_key)
  450. def add_section(label_text: str, key_ip: str | None, key_port: str | None, key_single: str | None):
  451. out = []
  452. ttk.Label(f, text=label_text, font=get_font(FONT_SIZE_BUTTONS)).grid(
  453. row=grid_row[0], column=0, columnspan=2, sticky=tk.W, pady=(8, 2)
  454. )
  455. grid_row[0] += 1
  456. if key_single:
  457. ttk.Label(f, text="COM:").grid(row=grid_row[0], column=0, sticky=tk.W, pady=2)
  458. var = tk.StringVar(value=sett.get(key_single, ""))
  459. e = tk.Entry(f, textvariable=var, width=14, font=get_font(FONT_SIZE), bg="white")
  460. e.grid(row=grid_row[0], column=1, sticky=tk.W, pady=2)
  461. _ensure_dot_in_entry(e)
  462. grid_row[0] += 1
  463. return [(key_single, var)]
  464. if key_ip:
  465. ttk.Label(f, text="IP:").grid(row=grid_row[0], column=0, sticky=tk.W, pady=2)
  466. var_ip = tk.StringVar(value=sett.get(key_ip, ""))
  467. e_ip = tk.Entry(f, textvariable=var_ip, width=22, font=get_font(FONT_SIZE), bg="white")
  468. e_ip.grid(row=grid_row[0], column=1, sticky=tk.W, pady=2)
  469. _ensure_dot_in_entry(e_ip)
  470. grid_row[0] += 1
  471. out.append((key_ip, var_ip))
  472. if key_port:
  473. ttk.Label(f, text="Port:").grid(row=grid_row[0], column=0, sticky=tk.W, pady=2)
  474. var_port = tk.StringVar(value=sett.get(key_port, ""))
  475. e_port = tk.Entry(f, textvariable=var_port, width=12, font=get_font(FONT_SIZE), bg="white")
  476. e_port.grid(row=grid_row[0], column=1, sticky=tk.W, pady=2)
  477. _ensure_dot_in_entry(e_port)
  478. grid_row[0] += 1
  479. out.append((key_port, var_port))
  480. return out
  481. all_vars = []
  482. all_vars.extend(add_section("API 伺服器", "api_ip", "api_port", None))
  483. all_vars.extend(add_section("打袋機 DataFlex", "dabag_ip", "dabag_port", None))
  484. all_vars.extend(add_section("激光機", "laser_ip", "laser_port", None))
  485. all_vars.extend(add_section("標簽機 COM 埠", None, None, "label_com"))
  486. def on_save():
  487. for key, var in all_vars:
  488. sett[key] = var.get().strip()
  489. save_settings(sett)
  490. base_url_ref_list[0] = build_base_url(sett["api_ip"], sett["api_port"])
  491. d.destroy()
  492. btn_f = tk.Frame(d, bg=BG_TOP)
  493. btn_f.pack(pady=12)
  494. ttk.Button(btn_f, text="儲存", command=on_save).pack(side=tk.LEFT, padx=4)
  495. ttk.Button(btn_f, text="取消", command=d.destroy).pack(side=tk.LEFT, padx=4)
  496. d.wait_window()
  497. job_orders_frame = tk.Frame(root, bg=BG_LIST)
  498. job_orders_frame.pack(fill=tk.BOTH, expand=True, padx=12, pady=12)
  499. # Scrollable area for buttons
  500. canvas = tk.Canvas(job_orders_frame, highlightthickness=0, bg=BG_LIST)
  501. scrollbar = ttk.Scrollbar(job_orders_frame, orient=tk.VERTICAL, command=canvas.yview)
  502. inner = tk.Frame(canvas, bg=BG_LIST)
  503. win_id = canvas.create_window((0, 0), window=inner, anchor=tk.NW)
  504. canvas.configure(yscrollcommand=scrollbar.set)
  505. def _on_inner_configure(event):
  506. canvas.configure(scrollregion=canvas.bbox("all"))
  507. def _on_canvas_configure(event):
  508. canvas.itemconfig(win_id, width=event.width)
  509. inner.bind("<Configure>", _on_inner_configure)
  510. canvas.bind("<Configure>", _on_canvas_configure)
  511. # Mouse wheel: make scroll work when hovering over canvas or the list (inner/buttons)
  512. def _on_mousewheel(event):
  513. if getattr(event, "delta", None) is not None:
  514. canvas.yview_scroll(int(-1 * (event.delta / 120)), "units")
  515. elif event.num == 5:
  516. canvas.yview_scroll(1, "units")
  517. elif event.num == 4:
  518. canvas.yview_scroll(-1, "units")
  519. canvas.bind("<MouseWheel>", _on_mousewheel)
  520. inner.bind("<MouseWheel>", _on_mousewheel)
  521. canvas.bind("<Button-4>", _on_mousewheel)
  522. canvas.bind("<Button-5>", _on_mousewheel)
  523. inner.bind("<Button-4>", _on_mousewheel)
  524. inner.bind("<Button-5>", _on_mousewheel)
  525. scrollbar.pack(side=tk.RIGHT, fill=tk.Y)
  526. canvas.pack(side=tk.LEFT, fill=tk.BOTH, expand=True)
  527. # Track which row is highlighted (selected for printing) and which job id
  528. selected_row_holder = [None] # [tk.Frame | None]
  529. selected_jo_id_ref = [None] # [int | None] job order id for selection preservation
  530. last_data_ref = [None] # [list | None] last successful fetch for current date
  531. after_id_ref = [None] # [str | None] root.after id to cancel retry/refresh
  532. def _data_equal(a: Optional[list], b: Optional[list]) -> bool:
  533. if a is None or b is None:
  534. return a is b
  535. if len(a) != len(b):
  536. return False
  537. ids_a = [x.get("id") for x in a]
  538. ids_b = [x.get("id") for x in b]
  539. return ids_a == ids_b
  540. def _build_list_from_data(data: list, plan_start: date, preserve_selection: bool) -> None:
  541. selected_row_holder[0] = None
  542. year = plan_start.year
  543. selected_id = selected_jo_id_ref[0] if preserve_selection else None
  544. found_row = None
  545. for jo in data:
  546. jo_id = jo.get("id")
  547. batch = batch_no(year, jo_id) if jo_id is not None else "—"
  548. item_code = jo.get("itemCode") or "—"
  549. item_name = jo.get("itemName") or "—"
  550. req_qty = jo.get("reqQty")
  551. qty_str = format_qty(req_qty)
  552. # Three columns: batch+數量 | item code (own column) | item name (≥2× width, wraps in column)
  553. row = tk.Frame(inner, bg=BG_ROW, relief=tk.RAISED, bd=2, cursor="hand2", padx=12, pady=10)
  554. row.pack(fill=tk.X, pady=4)
  555. left = tk.Frame(row, bg=BG_ROW)
  556. left.pack(side=tk.LEFT, anchor=tk.NW)
  557. batch_lbl = tk.Label(
  558. left,
  559. text=batch,
  560. font=get_font(FONT_SIZE_BUTTONS),
  561. bg=BG_ROW,
  562. fg="black",
  563. )
  564. batch_lbl.pack(anchor=tk.W)
  565. qty_lbl = None
  566. if qty_str != "—":
  567. qty_lbl = tk.Label(
  568. left,
  569. text=f"數量:{qty_str}",
  570. font=get_font(FONT_SIZE_QTY),
  571. bg=BG_ROW,
  572. fg="black",
  573. )
  574. qty_lbl.pack(anchor=tk.W)
  575. # Column 2: item code only, bigger font, wraps in its own column
  576. code_lbl = tk.Label(
  577. row,
  578. text=item_code,
  579. font=get_font(FONT_SIZE_ITEM_CODE),
  580. bg=BG_ROW,
  581. fg="black",
  582. wraplength=ITEM_CODE_WRAP,
  583. justify=tk.LEFT,
  584. anchor=tk.NW,
  585. )
  586. code_lbl.pack(side=tk.LEFT, anchor=tk.NW, padx=(12, 8))
  587. # Column 3: item name only, bigger font, at least double width, wraps under its own column
  588. name_lbl = tk.Label(
  589. row,
  590. text=item_name or "—",
  591. font=get_font(FONT_SIZE_ITEM_NAME),
  592. bg=BG_ROW,
  593. fg="black",
  594. wraplength=ITEM_NAME_WRAP,
  595. justify=tk.LEFT,
  596. anchor=tk.NW,
  597. )
  598. name_lbl.pack(side=tk.LEFT, fill=tk.BOTH, expand=True, anchor=tk.NW)
  599. def _on_click(e, j=jo, b=batch, r=row):
  600. if selected_row_holder[0] is not None:
  601. set_row_highlight(selected_row_holder[0], False)
  602. set_row_highlight(r, True)
  603. selected_row_holder[0] = r
  604. selected_jo_id_ref[0] = j.get("id")
  605. if printer_var.get() == "打袋機 DataFlex":
  606. ip = (settings.get("dabag_ip") or "").strip()
  607. port_str = (settings.get("dabag_port") or "3008").strip()
  608. try:
  609. port = int(port_str)
  610. except ValueError:
  611. port = 3008
  612. if not ip:
  613. messagebox.showerror("打袋機", "請在設定中填寫打袋機 DataFlex 的 IP。")
  614. else:
  615. count = ask_bag_count(root)
  616. if count is not None:
  617. item_code = j.get("itemCode") or "—"
  618. item_name = j.get("itemName") or "—"
  619. zpl = generate_zpl_dataflex(b, item_code, item_name)
  620. n = 100 if count == -1 else count
  621. try:
  622. for i in range(n):
  623. send_zpl_to_dataflex(ip, port, zpl)
  624. if i < n - 1:
  625. time.sleep(2)
  626. msg = f"已送出列印:批次 {b} x {n} 張" if count != -1 else f"已送出列印:批次 {b} x {n} 張 (連續)"
  627. set_status_message(msg, is_error=False)
  628. except ConnectionRefusedError:
  629. set_status_message(f"無法連線至 {ip}:{port},請確認印表機已開機且 IP 正確。", is_error=True)
  630. except socket.timeout:
  631. set_status_message(f"連線逾時 ({ip}:{port}),請檢查網路與連接埠。", is_error=True)
  632. except OSError as err:
  633. set_status_message(f"列印失敗:{err}", is_error=True)
  634. elif printer_var.get() == "標簽機":
  635. count = ask_label_count(root)
  636. if count is not None:
  637. if count == "C":
  638. msg = "已選擇連續列印標簽"
  639. else:
  640. msg = f"將列印 {count} 張標簽"
  641. set_status_message(msg, is_error=False)
  642. on_job_order_click(j, b)
  643. for w in (row, left, batch_lbl, code_lbl, name_lbl):
  644. w.bind("<Button-1>", _on_click)
  645. w.bind("<MouseWheel>", _on_mousewheel)
  646. w.bind("<Button-4>", _on_mousewheel)
  647. w.bind("<Button-5>", _on_mousewheel)
  648. if qty_lbl is not None:
  649. qty_lbl.bind("<Button-1>", _on_click)
  650. qty_lbl.bind("<MouseWheel>", _on_mousewheel)
  651. qty_lbl.bind("<Button-4>", _on_mousewheel)
  652. qty_lbl.bind("<Button-5>", _on_mousewheel)
  653. if preserve_selection and selected_id is not None and jo.get("id") == selected_id:
  654. found_row = row
  655. if found_row is not None:
  656. set_row_highlight(found_row, True)
  657. selected_row_holder[0] = found_row
  658. def load_job_orders(from_user_date_change: bool = False) -> None:
  659. if after_id_ref[0] is not None:
  660. root.after_cancel(after_id_ref[0])
  661. after_id_ref[0] = None
  662. # Auto-reset date to today if user hasn't manually changed it recently (for 24x7 use)
  663. if not from_user_date_change:
  664. elapsed = time.time() - last_manual_date_change_ref[0]
  665. today_str = date.today().isoformat()
  666. if elapsed > DATE_AUTO_RESET_SEC and date_var.get().strip() != today_str:
  667. date_var.set(today_str)
  668. from_user_date_change = True # treat as date change to reset selection/scroll
  669. date_str = date_var.get().strip()
  670. try:
  671. plan_start = date.fromisoformat(date_str)
  672. except ValueError:
  673. messagebox.showerror("日期錯誤", f"請使用 yyyy-MM-dd 格式。目前:{date_str}")
  674. return
  675. if from_user_date_change:
  676. selected_row_holder[0] = None
  677. selected_jo_id_ref[0] = None
  678. try:
  679. data = fetch_job_orders(base_url_ref[0], plan_start)
  680. except requests.RequestException:
  681. set_status_error()
  682. after_id_ref[0] = root.after(RETRY_MS, lambda: load_job_orders(from_user_date_change=False))
  683. return
  684. set_status_ok()
  685. old_data = last_data_ref[0]
  686. last_data_ref[0] = data
  687. data_changed = not _data_equal(old_data, data)
  688. if data_changed or from_user_date_change:
  689. # Rebuild list: clear and rebuild from current data (last_data_ref already updated)
  690. for w in inner.winfo_children():
  691. w.destroy()
  692. preserve = not from_user_date_change
  693. _build_list_from_data(data, plan_start, preserve_selection=preserve)
  694. if from_user_date_change:
  695. canvas.yview_moveto(0)
  696. after_id_ref[0] = root.after(REFRESH_MS, lambda: load_job_orders(from_user_date_change=False))
  697. # Load default (today) on start; then start printer connection check
  698. root.after(100, lambda: load_job_orders(from_user_date_change=True))
  699. root.after(300, check_printer)
  700. root.mainloop()
  701. if __name__ == "__main__":
  702. main()