25'ten fazla konu seçemezsiniz Konular bir harf veya rakamla başlamalı, kısa çizgiler ('-') içerebilir ve en fazla 35 karakter uzunluğunda olabilir.

Bag3.py 86 KiB

2 hafta önce
1 hafta önce
2 hafta önce
1 hafta önce
2 hafta önce
1 hafta önce
2 hafta önce
1 gün önce
2 hafta önce
1 gün önce
2 hafta önce
1 gün önce
2 hafta önce
1 gün önce
1 hafta önce
2 hafta önce
1 hafta önce
1 hafta önce
1 gün önce
1 hafta önce
1 gün önce
1 hafta önce
1 gün önce
1 hafta önce
1 gün önce
1 hafta önce
1 gün önce
1 hafta önce
1 hafta önce
2 hafta önce
1 gün önce
2 hafta önce
1 hafta önce
2 hafta önce
1 gün önce
2 hafta önce
1 gün önce
2 hafta önce
1 gün önce
2 hafta önce
1 hafta önce
2 hafta önce
1 gün önce
2 hafta önce
1 gün önce
2 hafta önce
1 gün önce
2 hafta önce
1 hafta önce
2 hafta önce
1 hafta önce
2 hafta önce
1 hafta önce
2 hafta önce
1 hafta önce
2 hafta önce
1 hafta önce
2 hafta önce
1 hafta önce
2 hafta önce
1 hafta önce
1 gün önce
1 hafta önce
1 gün önce
1 hafta önce
1 gün önce
1 hafta önce
1 gün önce
1 hafta önce
1 gün önce
1 hafta önce
1 gün önce
1 hafta önce
1 gün önce
1 hafta önce
1 gün önce
1 hafta önce
2 hafta önce
1 gün önce
2 hafta önce
1 gün önce
2 hafta önce
1 gün önce
2 hafta önce
1 hafta önce
2 hafta önce
1 hafta önce
1 gün önce
1 hafta önce
1 gün önce
1 hafta önce
2 hafta önce
1 gün önce
1 hafta önce
1 hafta önce
1 hafta önce
1 hafta önce
1 gün önce
1 hafta önce
2 hafta önce
1 hafta önce
1 gün önce
1 hafta önce
1 gün önce
1 hafta önce
1 gün önce
2 hafta önce
1 gün önce
2 hafta önce
1 gün önce
2 hafta önce
12345678910111213141516171819202122232425262728293031323334353637383940414243444546474849505152535455565758596061626364656667686970717273747576777879808182838485868788899091929394959697989910010110210310410510610710810911011111211311411511611711811912012112212312412512612712812913013113213313413513613713813914014114214314414514614714814915015115215315415515615715815916016116216316416516616716816917017117217317417517617717817918018118218318418518618718818919019119219319419519619719819920020120220320420520620720820921021121221321421521621721821922022122222322422522622722822923023123223323423523623723823924024124224324424524624724824925025125225325425525625725825926026126226326426526626726826927027127227327427527627727827928028128228328428528628728828929029129229329429529629729829930030130230330430530630730830931031131231331431531631731831932032132232332432532632732832933033133233333433533633733833934034134234334434534634734834935035135235335435535635735835936036136236336436536636736836937037137237337437537637737837938038138238338438538638738838939039139239339439539639739839940040140240340440540640740840941041141241341441541641741841942042142242342442542642742842943043143243343443543643743843944044144244344444544644744844945045145245345445545645745845946046146246346446546646746846947047147247347447547647747847948048148248348448548648748848949049149249349449549649749849950050150250350450550650750850951051151251351451551651751851952052152252352452552652752852953053153253353453553653753853954054154254354454554654754854955055155255355455555655755855956056156256356456556656756856957057157257357457557657757857958058158258358458558658758858959059159259359459559659759859960060160260360460560660760860961061161261361461561661761861962062162262362462562662762862963063163263363463563663763863964064164264364464564664764864965065165265365465565665765865966066166266366466566666766866967067167267367467567667767867968068168268368468568668768868969069169269369469569669769869970070170270370470570670770870971071171271371471571671771871972072172272372472572672772872973073173273373473573673773873974074174274374474574674774874975075175275375475575675775875976076176276376476576676776876977077177277377477577677777877978078178278378478578678778878979079179279379479579679779879980080180280380480580680780880981081181281381481581681781881982082182282382482582682782882983083183283383483583683783883984084184284384484584684784884985085185285385485585685785885986086186286386486586686786886987087187287387487587687787887988088188288388488588688788888989089189289389489589689789889990090190290390490590690790890991091191291391491591691791891992092192292392492592692792892993093193293393493593693793893994094194294394494594694794894995095195295395495595695795895996096196296396496596696796896997097197297397497597697797897998098198298398498598698798898999099199299399499599699799899910001001100210031004100510061007100810091010101110121013101410151016101710181019102010211022102310241025102610271028102910301031103210331034103510361037103810391040104110421043104410451046104710481049105010511052105310541055105610571058105910601061106210631064106510661067106810691070107110721073107410751076107710781079108010811082108310841085108610871088108910901091109210931094109510961097109810991100110111021103110411051106110711081109111011111112111311141115111611171118111911201121112211231124112511261127112811291130113111321133113411351136113711381139114011411142114311441145114611471148114911501151115211531154115511561157115811591160116111621163116411651166116711681169117011711172117311741175117611771178117911801181118211831184118511861187118811891190119111921193119411951196119711981199120012011202120312041205120612071208120912101211121212131214121512161217121812191220122112221223122412251226122712281229123012311232123312341235123612371238123912401241124212431244124512461247124812491250125112521253125412551256125712581259126012611262126312641265126612671268126912701271127212731274127512761277127812791280128112821283128412851286128712881289129012911292129312941295129612971298129913001301130213031304130513061307130813091310131113121313131413151316131713181319132013211322132313241325132613271328132913301331133213331334133513361337133813391340134113421343134413451346134713481349135013511352135313541355135613571358135913601361136213631364136513661367136813691370137113721373137413751376137713781379138013811382138313841385138613871388138913901391139213931394139513961397139813991400140114021403140414051406140714081409141014111412141314141415141614171418141914201421142214231424142514261427142814291430143114321433143414351436143714381439144014411442144314441445144614471448144914501451145214531454145514561457145814591460146114621463146414651466146714681469147014711472147314741475147614771478147914801481148214831484148514861487148814891490149114921493149414951496149714981499150015011502150315041505150615071508150915101511151215131514151515161517151815191520152115221523152415251526152715281529153015311532153315341535153615371538153915401541154215431544154515461547154815491550155115521553155415551556155715581559156015611562156315641565156615671568156915701571157215731574157515761577157815791580158115821583158415851586158715881589159015911592159315941595159615971598159916001601160216031604160516061607160816091610161116121613161416151616161716181619162016211622162316241625162616271628162916301631163216331634163516361637163816391640164116421643164416451646164716481649165016511652165316541655165616571658165916601661166216631664166516661667166816691670167116721673167416751676167716781679168016811682168316841685168616871688168916901691169216931694169516961697169816991700170117021703170417051706170717081709171017111712171317141715171617171718171917201721172217231724172517261727172817291730173117321733173417351736173717381739174017411742174317441745174617471748174917501751175217531754175517561757175817591760176117621763176417651766176717681769177017711772177317741775177617771778177917801781178217831784178517861787178817891790179117921793179417951796179717981799180018011802180318041805180618071808180918101811181218131814181518161817181818191820182118221823182418251826182718281829183018311832183318341835183618371838183918401841184218431844184518461847184818491850185118521853185418551856185718581859186018611862186318641865186618671868186918701871187218731874187518761877187818791880188118821883188418851886188718881889189018911892189318941895189618971898189919001901190219031904190519061907190819091910191119121913191419151916191719181919192019211922192319241925192619271928192919301931193219331934193519361937193819391940194119421943194419451946194719481949195019511952195319541955195619571958195919601961196219631964196519661967196819691970197119721973197419751976197719781979198019811982198319841985198619871988198919901991199219931994199519961997199819992000200120022003200420052006200720082009201020112012201320142015201620172018201920202021202220232024202520262027202820292030203120322033203420352036203720382039204020412042204320442045204620472048204920502051205220532054205520562057205820592060206120622063206420652066206720682069207020712072207320742075207620772078207920802081208220832084208520862087208820892090209120922093209420952096209720982099210021012102210321042105210621072108210921102111211221132114211521162117211821192120212121222123212421252126212721282129213021312132213321342135213621372138213921402141214221432144214521462147214821492150215121522153215421552156215721582159216021612162216321642165216621672168216921702171217221732174217521762177217821792180218121822183218421852186218721882189219021912192219321942195219621972198219922002201220222032204220522062207220822092210221122122213221422152216221722182219222022212222222322242225222622272228222922302231223222332234223522362237223822392240224122422243224422452246224722482249225022512252225322542255225622572258225922602261226222632264226522662267226822692270227122722273227422752276227722782279
  1. #!/usr/bin/env python3
  2. """
  3. Bag3 v3.2 – FPSMS job orders by plan date (this file is the maintained version).
  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. Bag2 is kept as a separate legacy v2.x line; do not assume Bag2 matches Bag3.
  7. Run: python Bag3.py
  8. """
  9. import json
  10. import os
  11. import select
  12. import socket
  13. import sys
  14. import tempfile
  15. import threading
  16. import time
  17. import tkinter as tk
  18. from datetime import date, datetime, timedelta
  19. from tkinter import messagebox, ttk
  20. from typing import Callable, Optional, Tuple
  21. import requests
  22. try:
  23. import serial
  24. except ImportError:
  25. serial = None # type: ignore
  26. try:
  27. import win32print # type: ignore[import]
  28. import win32ui # type: ignore[import]
  29. import win32con # type: ignore[import]
  30. import win32gui # type: ignore[import]
  31. except ImportError:
  32. win32print = None # type: ignore[assignment]
  33. win32ui = None # type: ignore[assignment]
  34. win32con = None # type: ignore[assignment]
  35. win32gui = None # type: ignore[assignment]
  36. try:
  37. from PIL import Image, ImageDraw, ImageFont, ImageOps
  38. try:
  39. from PIL import ImageWin # type: ignore
  40. except Exception:
  41. ImageWin = None # type: ignore[assignment]
  42. import qrcode
  43. _HAS_PIL_QR = True
  44. except ImportError:
  45. Image = None # type: ignore[assignment]
  46. ImageDraw = None # type: ignore[assignment]
  47. ImageFont = None # type: ignore[assignment]
  48. ImageOps = None # type: ignore[assignment]
  49. ImageWin = None # type: ignore[assignment]
  50. qrcode = None # type: ignore[assignment]
  51. _HAS_PIL_QR = False
  52. APP_VERSION = "3.2"
  53. DEFAULT_BASE_URL = os.environ.get("FPSMS_BASE_URL", "http://localhost:8090/api")
  54. # When run as PyInstaller exe, save settings next to the exe; otherwise next to script
  55. if getattr(sys, "frozen", False):
  56. _SETTINGS_DIR = os.path.dirname(sys.executable)
  57. else:
  58. _SETTINGS_DIR = os.path.dirname(os.path.abspath(__file__))
  59. # Bag3 has its own settings file so it doesn't share with Bag1/Bag2.
  60. SETTINGS_FILE = os.path.join(_SETTINGS_DIR, "bag3_settings.json")
  61. LASER_COUNTER_FILE = os.path.join(_SETTINGS_DIR, "bag3_last_batch_count.txt")
  62. DEFAULT_SETTINGS = {
  63. "api_ip": "localhost",
  64. "api_port": "8090",
  65. "dabag_ip": "",
  66. "dabag_port": "3008",
  67. "laser_ip": "192.168.17.10",
  68. "laser_port": "45678",
  69. # For 標簽機 on Windows, this is the Windows printer name, e.g. "TSC TTP-246M Pro"
  70. "label_com": "TSC TTP-246M Pro",
  71. }
  72. def load_settings() -> dict:
  73. """Load settings from JSON file; return defaults if missing or invalid."""
  74. try:
  75. if os.path.isfile(SETTINGS_FILE):
  76. with open(SETTINGS_FILE, "r", encoding="utf-8") as f:
  77. data = json.load(f)
  78. return {**DEFAULT_SETTINGS, **data}
  79. except Exception:
  80. pass
  81. return dict(DEFAULT_SETTINGS)
  82. def save_settings(settings: dict) -> None:
  83. """Save settings to JSON file."""
  84. with open(SETTINGS_FILE, "w", encoding="utf-8") as f:
  85. json.dump(settings, f, indent=2, ensure_ascii=False)
  86. def build_base_url(api_ip: str, api_port: str) -> str:
  87. ip = (api_ip or "localhost").strip()
  88. port = (api_port or "8090").strip()
  89. return f"http://{ip}:{port}/api"
  90. def try_printer_connection(printer_name: str, sett: dict) -> bool:
  91. """Try to connect to the selected printer (TCP IP:port or COM). Returns True if OK."""
  92. if printer_name == "打袋機 DataFlex":
  93. ip = (sett.get("dabag_ip") or "").strip()
  94. port_str = (sett.get("dabag_port") or "9100").strip()
  95. if not ip:
  96. return False
  97. try:
  98. port = int(port_str)
  99. s = socket.create_connection((ip, port), timeout=PRINTER_SOCKET_TIMEOUT)
  100. s.close()
  101. return True
  102. except (socket.error, ValueError, OSError):
  103. return False
  104. if printer_name == "激光機":
  105. ip = (sett.get("laser_ip") or "").strip()
  106. port_str = (sett.get("laser_port") or "45678").strip()
  107. if not ip:
  108. return False
  109. try:
  110. port = int(port_str)
  111. s = socket.create_connection((ip, port), timeout=PRINTER_SOCKET_TIMEOUT)
  112. s.close()
  113. return True
  114. except (socket.error, ValueError, OSError):
  115. return False
  116. if printer_name == "標簽機":
  117. target = (sett.get("label_com") or "").strip()
  118. if not target:
  119. return False
  120. # On Windows, allow using a Windows printer name (e.g. "TSC TTP-246M Pro")
  121. # as an alternative to a COM port. If it doesn't look like a COM port,
  122. # try opening it via the Windows print spooler.
  123. if os.name == "nt" and not target.upper().startswith("COM"):
  124. if win32print is None:
  125. return False
  126. try:
  127. handle = win32print.OpenPrinter(target)
  128. win32print.ClosePrinter(handle)
  129. return True
  130. except Exception:
  131. return False
  132. # Fallback: treat as serial COM port (original behaviour)
  133. if serial is None:
  134. return False
  135. try:
  136. ser = serial.Serial(target, timeout=1)
  137. ser.close()
  138. return True
  139. except (serial.SerialException, OSError):
  140. return False
  141. return False
  142. # Larger font for aged users (point size)
  143. FONT_SIZE = 16
  144. FONT_SIZE_BUTTONS = 15
  145. # Printer selector: field + dropdown (use tk.OptionMenu so menu font is respected on Windows)
  146. FONT_SIZE_COMBO = 18
  147. FONT_SIZE_QTY = 12 # smaller for 需求數量 under batch no.
  148. FONT_SIZE_META = 11 # single-line 需求/已印 (compact list)
  149. # Less vertical padding so ~30 rows fit more comfortably
  150. LIST_ROW_PADY = 2
  151. LIST_ROW_IPADY = 5
  152. FONT_SIZE_ITEM = 20 # item code and item name (larger for readability)
  153. FONT_FAMILY = "Microsoft JhengHei UI" # Traditional Chinese; fallback to TkDefaultFont
  154. FONT_SIZE_ITEM_CODE = 20 # item code (larger for readability)
  155. FONT_SIZE_ITEM_NAME = 26 # item name (bigger than item code)
  156. # Column widths: fixed frame widths so 品號/品名 columns line up across rows
  157. LEFT_COL_WIDTH_PX = 300 # 工單 + 需求/已印 block
  158. ITEM_CODE_WRAP = 140 # Label wraplength (px)
  159. # Narrower than wrap+padding so short codes sit closer to 品名 (still aligned across rows)
  160. CODE_COL_WIDTH_PX = ITEM_CODE_WRAP + 6
  161. ITEM_NAME_WRAP = 640 # item name wraps in remaining space
  162. # Light blue theme (softer than pure grey)
  163. BG_TOP = "#E8F4FC"
  164. BG_LIST = "#D4E8F7"
  165. BG_ROOT = "#E1F0FF"
  166. BG_ROW = "#C5E1F5"
  167. BG_ROW_SELECTED = "#6BB5FF" # highlighted when selected (for printing)
  168. # Connection status bar
  169. BG_STATUS_ERROR = "#FFCCCB" # red background when disconnected
  170. FG_STATUS_ERROR = "#B22222" # red text
  171. BG_STATUS_OK = "#90EE90" # light green when connected
  172. FG_STATUS_OK = "#006400" # green text
  173. RETRY_MS = 30 * 1000 # 30 seconds reconnect
  174. REFRESH_MS = 60 * 1000 # 60 seconds refresh when connected
  175. PRINTER_CHECK_MS = 60 * 1000 # 1 minute when printer OK
  176. PRINTER_RETRY_MS = 30 * 1000 # 30 seconds when printer failed
  177. PRINTER_SOCKET_TIMEOUT = 3
  178. DATAFLEX_SEND_TIMEOUT = 10 # seconds when sending ZPL to DataFlex
  179. # Gap between bag labels on DataFlex (one TCP session per batch; tune if bags are skipped)
  180. DATAFLEX_INTER_LABEL_DELAY_SEC = 2.5
  181. # After ~JA/~RO/~JR recovery, let firmware finish before sending label ZPL (avoids stuck E1005)
  182. DATAFLEX_POST_RECOVERY_DELAY_SEC = 1.5
  183. # Zebra ~RO only (used when FPSMS_DATAFLEX_NO_JR is set)
  184. DATAFLEX_RESET_BYTES = b"~RO1\r\n~RO2\r\n"
  185. # Full host recovery: ~JA clear buffers, ~RO counters, ~JR soft reset (clears latched errors without power cycle)
  186. DATAFLEX_FULL_RECOVERY_BYTES = b"~JA\r\n~RO1\r\n~RO2\r\n~JR\r\n"
  187. def _dataflex_recovery_payload() -> bytes:
  188. """~JA+~RO+~JR by default; set env FPSMS_DATAFLEX_NO_JR=1 to skip ~JR if firmware rejects it."""
  189. if os.environ.get("FPSMS_DATAFLEX_NO_JR", "").strip().lower() in ("1", "true", "yes"):
  190. return b"~JA\r\n" + DATAFLEX_RESET_BYTES
  191. return DATAFLEX_FULL_RECOVERY_BYTES
  192. def _zpl_escape(s: str) -> str:
  193. """Escape text for ZPL ^FD...^FS (backslash and caret)."""
  194. return s.replace("\\", "\\\\").replace("^", "\\^")
  195. def generate_zpl_dataflex(
  196. batch_no: str,
  197. item_code: str,
  198. item_name: str,
  199. item_id: Optional[int] = None,
  200. stock_in_line_id: Optional[int] = None,
  201. lot_no: Optional[str] = None,
  202. font_regular: str = "E:STXihei.ttf",
  203. font_bold: str = "E:STXihei.ttf",
  204. ) -> str:
  205. """
  206. Row 1 (from zero): QR code, then item name (rotated 90°).
  207. Row 2: Batch/lot (left), item code (right).
  208. Label and QR use lotNo from API when present, else batch_no (Bxxxxx).
  209. Host recovery (~JA/~RO/~JR) is sent separately via [send_dataflex_job_counter_reset] before labels.
  210. """
  211. desc = _zpl_escape((item_name or "—").strip())
  212. code = _zpl_escape((item_code or "—").strip())
  213. label_line = (lot_no or batch_no or "").strip()
  214. label_esc = _zpl_escape(label_line)
  215. # QR payload: prefer JSON {"itemId":..., "stockInLineId":...} when both present; else fall back to lot/batch text
  216. if item_id is not None and stock_in_line_id is not None:
  217. qr_payload = json.dumps({"itemId": item_id, "stockInLineId": stock_in_line_id})
  218. else:
  219. qr_payload = label_line if label_line else batch_no.strip()
  220. qr_value = _zpl_escape(qr_payload)
  221. # Explicit ^PQ1: each ^XA…^XZ is exactly one bag. Avoids E1005 "over quantity" on some Zebra/DataFlex
  222. # firmware when many labels are sent on one TCP session without a per-job quantity.
  223. return f"""^XA
  224. ^PQ1,0,1,N
  225. ^CI28
  226. ^PW700
  227. ^LL500
  228. ^PO N
  229. ^FO10,20
  230. ^BQN,2,4^FDQA,{qr_value}^FS
  231. ^FO170,20
  232. ^A@R,72,72,{font_regular}^FD{desc}^FS
  233. ^FO0,200
  234. ^A@R,72,72,{font_regular}^FD{label_esc}^FS
  235. ^FO55,200
  236. ^A@R,88,88,{font_bold}^FD{code}^FS
  237. ^XZ"""
  238. def send_dataflex_job_counter_reset(ip: str, port: int) -> None:
  239. """
  240. Prepare DataFlex for a new job without power cycling: ~JA (clear host buffer / cancel pending),
  241. ~RO1/~RO2 (counters), ~JR (soft reset — clears latched E1005 / “over qty” on many Zebra-class units).
  242. Brief pause after send so ~JR can finish before label data. Set FPSMS_DATAFLEX_NO_JR=1 to omit ~JR.
  243. """
  244. sock = socket.socket(socket.AF_INET, socket.SOCK_STREAM)
  245. try:
  246. sock.setsockopt(socket.IPPROTO_TCP, socket.TCP_NODELAY, 1)
  247. except OSError:
  248. pass
  249. sock.settimeout(DATAFLEX_SEND_TIMEOUT)
  250. try:
  251. sock.connect((ip, port))
  252. sock.sendall(_dataflex_recovery_payload())
  253. time.sleep(DATAFLEX_POST_RECOVERY_DELAY_SEC)
  254. try:
  255. sock.shutdown(socket.SHUT_WR)
  256. except OSError:
  257. pass
  258. finally:
  259. sock.close()
  260. def send_dataflex_reset_and_labels(
  261. ip: str,
  262. port: int,
  263. zpl: str,
  264. copies: int,
  265. delay_sec: float,
  266. ) -> None:
  267. """
  268. One TCP connection: send ~RO reset, then `copies` identical ZPL labels with delay_sec between
  269. copies (not after the last). Avoids rapid connect/disconnect per bag, which can cause skipped
  270. prints on some DataFlex/Zebra TCP hosts.
  271. """
  272. if copies < 1:
  273. return
  274. raw_zpl = zpl.encode("utf-8")
  275. sock = socket.socket(socket.AF_INET, socket.SOCK_STREAM)
  276. try:
  277. sock.setsockopt(socket.IPPROTO_TCP, socket.TCP_NODELAY, 1)
  278. except OSError:
  279. pass
  280. sock.settimeout(DATAFLEX_SEND_TIMEOUT)
  281. try:
  282. sock.connect((ip, port))
  283. sock.sendall(_dataflex_recovery_payload())
  284. time.sleep(DATAFLEX_POST_RECOVERY_DELAY_SEC)
  285. for i in range(copies):
  286. sock.sendall(raw_zpl)
  287. if i < copies - 1:
  288. time.sleep(delay_sec)
  289. try:
  290. sock.shutdown(socket.SHUT_WR)
  291. except OSError:
  292. pass
  293. finally:
  294. sock.close()
  295. def generate_zpl_label_small(
  296. batch_no: str,
  297. item_code: str,
  298. item_name: str,
  299. item_id: Optional[int] = None,
  300. stock_in_line_id: Optional[int] = None,
  301. lot_no: Optional[str] = None,
  302. font: str = "MingLiUHKSCS",
  303. ) -> str:
  304. """
  305. ZPL for 標簽機. Row 1: item name. Row 2: QR left | item code + lot no (or batch) right.
  306. QR contains {"itemId": xxx, "stockInLineId": xxx} when both present; else batch_no.
  307. Unicode (^CI28); font set for Big-5 (e.g. MingLiUHKSCS).
  308. """
  309. desc = _zpl_escape((item_name or "—").strip())
  310. code = _zpl_escape((item_code or "—").strip())
  311. label_line2 = (lot_no or batch_no or "—").strip()
  312. label_line2_esc = _zpl_escape(label_line2)
  313. if item_id is not None and stock_in_line_id is not None:
  314. qr_data = _zpl_escape(json.dumps({"itemId": item_id, "stockInLineId": stock_in_line_id}))
  315. else:
  316. qr_data = f"QA,{batch_no}"
  317. return f"""^XA
  318. ^CI28
  319. ^PW500
  320. ^LL500
  321. ^FO10,15
  322. ^FB480,3,0,L,0
  323. ^A@N,38,38,{font}^FD{desc}^FS
  324. ^FO10,110
  325. ^BQN,2,6^FD{qr_data}^FS
  326. ^FO150,110
  327. ^A@N,48,48,{font}^FD{code}^FS
  328. ^FO150,175
  329. ^A@N,40,40,{font}^FD{label_line2_esc}^FS
  330. ^XZ"""
  331. # Label image size (pixels) for 標簽機 image printing.
  332. # Enlarged for readability (approx +90% scale).
  333. LABEL_IMAGE_W = 720
  334. LABEL_IMAGE_H = 530
  335. LABEL_PADDING = 23
  336. LABEL_FONT_NAME_SIZE = 42
  337. LABEL_FONT_CODE_SIZE = 49
  338. LABEL_FONT_BATCH_SIZE = 34
  339. LABEL_QR_SIZE = 210
  340. def _get_chinese_font(size: int) -> Optional["ImageFont.FreeTypeFont"]:
  341. """Return a Chinese-capable font for PIL, or None to use default."""
  342. if ImageFont is None:
  343. return None
  344. # Prefer real font files on Windows (font *names* may fail and silently fallback).
  345. if os.name == "nt":
  346. fonts_dir = os.path.join(os.environ.get("WINDIR", r"C:\Windows"), "Fonts")
  347. for rel in (
  348. "msjh.ttc", # Microsoft JhengHei
  349. "msjhl.ttc", # Microsoft JhengHei Light
  350. "msjhbd.ttc", # Microsoft JhengHei Bold
  351. "mingliu.ttc",
  352. "mingliub.ttc",
  353. "kaiu.ttf",
  354. "msyh.ttc", # Microsoft YaHei
  355. "msyhbd.ttc",
  356. "simhei.ttf",
  357. "simsun.ttc",
  358. ):
  359. p = os.path.join(fonts_dir, rel)
  360. try:
  361. if os.path.exists(p):
  362. return ImageFont.truetype(p, size)
  363. except (OSError, IOError):
  364. continue
  365. # Fallback: try common font names (may still work depending on Pillow build)
  366. for name in (
  367. "Microsoft JhengHei UI",
  368. "Microsoft JhengHei",
  369. "MingLiU",
  370. "MingLiU_HKSCS",
  371. "Microsoft YaHei",
  372. "SimHei",
  373. "SimSun",
  374. ):
  375. try:
  376. return ImageFont.truetype(name, size)
  377. except (OSError, IOError):
  378. continue
  379. try:
  380. return ImageFont.load_default()
  381. except Exception:
  382. return None
  383. def render_label_to_image(
  384. batch_no: str,
  385. item_code: str,
  386. item_name: str,
  387. item_id: Optional[int] = None,
  388. stock_in_line_id: Optional[int] = None,
  389. lot_no: Optional[str] = None,
  390. ) -> "Image.Image":
  391. """
  392. Render 標簽機 label as a PIL Image (white bg, black text + QR).
  393. Use this image for printing so Chinese displays correctly; words are drawn bigger.
  394. Requires Pillow and qrcode. Raises RuntimeError if not available.
  395. """
  396. if not _HAS_PIL_QR or Image is None or qrcode is None:
  397. raise RuntimeError("Pillow and qrcode are required for image labels. Run: pip install Pillow qrcode[pil]")
  398. img = Image.new("RGB", (LABEL_IMAGE_W, LABEL_IMAGE_H), "white")
  399. draw = ImageDraw.Draw(img)
  400. # QR payload (same as ZPL)
  401. if item_id is not None and stock_in_line_id is not None:
  402. qr_data = json.dumps({"itemId": item_id, "stockInLineId": stock_in_line_id})
  403. else:
  404. qr_data = f"QA,{batch_no}"
  405. # Draw QR top-left area
  406. qr = qrcode.QRCode(box_size=4, border=2)
  407. qr.add_data(qr_data)
  408. qr.make(fit=True)
  409. qr_img = qr.make_image(fill_color="black", back_color="white")
  410. _resample = getattr(Image, "Resampling", Image).NEAREST
  411. qr_img = qr_img.resize((LABEL_QR_SIZE, LABEL_QR_SIZE), _resample)
  412. img.paste(qr_img, (LABEL_PADDING, LABEL_PADDING))
  413. # Fonts (bigger for readability)
  414. font_name = _get_chinese_font(LABEL_FONT_NAME_SIZE)
  415. font_code = _get_chinese_font(LABEL_FONT_CODE_SIZE)
  416. font_batch = _get_chinese_font(LABEL_FONT_BATCH_SIZE)
  417. x_right = LABEL_PADDING + LABEL_QR_SIZE + LABEL_PADDING
  418. y_line = LABEL_PADDING
  419. # Line 1: item name (wrap within remaining width)
  420. name_str = (item_name or "—").strip()
  421. max_name_w = LABEL_IMAGE_W - x_right - LABEL_PADDING
  422. if font_name:
  423. # Wrap rule: after 7 "words" (excl. parentheses). ()() not counted; +=*/. and A–Z/a–z count as 0.5.
  424. def _wrap_text(text: str, font, max_width: int) -> list:
  425. ignore = set("()()")
  426. half = set("+=*/.abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ")
  427. max_count = 6.5
  428. lines: list[str] = []
  429. current: list[str] = []
  430. count = 0.0
  431. for ch in text:
  432. if ch == "\n":
  433. lines.append("".join(current).strip())
  434. current = []
  435. count = 0.0
  436. continue
  437. ch_count = 0.0 if ch in ignore else (0.5 if ch in half else 1.0)
  438. if count + ch_count > max_count and current:
  439. lines.append("".join(current).strip())
  440. current = []
  441. count = 0.0
  442. current.append(ch)
  443. count += ch_count
  444. if current:
  445. lines.append("".join(current).strip())
  446. # Max 2 rows for item name. If still long, keep everything in row 2.
  447. if len(lines) > 2:
  448. lines = [lines[0], "".join(lines[1:]).strip()]
  449. # Safety: if any line still exceeds pixel width, wrap by width as well.
  450. if hasattr(draw, "textbbox"):
  451. out: list[str] = []
  452. for ln in lines:
  453. buf: list[str] = []
  454. for ch in ln:
  455. buf.append(ch)
  456. bbox = draw.textbbox((0, 0), "".join(buf), font=font)
  457. if bbox[2] - bbox[0] > max_width and len(buf) > 1:
  458. out.append("".join(buf[:-1]).strip())
  459. buf = [buf[-1]]
  460. if buf:
  461. out.append("".join(buf).strip())
  462. out = [x for x in out if x]
  463. if len(out) > 2:
  464. out = [out[0], "".join(out[1:]).strip()]
  465. return out
  466. lines = [x for x in lines if x]
  467. if len(lines) > 2:
  468. lines = [lines[0], "".join(lines[1:]).strip()]
  469. return lines
  470. lines = _wrap_text(name_str, font_name, max_name_w)
  471. for i, ln in enumerate(lines):
  472. draw.text((x_right, y_line + i * (LABEL_FONT_NAME_SIZE + 4)), ln, font=font_name, fill="black")
  473. y_line += len(lines) * (LABEL_FONT_NAME_SIZE + 4) + 8
  474. else:
  475. draw.text((x_right, y_line), name_str[:30], fill="black")
  476. y_line += LABEL_FONT_NAME_SIZE + 12
  477. # Item code (bigger)
  478. code_str = (item_code or "—").strip()
  479. if font_code:
  480. draw.text((x_right, y_line), code_str, font=font_code, fill="black")
  481. else:
  482. draw.text((x_right, y_line), code_str, fill="black")
  483. y_line += LABEL_FONT_CODE_SIZE + 6
  484. # Batch/lot line
  485. batch_str = (lot_no or batch_no or "—").strip()
  486. if font_batch:
  487. draw.text((x_right, y_line), batch_str, font=font_batch, fill="black")
  488. else:
  489. draw.text((x_right, y_line), batch_str, fill="black")
  490. return img
  491. def _image_to_zpl_gfa(pil_image: "Image.Image") -> str:
  492. """
  493. Convert a PIL image into ZPL ^GFA (ASCII hex) so we can print Chinese reliably
  494. on ZPL printers (USB/Windows printer or COM) without relying on GDI drivers.
  495. """
  496. if Image is None or ImageOps is None:
  497. raise RuntimeError("Pillow is required for image-to-ZPL conversion.")
  498. # Convert to 1-bit monochrome bitmap. Invert so '1' bits represent black in ZPL.
  499. img_bw = ImageOps.invert(pil_image.convert("L")).convert("1")
  500. w, h = img_bw.size
  501. bytes_per_row = (w + 7) // 8
  502. raw = img_bw.tobytes()
  503. total = bytes_per_row * h
  504. # Ensure length matches expected (Pillow should already pack per row).
  505. if len(raw) != total:
  506. raw = raw[:total].ljust(total, b"\x00")
  507. hex_data = raw.hex().upper()
  508. return f"""^XA
  509. ^PW{w}
  510. ^LL{h}
  511. ^FO0,0
  512. ^GFA,{total},{total},{bytes_per_row},{hex_data}
  513. ^FS
  514. ^XZ"""
  515. def zpl_apply_print_quantity(zpl: str, copies: int) -> str:
  516. """
  517. Ask the printer to output `copies` identical labels from one ZPL format by inserting ^PQ after ^XA.
  518. Results in a **single** spool job / one write — no N separate Windows jobs, no chained ^XA blocks
  519. (which broke some TSC drivers with white-on-white ^GFA output).
  520. """
  521. if copies <= 1:
  522. return zpl
  523. first_fmt = zpl.split("^XZ", 1)[0] if "^XZ" in zpl else zpl
  524. if "^PQ" in first_fmt.upper():
  525. return zpl
  526. lines = zpl.splitlines()
  527. new_lines: list[str] = []
  528. inserted = False
  529. for line in lines:
  530. new_lines.append(line)
  531. if not inserted and line.strip() == "^XA":
  532. # ZPL II: q labels, 0 pause between, 1 replicate (non-serial), N = default options.
  533. # Same graphic (^GFA) is repeated q times — e.g. 需求數量 150 → 150 identical labels, one spool job.
  534. new_lines.append(f"^PQ{copies},0,1,N")
  535. inserted = True
  536. if not inserted:
  537. raise RuntimeError(
  538. "標籤 ZPL 無法插入 ^PQ(格式非預期)。請聯絡程式維護。"
  539. )
  540. ending = "\n" if zpl.endswith("\n") else ""
  541. return "\n".join(new_lines) + ending
  542. def send_image_to_label_printer(printer_name: str, pil_image: "Image.Image") -> None:
  543. """
  544. Send a PIL Image to 標簽機 via Windows GDI (so Chinese and graphics print correctly).
  545. Only supported when target is a Windows printer name (not COM port). Requires pywin32.
  546. """
  547. dest = (printer_name or "").strip()
  548. if not dest:
  549. raise ValueError("Label printer destination is empty.")
  550. if os.name != "nt" or dest.upper().startswith("COM"):
  551. raise RuntimeError("Image printing is only supported for a Windows printer name (e.g. TSC TTP-246M Pro).")
  552. if win32print is None or win32ui is None or win32con is None or win32gui is None:
  553. raise RuntimeError("pywin32 is required. Run: pip install pywin32")
  554. dc = win32ui.CreateDC()
  555. dc.CreatePrinterDC(dest)
  556. dc.StartDoc("FPSMS Label")
  557. dc.StartPage()
  558. try:
  559. bmp_w = pil_image.width
  560. bmp_h = pil_image.height
  561. # Scale-to-fit printable area (important for smaller physical labels).
  562. try:
  563. page_w = int(dc.GetDeviceCaps(win32con.HORZRES))
  564. page_h = int(dc.GetDeviceCaps(win32con.VERTRES))
  565. except Exception:
  566. page_w, page_h = bmp_w, bmp_h
  567. if page_w <= 0 or page_h <= 0:
  568. page_w, page_h = bmp_w, bmp_h
  569. scale = min(page_w / max(1, bmp_w), page_h / max(1, bmp_h))
  570. out_w = max(1, int(bmp_w * scale))
  571. out_h = max(1, int(bmp_h * scale))
  572. x0 = max(0, (page_w - out_w) // 2)
  573. y0 = max(0, (page_h - out_h) // 2)
  574. # Most reliable: render via Pillow ImageWin directly to printer DC.
  575. if ImageWin is not None:
  576. dib = ImageWin.Dib(pil_image.convert("RGB"))
  577. dib.draw(dc.GetHandleOutput(), (x0, y0, x0 + out_w, y0 + out_h))
  578. else:
  579. # Fallback: Draw image to printer DC via temp BMP (GDI uses BMP)
  580. with tempfile.NamedTemporaryFile(suffix=".bmp", delete=False) as f:
  581. tmp_bmp = f.name
  582. try:
  583. pil_image.save(tmp_bmp, "BMP")
  584. hbm = win32gui.LoadImage(
  585. 0, tmp_bmp, win32con.IMAGE_BITMAP, 0, 0,
  586. win32con.LR_LOADFROMFILE | win32con.LR_CREATEDIBSECTION,
  587. )
  588. if hbm == 0:
  589. raise RuntimeError("Failed to load label image as bitmap.")
  590. try:
  591. mem_dc = win32ui.CreateDCFromHandle(win32gui.CreateCompatibleDC(dc.GetSafeHdc()))
  592. bmp = getattr(win32ui, "CreateBitmapFromHandle", lambda h: win32ui.PyCBitmap.FromHandle(h))(hbm)
  593. mem_dc.SelectObject(bmp)
  594. dc.StretchBlt((x0, y0), (out_w, out_h), mem_dc, (0, 0), (bmp_w, bmp_h), win32con.SRCCOPY)
  595. finally:
  596. win32gui.DeleteObject(hbm)
  597. finally:
  598. try:
  599. os.unlink(tmp_bmp)
  600. except OSError:
  601. pass
  602. finally:
  603. dc.EndPage()
  604. dc.EndDoc()
  605. def send_zpl_to_dataflex(ip: str, port: int, zpl: str) -> None:
  606. """Send ZPL label (^XA…^XZ) to DataFlex printer via TCP. Raises on connection/send error."""
  607. sock = socket.socket(socket.AF_INET, socket.SOCK_STREAM)
  608. try:
  609. sock.setsockopt(socket.IPPROTO_TCP, socket.TCP_NODELAY, 1)
  610. except OSError:
  611. pass
  612. sock.settimeout(DATAFLEX_SEND_TIMEOUT)
  613. try:
  614. sock.connect((ip, port))
  615. sock.sendall(zpl.encode("utf-8"))
  616. try:
  617. sock.shutdown(socket.SHUT_WR)
  618. except OSError:
  619. pass
  620. finally:
  621. sock.close()
  622. def send_zpl_to_label_printer(target: str, zpl: str) -> None:
  623. """
  624. Send ZPL to 標簽機.
  625. On Windows, if target is not a COM port (e.g. "TSC TTP-246M Pro"),
  626. send raw ZPL to the named Windows printer via the spooler.
  627. Otherwise, treat target as a serial COM port (original behaviour).
  628. """
  629. dest = (target or "").strip()
  630. if not dest:
  631. raise ValueError("Label printer destination is empty.")
  632. # Unicode (^CI28); send UTF-8 to 標簽機
  633. raw_bytes = zpl.encode("utf-8")
  634. # Windows printer name path (USB printer installed as normal printer)
  635. if os.name == "nt" and not dest.upper().startswith("COM"):
  636. if win32print is None:
  637. raise RuntimeError("pywin32 not installed. Run: pip install pywin32")
  638. handle = win32print.OpenPrinter(dest)
  639. try:
  640. job = win32print.StartDocPrinter(handle, 1, ("FPSMS Label", None, "RAW"))
  641. win32print.StartPagePrinter(handle)
  642. win32print.WritePrinter(handle, raw_bytes)
  643. win32print.EndPagePrinter(handle)
  644. win32print.EndDocPrinter(handle)
  645. finally:
  646. win32print.ClosePrinter(handle)
  647. return
  648. # Fallback: serial COM port
  649. if serial is None:
  650. raise RuntimeError("pyserial not installed. Run: pip install pyserial")
  651. ser = serial.Serial(dest, timeout=5)
  652. try:
  653. ser.write(raw_bytes)
  654. finally:
  655. ser.close()
  656. def send_zpl_to_label_printer_batch(target: str, zpl: str, copies: int) -> None:
  657. """
  658. Print multiple identical ZPL labels in **exactly one** Windows spool job (or one COM write).
  659. Uses ZPL ^PQ so the printer firmware repeats the format N times — never one job per label.
  660. """
  661. if copies < 1:
  662. return
  663. zpl_out = zpl_apply_print_quantity(zpl, copies)
  664. send_zpl_to_label_printer(target, zpl_out)
  665. def load_laser_last_count() -> tuple[int, Optional[str]]:
  666. """Load last batch count and date from laser counter file. Returns (count, date_str)."""
  667. if not os.path.exists(LASER_COUNTER_FILE):
  668. return 0, None
  669. try:
  670. with open(LASER_COUNTER_FILE, "r", encoding="utf-8") as f:
  671. lines = f.read().strip().splitlines()
  672. if len(lines) >= 2:
  673. return int(lines[1].strip()), lines[0].strip()
  674. except Exception:
  675. pass
  676. return 0, None
  677. def save_laser_last_count(date_str: str, count: int) -> None:
  678. """Save laser batch count and date to file."""
  679. try:
  680. with open(LASER_COUNTER_FILE, "w", encoding="utf-8") as f:
  681. f.write(f"{date_str}\n{count}")
  682. except Exception:
  683. pass
  684. LASER_PUSH_INTERVAL = 2 # seconds between pushes (like sample script)
  685. # Click row with 激光機 selected: send this many times, delay between sends (not after last).
  686. LASER_ROW_SEND_COUNT = 3
  687. LASER_ROW_SEND_DELAY_SEC = 3
  688. def laser_push_loop(
  689. ip: str,
  690. port: int,
  691. stop_event: threading.Event,
  692. root: tk.Tk,
  693. on_error: Callable[[str], None],
  694. ) -> None:
  695. """
  696. Run in a background thread: persistent connection to EZCAD, push B{yymmdd}{count:03d};;
  697. every LASER_PUSH_INTERVAL seconds. Resets count each new day. Uses counter file.
  698. """
  699. conn = None
  700. push_count, last_saved_date = load_laser_last_count()
  701. while not stop_event.is_set():
  702. try:
  703. if conn is None:
  704. conn = socket.socket(socket.AF_INET, socket.SOCK_STREAM)
  705. conn.settimeout(0.4)
  706. conn.connect((ip, port))
  707. now = datetime.now()
  708. today_str = now.strftime("%y%m%d")
  709. if last_saved_date != today_str:
  710. push_count = 1
  711. last_saved_date = today_str
  712. batch = f"B{today_str}{push_count:03d}"
  713. reply = f"{batch};;"
  714. conn.sendall(reply.encode("utf-8"))
  715. save_laser_last_count(today_str, push_count)
  716. rlist, _, _ = select.select([conn], [], [], 0.4)
  717. if rlist:
  718. data = conn.recv(4096)
  719. if not data:
  720. conn.close()
  721. conn = None
  722. push_count += 1
  723. for _ in range(int(LASER_PUSH_INTERVAL * 2)):
  724. if stop_event.is_set():
  725. break
  726. time.sleep(0.5)
  727. except socket.timeout:
  728. pass
  729. except Exception as e:
  730. if conn:
  731. try:
  732. conn.close()
  733. except Exception:
  734. pass
  735. conn = None
  736. try:
  737. root.after(0, lambda msg=str(e): on_error(msg))
  738. except Exception:
  739. pass
  740. for _ in range(6):
  741. if stop_event.is_set():
  742. break
  743. time.sleep(0.5)
  744. if conn:
  745. try:
  746. conn.close()
  747. except Exception:
  748. pass
  749. def send_job_to_laser(
  750. conn_ref: list,
  751. ip: str,
  752. port: int,
  753. item_id: Optional[int],
  754. stock_in_line_id: Optional[int],
  755. item_code: str,
  756. item_name: str,
  757. ) -> tuple[bool, str]:
  758. """
  759. Send to laser using `;` separated 3 params:
  760. {"itemID": itemId, "stockInLineId": stockInLineId} ; itemCode ; itemName ;;
  761. conn_ref: [socket or None] - reused across calls; closed only when switching printer.
  762. When both item_id and stock_in_line_id present, sends JSON first param; else fallback: 0;item_code;item_name;;
  763. Returns (success, message).
  764. """
  765. code_str = (item_code or "").strip().replace(";", ",")
  766. name_str = (item_name or "").strip().replace(";", ",")
  767. if item_id is not None and stock_in_line_id is not None:
  768. # Use compact JSON so device-side parser doesn't get spaces.
  769. json_part = json.dumps(
  770. {"itemId": item_id, "stockInLineId": stock_in_line_id},
  771. separators=(",", ":"),
  772. )
  773. reply = f"{json_part};{code_str};{name_str};;"
  774. else:
  775. reply = f"0;{code_str};{name_str};;"
  776. conn = conn_ref[0]
  777. try:
  778. if conn is None:
  779. conn = socket.socket(socket.AF_INET, socket.SOCK_STREAM)
  780. conn.settimeout(3.0)
  781. conn.connect((ip, port))
  782. conn_ref[0] = conn
  783. conn.settimeout(3.0)
  784. conn.sendall(reply.encode("utf-8"))
  785. conn.settimeout(0.5)
  786. try:
  787. data = conn.recv(4096)
  788. if data:
  789. ack = data.decode("utf-8", errors="ignore").strip().lower()
  790. if "receive" in ack and "invalid" not in ack:
  791. return True, f"已送出激光機:{reply}(已確認)"
  792. except socket.timeout:
  793. pass
  794. return True, f"已送出激光機:{reply}"
  795. except (ConnectionRefusedError, socket.timeout, OSError) as e:
  796. if conn_ref[0] is not None:
  797. try:
  798. conn_ref[0].close()
  799. except Exception:
  800. pass
  801. conn_ref[0] = None
  802. if isinstance(e, ConnectionRefusedError):
  803. return False, f"無法連線至 {ip}:{port},請確認激光機已開機且 IP 正確。"
  804. if isinstance(e, socket.timeout):
  805. return False, f"連線逾時 ({ip}:{port}),請檢查網路與連接埠。"
  806. return False, f"激光機送出失敗:{e}"
  807. def send_job_to_laser_with_retry(
  808. conn_ref: list,
  809. ip: str,
  810. port: int,
  811. item_id: Optional[int],
  812. stock_in_line_id: Optional[int],
  813. item_code: str,
  814. item_name: str,
  815. ) -> tuple[bool, str]:
  816. """Send job to laser; on failure, retry once. Returns (success, message)."""
  817. ok, msg = send_job_to_laser(conn_ref, ip, port, item_id, stock_in_line_id, item_code, item_name)
  818. if ok:
  819. return True, msg
  820. ok2, msg2 = send_job_to_laser(conn_ref, ip, port, item_id, stock_in_line_id, item_code, item_name)
  821. return ok2, msg2
  822. def run_laser_row_send_thread(
  823. root: tk.Tk,
  824. laser_conn_ref: list,
  825. laser_busy_ref: list,
  826. ip: str,
  827. port: int,
  828. item_id: Optional[int],
  829. stock_in_line_id: Optional[int],
  830. item_code: str,
  831. item_name: str,
  832. set_status_message: Callable[[str, bool], None],
  833. base_url: Optional[str] = None,
  834. job_order_id: Optional[int] = None,
  835. on_recorded: Optional[Callable[[], None]] = None,
  836. ) -> None:
  837. """
  838. On row click with 激光機: send LASER_ROW_SEND_COUNT times with LASER_ROW_SEND_DELAY_SEC between sends.
  839. UI updates on main thread; work runs in background so the window does not freeze.
  840. After success, POST LASER qty to API when job_order_id and base_url are set.
  841. """
  842. if laser_busy_ref[0]:
  843. messagebox.showwarning("激光機", "請等待目前激光發送完成。")
  844. return
  845. laser_busy_ref[0] = True
  846. def worker() -> None:
  847. try:
  848. n = LASER_ROW_SEND_COUNT
  849. for i in range(n):
  850. ok, msg = send_job_to_laser_with_retry(
  851. laser_conn_ref,
  852. ip,
  853. port,
  854. item_id,
  855. stock_in_line_id,
  856. item_code,
  857. item_name,
  858. )
  859. if not ok:
  860. root.after(
  861. 0,
  862. lambda m=msg: messagebox.showwarning("激光機", m),
  863. )
  864. return
  865. if i < n - 1:
  866. time.sleep(LASER_ROW_SEND_DELAY_SEC)
  867. posted = False
  868. if base_url and job_order_id is not None:
  869. try:
  870. submit_job_order_print_submit(base_url, int(job_order_id), n, "LASER")
  871. posted = True
  872. except requests.RequestException as ex:
  873. root.after(
  874. 0,
  875. lambda err=str(ex): messagebox.showwarning(
  876. "激光機",
  877. f"已發送,但伺服器記錄失敗:{err}",
  878. ),
  879. )
  880. root.after(
  881. 0,
  882. lambda: set_status_message("已發送", is_error=False),
  883. )
  884. if on_recorded is not None and posted:
  885. root.after(0, on_recorded)
  886. except Exception as e:
  887. root.after(
  888. 0,
  889. lambda err=str(e): messagebox.showwarning("激光機", f"送出失敗:{err}"),
  890. )
  891. finally:
  892. laser_busy_ref[0] = False
  893. threading.Thread(target=worker, daemon=True).start()
  894. def run_dataflex_fixed_qty_thread(
  895. root: tk.Tk,
  896. dataflex_lock: threading.Lock,
  897. dataflex_busy_ref: list,
  898. ip: str,
  899. port: int,
  900. n: int,
  901. zpl: str,
  902. label_text: str,
  903. jo_id: Optional[int],
  904. base_url: str,
  905. set_status_message: Callable[[str, bool], None],
  906. on_recorded: Callable[[], None],
  907. ) -> None:
  908. """
  909. Send n DataFlex labels with delay between copies. Runs off the Tk main thread so the UI
  910. stays responsive (printer dropdown, other controls) during printing.
  911. """
  912. def worker() -> None:
  913. with dataflex_lock:
  914. if dataflex_busy_ref[0]:
  915. root.after(
  916. 0,
  917. lambda: messagebox.showwarning(
  918. "打袋機",
  919. "請等待目前列印完成或先停止連續列印。",
  920. ),
  921. )
  922. return
  923. dataflex_busy_ref[0] = True
  924. try:
  925. send_dataflex_reset_and_labels(
  926. ip,
  927. port,
  928. zpl,
  929. n,
  930. DATAFLEX_INTER_LABEL_DELAY_SEC,
  931. )
  932. root.after(
  933. 0,
  934. lambda: set_status_message(
  935. f"已送出列印:批次 {label_text} x {n} 張",
  936. is_error=False,
  937. ),
  938. )
  939. if jo_id is not None:
  940. try:
  941. submit_job_order_print_submit(base_url, int(jo_id), n, "DATAFLEX")
  942. root.after(0, on_recorded)
  943. except requests.RequestException as ex:
  944. root.after(
  945. 0,
  946. lambda err=str(ex): messagebox.showwarning(
  947. "打袋機",
  948. f"已送出 {n} 張,但伺服器記錄失敗:{err}",
  949. ),
  950. )
  951. except ConnectionRefusedError:
  952. root.after(
  953. 0,
  954. lambda: set_status_message(
  955. f"無法連線至 {ip}:{port},請確認印表機已開機且 IP 正確。",
  956. is_error=True,
  957. ),
  958. )
  959. except socket.timeout:
  960. root.after(
  961. 0,
  962. lambda: set_status_message(
  963. f"連線逾時 ({ip}:{port}),請檢查網路與連接埠。",
  964. is_error=True,
  965. ),
  966. )
  967. except OSError as err:
  968. root.after(
  969. 0,
  970. lambda e=err: set_status_message(f"列印失敗:{e}", is_error=True),
  971. )
  972. finally:
  973. with dataflex_lock:
  974. dataflex_busy_ref[0] = False
  975. threading.Thread(target=worker, daemon=True).start()
  976. def run_label_print_batch_thread(
  977. root: tk.Tk,
  978. label_lock: threading.Lock,
  979. label_busy_ref: list,
  980. com: str,
  981. zpl_img: str,
  982. n: int,
  983. jo_id: Optional[int],
  984. base_url: str,
  985. set_status_message: Callable[[str, bool], None],
  986. on_recorded: Callable[[], None],
  987. ) -> None:
  988. """
  989. Send n label copies off the main thread so DataFlex / laser / UI stay usable in parallel.
  990. """
  991. def worker() -> None:
  992. with label_lock:
  993. if label_busy_ref[0]:
  994. root.after(
  995. 0,
  996. lambda: messagebox.showwarning(
  997. "標籤機",
  998. "請等待目前標籤列印完成。",
  999. ),
  1000. )
  1001. return
  1002. label_busy_ref[0] = True
  1003. try:
  1004. send_zpl_to_label_printer_batch(com, zpl_img, n)
  1005. root.after(
  1006. 0,
  1007. lambda: set_status_message(f"已送出列印:標籤 x {n} 張", is_error=False),
  1008. )
  1009. if jo_id is not None:
  1010. try:
  1011. submit_job_order_print_submit(base_url, int(jo_id), n, "LABEL")
  1012. root.after(0, on_recorded)
  1013. root.after(
  1014. 0,
  1015. lambda: messagebox.showinfo(
  1016. "標籤機",
  1017. f"已送出列印:{n} 張標籤(已記錄)",
  1018. ),
  1019. )
  1020. except requests.RequestException as ex:
  1021. root.after(
  1022. 0,
  1023. lambda err=str(ex): messagebox.showwarning(
  1024. "標籤機",
  1025. f"標籤已列印 {n} 張,但伺服器記錄失敗:{err}",
  1026. ),
  1027. )
  1028. else:
  1029. root.after(
  1030. 0,
  1031. lambda: messagebox.showwarning(
  1032. "標籤機",
  1033. f"已送出列印:{n} 張標籤(無工單 id,無法寫入伺服器記錄)",
  1034. ),
  1035. )
  1036. except Exception as err:
  1037. root.after(
  1038. 0,
  1039. lambda e=str(err): messagebox.showerror("標籤機", f"列印失敗:{e}"),
  1040. )
  1041. finally:
  1042. with label_lock:
  1043. label_busy_ref[0] = False
  1044. threading.Thread(target=worker, daemon=True).start()
  1045. def _printed_qty_int(raw) -> int:
  1046. """Parse API printed qty field (may be float JSON) to int."""
  1047. try:
  1048. return int(float(raw)) if raw is not None else 0
  1049. except (TypeError, ValueError):
  1050. return 0
  1051. def _filter_job_orders_by_search(data: list, needle: str) -> list:
  1052. """Substring match on item code, job order code, item name, lot (case-insensitive)."""
  1053. n = needle.strip().lower()
  1054. if not n:
  1055. return data
  1056. out: list = []
  1057. for jo in data:
  1058. parts = [
  1059. str(jo.get("itemCode") or ""),
  1060. str(jo.get("code") or ""),
  1061. str(jo.get("itemName") or ""),
  1062. str(jo.get("lotNo") or ""),
  1063. ]
  1064. if any(n in p.lower() for p in parts):
  1065. out.append(jo)
  1066. return out
  1067. def format_qty(val) -> str:
  1068. """Format quantity: integer without .0, with thousand separator."""
  1069. if val is None:
  1070. return "—"
  1071. try:
  1072. n = float(val)
  1073. if n == int(n):
  1074. return f"{int(n):,}"
  1075. return f"{n:,.2f}".rstrip("0").rstrip(".")
  1076. except (TypeError, ValueError):
  1077. return str(val)
  1078. def batch_no(year: int, job_order_id: int) -> str:
  1079. """Batch no.: B + 4-digit year + jobOrderId zero-padded to 6 digits."""
  1080. return f"B{year}{job_order_id:06d}"
  1081. def get_font(size: int = FONT_SIZE, bold: bool = False) -> tuple:
  1082. try:
  1083. return (FONT_FAMILY, size, "bold" if bold else "normal")
  1084. except Exception:
  1085. return ("TkDefaultFont", size, "bold" if bold else "normal")
  1086. def fetch_job_orders(base_url: str, plan_start: date) -> list:
  1087. """Call GET /py/job-orders and return the JSON list."""
  1088. url = f"{base_url.rstrip('/')}/py/job-orders"
  1089. params = {"planStart": plan_start.isoformat()}
  1090. resp = requests.get(url, params=params, timeout=30)
  1091. resp.raise_for_status()
  1092. return resp.json()
  1093. def submit_job_order_print_submit(
  1094. base_url: str,
  1095. job_order_id: int,
  1096. qty: int,
  1097. print_channel: str = "LABEL",
  1098. ) -> None:
  1099. """POST /py/job-order-print-submit — one row per submit for DB wastage/stock tracking."""
  1100. url = f"{base_url.rstrip('/')}/py/job-order-print-submit"
  1101. resp = requests.post(
  1102. url,
  1103. json={"jobOrderId": job_order_id, "qty": qty, "printChannel": print_channel},
  1104. timeout=30,
  1105. )
  1106. resp.raise_for_status()
  1107. def set_row_highlight(row_frame: tk.Frame, selected: bool) -> None:
  1108. """Set row and all nested Frame/Label children to selected or normal background."""
  1109. bg = BG_ROW_SELECTED if selected else BG_ROW
  1110. def _paint(w: tk.Misc) -> None:
  1111. if isinstance(w, (tk.Frame, tk.Label)):
  1112. w.configure(bg=bg)
  1113. for c in w.winfo_children():
  1114. _paint(c)
  1115. _paint(row_frame)
  1116. def on_job_order_click(jo: dict, batch: str) -> None:
  1117. """Show message and highlight row (keeps printing to selected printer)."""
  1118. item_code = jo.get("itemCode") or "—"
  1119. item_name = jo.get("itemName") or "—"
  1120. messagebox.showinfo(
  1121. "工單",
  1122. f'已點選:批次 {batch}\n品號 {item_code} {item_name}',
  1123. )
  1124. def ask_label_count(parent: tk.Tk) -> Optional[int]:
  1125. """
  1126. When printer is 標簽機, ask how many labels to print:
  1127. optional direct qty in text field (e.g. 150), +50/+10/+5/+1, 重置, then 確認送出.
  1128. That count becomes ZPL ^PQ in one job — 150 → 150 identical labels.
  1129. Returns count (>= 1), or None if cancelled.
  1130. """
  1131. result: list[Optional[int]] = [None]
  1132. qty_var = tk.StringVar(value="0")
  1133. win = tk.Toplevel(parent)
  1134. win.title("標簽印數")
  1135. win.geometry("580x280")
  1136. win.transient(parent)
  1137. win.grab_set()
  1138. win.configure(bg=BG_TOP)
  1139. ttk.Label(win, text="印多少個?", font=get_font(FONT_SIZE)).pack(pady=(12, 4))
  1140. entry_row = tk.Frame(win, bg=BG_TOP)
  1141. entry_row.pack(pady=8)
  1142. tk.Label(entry_row, text="需求數量:", font=get_font(FONT_SIZE), bg=BG_TOP).pack(side=tk.LEFT, padx=(0, 6))
  1143. qty_entry = tk.Entry(
  1144. entry_row,
  1145. textvariable=qty_var,
  1146. width=12,
  1147. font=get_font(FONT_SIZE),
  1148. bg="white",
  1149. justify=tk.RIGHT,
  1150. )
  1151. qty_entry.pack(side=tk.LEFT, padx=4)
  1152. def current_qty() -> int:
  1153. s = (qty_var.get() or "").strip().replace(",", "")
  1154. if not s:
  1155. return 0
  1156. try:
  1157. return max(0, int(s))
  1158. except ValueError:
  1159. return 0
  1160. def reset_qty() -> None:
  1161. qty_var.set("0")
  1162. ttk.Button(entry_row, text="重置", command=reset_qty, width=8).pack(side=tk.LEFT, padx=8)
  1163. def add(n: int) -> None:
  1164. qty_var.set(str(current_qty() + n))
  1165. def confirm() -> None:
  1166. q = current_qty()
  1167. if q < 1:
  1168. messagebox.showwarning("標簽機", "請輸入需求數量或按 +50、+10、+5、+1。", parent=win)
  1169. return
  1170. result[0] = q
  1171. win.destroy()
  1172. btn_row1 = tk.Frame(win, bg=BG_TOP)
  1173. btn_row1.pack(pady=8)
  1174. for label, value in [("+50", 50), ("+10", 10), ("+5", 5), ("+1", 1)]:
  1175. def make_add(v: int):
  1176. return lambda: add(v)
  1177. ttk.Button(btn_row1, text=label, command=make_add(value), width=8).pack(side=tk.LEFT, padx=4)
  1178. ttk.Button(win, text="確認送出", command=confirm, width=14).pack(pady=12)
  1179. qty_entry.bind("<Return>", lambda e: confirm())
  1180. win.protocol("WM_DELETE_WINDOW", win.destroy)
  1181. win.wait_window()
  1182. return result[0]
  1183. def ask_bag_count(parent: tk.Tk) -> Optional[Tuple[int, bool]]:
  1184. """
  1185. When printer is 打袋機 DataFlex: qty with +按鈕 then 確認送出, or big bottom「C」for continuous.
  1186. Returns (count, continuous_print). If continuous_print is True, count is ignored (use 0).
  1187. None if cancelled.
  1188. """
  1189. result: list[Optional[Tuple[int, bool]]] = [None]
  1190. qty_var = tk.StringVar(value="0")
  1191. win = tk.Toplevel(parent)
  1192. win.title("打袋列印數量")
  1193. win.geometry("580x420")
  1194. win.transient(parent)
  1195. win.grab_set()
  1196. win.configure(bg=BG_TOP)
  1197. ttk.Label(win, text="列印多少個袋?", font=get_font(FONT_SIZE)).pack(pady=(12, 4))
  1198. entry_row = tk.Frame(win, bg=BG_TOP)
  1199. entry_row.pack(pady=8)
  1200. tk.Label(entry_row, text="需求數量:", font=get_font(FONT_SIZE), bg=BG_TOP).pack(side=tk.LEFT, padx=(0, 6))
  1201. qty_entry = tk.Entry(
  1202. entry_row,
  1203. textvariable=qty_var,
  1204. width=12,
  1205. font=get_font(FONT_SIZE),
  1206. bg="white",
  1207. justify=tk.RIGHT,
  1208. )
  1209. qty_entry.pack(side=tk.LEFT, padx=4)
  1210. def current_qty() -> int:
  1211. s = (qty_var.get() or "").strip().replace(",", "")
  1212. if not s:
  1213. return 0
  1214. try:
  1215. return max(0, int(s))
  1216. except ValueError:
  1217. return 0
  1218. def reset_qty() -> None:
  1219. qty_var.set("0")
  1220. ttk.Button(entry_row, text="重置", command=reset_qty, width=8).pack(side=tk.LEFT, padx=8)
  1221. def add(n: int) -> None:
  1222. qty_var.set(str(current_qty() + n))
  1223. def confirm() -> None:
  1224. q = current_qty()
  1225. if q < 1:
  1226. messagebox.showwarning("打袋機", "請輸入需求數量或按 +50、+10、+5、+1。", parent=win)
  1227. return
  1228. result[0] = (q, False)
  1229. win.destroy()
  1230. def start_continuous() -> None:
  1231. """Big C: continuous print until 停止; counter reset at job start."""
  1232. result[0] = (0, True)
  1233. win.destroy()
  1234. btn_row1 = tk.Frame(win, bg=BG_TOP)
  1235. btn_row1.pack(pady=8)
  1236. for label, value in [("+50", 50), ("+10", 10), ("+5", 5), ("+1", 1)]:
  1237. def make_add(v: int):
  1238. return lambda: add(v)
  1239. ttk.Button(btn_row1, text=label, command=make_add(value), width=8).pack(side=tk.LEFT, padx=4)
  1240. ttk.Button(win, text="確認送出", command=confirm, width=14).pack(pady=12)
  1241. qty_entry.bind("<Return>", lambda e: confirm())
  1242. sep = ttk.Separator(win, orient=tk.HORIZONTAL)
  1243. sep.pack(fill=tk.X, padx=16, pady=(4, 8))
  1244. bottom = tk.Frame(win, bg=BG_TOP)
  1245. bottom.pack(fill=tk.X, padx=12, pady=(0, 12))
  1246. tk.Label(
  1247. bottom,
  1248. text="連續出袋 · 每單開始重設計數 · 另開視窗按「停止列印」結束",
  1249. font=get_font(FONT_SIZE_META),
  1250. bg=BG_TOP,
  1251. fg="#333333",
  1252. wraplength=540,
  1253. justify=tk.CENTER,
  1254. ).pack(fill=tk.X, pady=(0, 6))
  1255. tk.Button(
  1256. bottom,
  1257. text="C(連續印)",
  1258. command=start_continuous,
  1259. font=(FONT_FAMILY, 38, "bold"),
  1260. bg="#2E7D32",
  1261. fg="white",
  1262. activebackground="#1B5E20",
  1263. activeforeground="white",
  1264. relief=tk.RAISED,
  1265. bd=4,
  1266. cursor="hand2",
  1267. padx=24,
  1268. pady=18,
  1269. ).pack(fill=tk.X)
  1270. win.protocol("WM_DELETE_WINDOW", win.destroy)
  1271. win.wait_window()
  1272. return result[0]
  1273. def _sleep_interruptible(stop_event: threading.Event, total_sec: float) -> None:
  1274. """Sleep up to total_sec but return early if stop_event is set."""
  1275. end = time.perf_counter() + total_sec
  1276. while time.perf_counter() < end:
  1277. if stop_event.is_set():
  1278. return
  1279. remaining = end - time.perf_counter()
  1280. if remaining <= 0:
  1281. break
  1282. time.sleep(min(0.05, remaining))
  1283. def open_dataflex_stop_window(
  1284. parent: tk.Tk,
  1285. stop_event: threading.Event,
  1286. stop_win_ref: list,
  1287. ) -> tk.Toplevel:
  1288. """
  1289. Small window with 停止列印 for DataFlex continuous mode (non-modal so stop stays usable).
  1290. Stays above other dialogs (e.g. 標籤機 quantity) via periodic lift + optional topmost on Windows,
  1291. so switching printer and printing labels does not hide the stop control. Ref is cleared on destroy.
  1292. """
  1293. win = tk.Toplevel(parent)
  1294. win.title("打袋機連續列印")
  1295. win.geometry("420x170")
  1296. # On Windows, transient(root) can hide this Toplevel when the menubutton / printer row
  1297. # updates (e.g. switching to 激光機); keep transient only on non-Windows.
  1298. if os.name != "nt":
  1299. win.transient(parent)
  1300. win.configure(bg=BG_TOP)
  1301. stop_win_ref[0] = win
  1302. if os.name == "nt":
  1303. try:
  1304. win.attributes("-topmost", True)
  1305. except tk.TclError:
  1306. pass
  1307. tk.Label(
  1308. win,
  1309. text="連續列印進行中(與上方列印機選項無關),可隨時按下方停止。",
  1310. font=get_font(FONT_SIZE),
  1311. bg=BG_TOP,
  1312. wraplength=400,
  1313. ).pack(pady=(16, 8))
  1314. def clear_topmost() -> None:
  1315. if os.name == "nt":
  1316. try:
  1317. win.attributes("-topmost", False)
  1318. except tk.TclError:
  1319. pass
  1320. def stop() -> None:
  1321. stop_event.set()
  1322. stop_win_ref[0] = None
  1323. clear_topmost()
  1324. try:
  1325. win.destroy()
  1326. except tk.TclError:
  1327. pass
  1328. def periodic_lift() -> None:
  1329. if stop_win_ref[0] is not win:
  1330. return
  1331. try:
  1332. if not win.winfo_exists():
  1333. return
  1334. win.lift()
  1335. if os.name == "nt":
  1336. win.attributes("-topmost", True)
  1337. except tk.TclError:
  1338. return
  1339. parent.after(4000, periodic_lift)
  1340. tk.Button(
  1341. win,
  1342. text="停止列印",
  1343. command=stop,
  1344. font=get_font(FONT_SIZE_BUTTONS),
  1345. bg=BG_STATUS_ERROR,
  1346. fg=FG_STATUS_ERROR,
  1347. padx=20,
  1348. pady=10,
  1349. ).pack(pady=12)
  1350. win.protocol("WM_DELETE_WINDOW", stop)
  1351. parent.after(500, periodic_lift)
  1352. return win
  1353. def main() -> None:
  1354. settings = load_settings()
  1355. base_url_ref = [build_base_url(settings["api_ip"], settings["api_port"])]
  1356. root = tk.Tk()
  1357. root.title(f"FP-MTMS Bag3 v{APP_VERSION} 打袋機")
  1358. root.geometry("1120x960")
  1359. root.minsize(480, 360)
  1360. root.configure(bg=BG_ROOT)
  1361. # Style: larger font for aged users; light blue theme
  1362. style = ttk.Style()
  1363. try:
  1364. style.configure(".", font=get_font(FONT_SIZE), background=BG_TOP)
  1365. style.configure("TButton", font=get_font(FONT_SIZE_BUTTONS), background=BG_TOP)
  1366. style.configure("TLabel", font=get_font(FONT_SIZE), background=BG_TOP)
  1367. style.configure("TEntry", font=get_font(FONT_SIZE))
  1368. style.configure("TFrame", background=BG_TOP)
  1369. # TCombobox field (if other combos use ttk later)
  1370. style.configure("TCombobox", font=get_font(FONT_SIZE_COMBO))
  1371. except tk.TclError:
  1372. pass
  1373. # Status bar at top: connection state (no popup on error)
  1374. status_frame = tk.Frame(root, bg=BG_STATUS_ERROR, padx=12, pady=6)
  1375. status_frame.pack(fill=tk.X)
  1376. status_lbl = tk.Label(
  1377. status_frame,
  1378. text="連接不到服務器",
  1379. font=get_font(FONT_SIZE_BUTTONS),
  1380. bg=BG_STATUS_ERROR,
  1381. fg=FG_STATUS_ERROR,
  1382. anchor=tk.CENTER,
  1383. )
  1384. status_lbl.pack(fill=tk.X)
  1385. def set_status_ok():
  1386. status_frame.configure(bg=BG_STATUS_OK)
  1387. status_lbl.configure(text="連接正常", bg=BG_STATUS_OK, fg=FG_STATUS_OK)
  1388. def set_status_error():
  1389. status_frame.configure(bg=BG_STATUS_ERROR)
  1390. status_lbl.configure(text="連接不到服務器", bg=BG_STATUS_ERROR, fg=FG_STATUS_ERROR)
  1391. def set_status_message(msg: str, is_error: bool = False) -> None:
  1392. """Show a message on the status bar."""
  1393. if is_error:
  1394. status_frame.configure(bg=BG_STATUS_ERROR)
  1395. status_lbl.configure(text=msg, bg=BG_STATUS_ERROR, fg=FG_STATUS_ERROR)
  1396. else:
  1397. status_frame.configure(bg=BG_STATUS_OK)
  1398. status_lbl.configure(text=msg, bg=BG_STATUS_OK, fg=FG_STATUS_OK)
  1399. # Laser: keep connection open for repeated sends; close when switching away
  1400. laser_conn_ref: list = [None]
  1401. laser_send_busy_ref: list = [False]
  1402. # DataFlex: shared lock so fixed-qty and continuous jobs do not overlap (independent of laser/label)
  1403. dataflex_lock = threading.Lock()
  1404. dataflex_busy_ref: list = [False]
  1405. # 標籤機: own lock so label jobs do not overlap; does not block DataFlex or laser
  1406. label_lock = threading.Lock()
  1407. label_busy_ref: list = [False]
  1408. # DataFlex continuous: stop Toplevel ref so we can lift it after other dialogs
  1409. dataflex_stop_win_ref: list = [None]
  1410. def lift_dataflex_stop_if_running() -> None:
  1411. """After closing another dialog (e.g. 標籤印數), bring the stop panel forward again."""
  1412. w = dataflex_stop_win_ref[0]
  1413. if w is None:
  1414. return
  1415. try:
  1416. if w.winfo_exists():
  1417. w.lift()
  1418. if os.name == "nt":
  1419. w.attributes("-topmost", True)
  1420. except tk.TclError:
  1421. pass
  1422. # Top: left [前一天] [date] [後一天] | right [printer dropdown]
  1423. top = tk.Frame(root, padx=12, pady=12, bg=BG_TOP)
  1424. top.pack(fill=tk.X)
  1425. date_var = tk.StringVar(value=date.today().isoformat())
  1426. printer_options = ["打袋機 DataFlex", "標簽機", "激光機"]
  1427. printer_var = tk.StringVar(value=printer_options[0])
  1428. def go_prev_day() -> None:
  1429. try:
  1430. d = date.fromisoformat(date_var.get().strip())
  1431. date_var.set((d - timedelta(days=1)).isoformat())
  1432. load_job_orders(from_user_date_change=True)
  1433. except ValueError:
  1434. date_var.set(date.today().isoformat())
  1435. load_job_orders(from_user_date_change=True)
  1436. def go_next_day() -> None:
  1437. try:
  1438. d = date.fromisoformat(date_var.get().strip())
  1439. date_var.set((d + timedelta(days=1)).isoformat())
  1440. load_job_orders(from_user_date_change=True)
  1441. except ValueError:
  1442. date_var.set(date.today().isoformat())
  1443. load_job_orders(from_user_date_change=True)
  1444. # 前一天 (previous day) with left arrow icon
  1445. btn_prev = ttk.Button(top, text="◀ 前一天", command=go_prev_day)
  1446. btn_prev.pack(side=tk.LEFT, padx=(0, 8))
  1447. # Date field (no "日期:" label); shorter width
  1448. date_entry = tk.Entry(
  1449. top,
  1450. textvariable=date_var,
  1451. font=get_font(FONT_SIZE),
  1452. width=10,
  1453. bg="white",
  1454. )
  1455. date_entry.pack(side=tk.LEFT, padx=(0, 8), ipady=4)
  1456. # 後一天 (next day) with right arrow icon
  1457. btn_next = ttk.Button(top, text="後一天 ▶", command=go_next_day)
  1458. btn_next.pack(side=tk.LEFT, padx=(0, 8))
  1459. # Top right: Setup button + printer selection
  1460. right_frame = tk.Frame(top, bg=BG_TOP)
  1461. right_frame.pack(side=tk.RIGHT)
  1462. ttk.Button(right_frame, text="設定", command=lambda: open_setup_window(root, settings, base_url_ref)).pack(
  1463. side=tk.LEFT, padx=(0, 12)
  1464. )
  1465. def on_dataflex_host_reset() -> None:
  1466. """Send ~JA/~RO/~JR to clear E1005 latch without turning the printer off."""
  1467. ip = (settings.get("dabag_ip") or "").strip()
  1468. port_str = (settings.get("dabag_port") or "3008").strip()
  1469. if not ip:
  1470. messagebox.showwarning("打袋機", "請先在「設定」填寫打袋機 IP。")
  1471. return
  1472. try:
  1473. port = int(port_str)
  1474. except ValueError:
  1475. port = 3008
  1476. def worker() -> None:
  1477. try:
  1478. send_dataflex_job_counter_reset(ip, port)
  1479. root.after(
  1480. 0,
  1481. lambda: messagebox.showinfo(
  1482. "打袋機",
  1483. "已送出主機重設(緩衝清除/計數/軟重設)。\n"
  1484. "若畫面仍顯示 E1005,請再按一次或關機重開。",
  1485. ),
  1486. )
  1487. except OSError as ex:
  1488. root.after(
  1489. 0,
  1490. lambda e=str(ex): messagebox.showerror(
  1491. "打袋機",
  1492. f"連線失敗,無法重設:{e}",
  1493. ),
  1494. )
  1495. threading.Thread(target=worker, daemon=True).start()
  1496. ttk.Button(right_frame, text="打袋重設", command=on_dataflex_host_reset).pack(
  1497. side=tk.LEFT, padx=(0, 8)
  1498. )
  1499. # 列印機 label: green when printer connected, red when not (checked periodically)
  1500. printer_status_lbl = tk.Label(
  1501. right_frame,
  1502. text="列印機:",
  1503. font=get_font(FONT_SIZE),
  1504. bg=BG_STATUS_ERROR,
  1505. fg="black",
  1506. padx=6,
  1507. pady=2,
  1508. )
  1509. printer_status_lbl.pack(side=tk.LEFT, padx=(0, 4))
  1510. # tk.OptionMenu (not ttk.Combobox): on Windows the ttk dropdown uses OS font and stays tiny;
  1511. # OptionMenu's menu supports font= for the open list.
  1512. printer_combo = tk.OptionMenu(right_frame, printer_var, *printer_options)
  1513. _combo_font = get_font(FONT_SIZE_COMBO)
  1514. printer_combo.configure(
  1515. font=_combo_font,
  1516. bg=BG_TOP,
  1517. fg="black",
  1518. activebackground=BG_TOP,
  1519. activeforeground="black",
  1520. width=14,
  1521. anchor="w",
  1522. highlightthickness=0,
  1523. bd=1,
  1524. relief=tk.GROOVE,
  1525. )
  1526. printer_combo["menu"].configure(font=_combo_font, tearoff=0)
  1527. printer_combo.pack(side=tk.LEFT)
  1528. printer_after_ref = [None]
  1529. def set_printer_status_ok():
  1530. printer_status_lbl.configure(bg=BG_STATUS_OK, fg=FG_STATUS_OK)
  1531. def set_printer_status_error():
  1532. printer_status_lbl.configure(bg=BG_STATUS_ERROR, fg=FG_STATUS_ERROR)
  1533. def check_printer() -> None:
  1534. if printer_after_ref[0] is not None:
  1535. root.after_cancel(printer_after_ref[0])
  1536. printer_after_ref[0] = None
  1537. ok = try_printer_connection(printer_var.get(), settings)
  1538. if ok:
  1539. set_printer_status_ok()
  1540. printer_after_ref[0] = root.after(PRINTER_CHECK_MS, check_printer)
  1541. else:
  1542. set_printer_status_error()
  1543. printer_after_ref[0] = root.after(PRINTER_RETRY_MS, check_printer)
  1544. def on_printer_selection_changed(*args) -> None:
  1545. check_printer()
  1546. if printer_var.get() != "激光機":
  1547. if laser_conn_ref[0] is not None:
  1548. try:
  1549. laser_conn_ref[0].close()
  1550. except Exception:
  1551. pass
  1552. laser_conn_ref[0] = None
  1553. # DataFlex continuous stop panel can drop behind after OptionMenu closes; re-lift for any choice.
  1554. root.after(100, lift_dataflex_stop_if_running)
  1555. printer_var.trace_add("write", on_printer_selection_changed)
  1556. def open_setup_window(parent_win: tk.Tk, sett: dict, base_url_ref_list: list) -> None:
  1557. """Modal setup: API IP/port, 打袋機/激光機 IP+port, 標簽機 COM port."""
  1558. d = tk.Toplevel(parent_win)
  1559. d.title("設定")
  1560. d.geometry("440x520")
  1561. d.transient(parent_win)
  1562. d.grab_set()
  1563. d.configure(bg=BG_TOP)
  1564. f = tk.Frame(d, padx=16, pady=16, bg=BG_TOP)
  1565. f.pack(fill=tk.BOTH, expand=True)
  1566. grid_row = [0] # use list so inner function can update
  1567. def _ensure_dot_in_entry(entry: tk.Entry) -> None:
  1568. """Allow typing dot (.) in Entry when IME or layout blocks it (e.g. 192.168.17.27)."""
  1569. def on_key(event):
  1570. if event.keysym in ("period", "decimal"):
  1571. pos = entry.index(tk.INSERT)
  1572. entry.insert(tk.INSERT, ".")
  1573. return "break"
  1574. entry.bind("<KeyPress>", on_key)
  1575. def add_section(label_text: str, key_ip: str | None, key_port: str | None, key_single: str | None):
  1576. out = []
  1577. ttk.Label(f, text=label_text, font=get_font(FONT_SIZE_BUTTONS)).grid(
  1578. row=grid_row[0], column=0, columnspan=2, sticky=tk.W, pady=(8, 2)
  1579. )
  1580. grid_row[0] += 1
  1581. if key_single:
  1582. ttk.Label(
  1583. f,
  1584. text="列印機名稱 (Windows):",
  1585. ).grid(
  1586. row=grid_row[0],
  1587. column=0,
  1588. sticky=tk.W,
  1589. pady=2,
  1590. )
  1591. var = tk.StringVar(value=sett.get(key_single, ""))
  1592. e = tk.Entry(f, textvariable=var, width=22, font=get_font(FONT_SIZE), bg="white")
  1593. e.grid(row=grid_row[0], column=1, sticky=tk.W, pady=2)
  1594. _ensure_dot_in_entry(e)
  1595. grid_row[0] += 1
  1596. return [(key_single, var)]
  1597. if key_ip:
  1598. ttk.Label(f, text="IP:").grid(row=grid_row[0], column=0, sticky=tk.W, pady=2)
  1599. var_ip = tk.StringVar(value=sett.get(key_ip, ""))
  1600. e_ip = tk.Entry(f, textvariable=var_ip, width=22, font=get_font(FONT_SIZE), bg="white")
  1601. e_ip.grid(row=grid_row[0], column=1, sticky=tk.W, pady=2)
  1602. _ensure_dot_in_entry(e_ip)
  1603. grid_row[0] += 1
  1604. out.append((key_ip, var_ip))
  1605. if key_port:
  1606. ttk.Label(f, text="Port:").grid(row=grid_row[0], column=0, sticky=tk.W, pady=2)
  1607. var_port = tk.StringVar(value=sett.get(key_port, ""))
  1608. e_port = tk.Entry(f, textvariable=var_port, width=12, font=get_font(FONT_SIZE), bg="white")
  1609. e_port.grid(row=grid_row[0], column=1, sticky=tk.W, pady=2)
  1610. _ensure_dot_in_entry(e_port)
  1611. grid_row[0] += 1
  1612. out.append((key_port, var_port))
  1613. return out
  1614. all_vars = []
  1615. all_vars.extend(add_section("API 伺服器", "api_ip", "api_port", None))
  1616. all_vars.extend(add_section("打袋機 DataFlex", "dabag_ip", "dabag_port", None))
  1617. all_vars.extend(add_section("激光機", "laser_ip", "laser_port", None))
  1618. all_vars.extend(add_section("標簽機 (USB)", None, None, "label_com"))
  1619. def on_save():
  1620. for key, var in all_vars:
  1621. sett[key] = var.get().strip()
  1622. save_settings(sett)
  1623. base_url_ref_list[0] = build_base_url(sett["api_ip"], sett["api_port"])
  1624. d.destroy()
  1625. btn_f = tk.Frame(d, bg=BG_TOP)
  1626. btn_f.pack(pady=12)
  1627. ttk.Button(btn_f, text="儲存", command=on_save).pack(side=tk.LEFT, padx=4)
  1628. ttk.Button(btn_f, text="取消", command=d.destroy).pack(side=tk.LEFT, padx=4)
  1629. d.wait_window()
  1630. job_orders_frame = tk.Frame(root, bg=BG_LIST)
  1631. job_orders_frame.pack(fill=tk.BOTH, expand=True, padx=12, pady=12)
  1632. search_var = tk.StringVar()
  1633. search_frame = tk.Frame(job_orders_frame, bg=BG_LIST)
  1634. search_frame.pack(fill=tk.X, pady=(0, 6))
  1635. tk.Label(
  1636. search_frame,
  1637. text="搜尋品號/工單/批號:",
  1638. font=get_font(FONT_SIZE_QTY),
  1639. bg=BG_LIST,
  1640. fg="black",
  1641. ).pack(side=tk.LEFT, padx=(0, 6))
  1642. search_entry = tk.Entry(
  1643. search_frame,
  1644. textvariable=search_var,
  1645. width=32,
  1646. font=get_font(FONT_SIZE_QTY),
  1647. bg="white",
  1648. )
  1649. search_entry.pack(side=tk.LEFT, fill=tk.X, expand=True, padx=(0, 8))
  1650. # Scrollable area for buttons
  1651. canvas = tk.Canvas(job_orders_frame, highlightthickness=0, bg=BG_LIST)
  1652. scrollbar = ttk.Scrollbar(job_orders_frame, orient=tk.VERTICAL, command=canvas.yview)
  1653. inner = tk.Frame(canvas, bg=BG_LIST)
  1654. win_id = canvas.create_window((0, 0), window=inner, anchor=tk.NW)
  1655. canvas.configure(yscrollcommand=scrollbar.set)
  1656. def _on_inner_configure(event):
  1657. canvas.configure(scrollregion=canvas.bbox("all"))
  1658. def _on_canvas_configure(event):
  1659. canvas.itemconfig(win_id, width=event.width)
  1660. inner.bind("<Configure>", _on_inner_configure)
  1661. canvas.bind("<Configure>", _on_canvas_configure)
  1662. # Mouse wheel: default Tk scroll speed (one unit per notch)
  1663. def _on_mousewheel(event):
  1664. if getattr(event, "delta", None) is not None:
  1665. canvas.yview_scroll(int(-1 * (event.delta / 120)), "units")
  1666. elif event.num == 5:
  1667. canvas.yview_scroll(1, "units")
  1668. elif event.num == 4:
  1669. canvas.yview_scroll(-1, "units")
  1670. canvas.bind("<MouseWheel>", _on_mousewheel)
  1671. inner.bind("<MouseWheel>", _on_mousewheel)
  1672. canvas.bind("<Button-4>", _on_mousewheel)
  1673. canvas.bind("<Button-5>", _on_mousewheel)
  1674. inner.bind("<Button-4>", _on_mousewheel)
  1675. inner.bind("<Button-5>", _on_mousewheel)
  1676. scrollbar.pack(side=tk.RIGHT, fill=tk.Y)
  1677. canvas.pack(side=tk.LEFT, fill=tk.BOTH, expand=True)
  1678. # Track which row is highlighted (selected for printing) and which job id
  1679. selected_row_holder = [None] # [tk.Frame | None]
  1680. selected_jo_id_ref = [None] # [int | None] job order id for selection preservation
  1681. last_data_ref = [None] # [list | None] last successful fetch for current date
  1682. last_plan_start_ref = [date.today()] # plan date for the current list (search filter uses same)
  1683. after_id_ref = [None] # [str | None] root.after id to cancel retry/refresh
  1684. def _data_equal(a: Optional[list], b: Optional[list]) -> bool:
  1685. if a is None or b is None:
  1686. return a is b
  1687. if len(a) != len(b):
  1688. return False
  1689. for x, y in zip(a, b):
  1690. if x.get("id") != y.get("id"):
  1691. return False
  1692. for k in ("bagPrintedQty", "labelPrintedQty", "laserPrintedQty"):
  1693. if x.get(k) != y.get(k):
  1694. return False
  1695. return True
  1696. def _build_list_from_data(data: list, plan_start: date, preserve_selection: bool) -> None:
  1697. selected_row_holder[0] = None
  1698. year = plan_start.year
  1699. selected_id = selected_jo_id_ref[0] if preserve_selection else None
  1700. found_row = None
  1701. for jo in data:
  1702. jo_id = jo.get("id")
  1703. raw_batch = batch_no(year, jo_id) if jo_id is not None else "—"
  1704. lot_no_val = jo.get("lotNo")
  1705. batch = (lot_no_val or "—").strip() if lot_no_val else "—"
  1706. jo_no_display = (jo.get("code") or "").strip()
  1707. if not jo_no_display and jo_id is not None:
  1708. jo_no_display = raw_batch
  1709. elif not jo_no_display:
  1710. jo_no_display = "—"
  1711. # Line 1: job order no.; line 2: 需求 + 已印(袋/標/激)on one row for compact scrolling
  1712. head_line = f"工單:{jo_no_display}"
  1713. item_code = jo.get("itemCode") or "—"
  1714. item_name = jo.get("itemName") or "—"
  1715. req_qty = jo.get("reqQty")
  1716. qty_str = format_qty(req_qty)
  1717. bag_pq = _printed_qty_int(jo.get("bagPrintedQty"))
  1718. label_pq = _printed_qty_int(jo.get("labelPrintedQty"))
  1719. laser_pq = _printed_qty_int(jo.get("laserPrintedQty"))
  1720. meta_line = (
  1721. f"需求:{qty_str} "
  1722. f"已印 袋{bag_pq:,} 標{label_pq:,} 激{laser_pq:,}"
  1723. )
  1724. # Columns: fixed-width left | fixed-width 品號 | 品名 (expand)
  1725. row = tk.Frame(
  1726. inner,
  1727. bg=BG_ROW,
  1728. relief=tk.RAISED,
  1729. bd=2,
  1730. cursor="hand2",
  1731. padx=10,
  1732. pady=LIST_ROW_IPADY,
  1733. )
  1734. row.pack(fill=tk.X, pady=LIST_ROW_PADY)
  1735. left = tk.Frame(row, bg=BG_ROW, width=LEFT_COL_WIDTH_PX)
  1736. left.pack_propagate(False)
  1737. left.pack(side=tk.LEFT, anchor=tk.NW, fill=tk.Y)
  1738. batch_lbl = tk.Label(
  1739. left,
  1740. text=head_line,
  1741. font=get_font(FONT_SIZE_BUTTONS),
  1742. bg=BG_ROW,
  1743. fg="black",
  1744. )
  1745. batch_lbl.pack(anchor=tk.W)
  1746. meta_lbl = tk.Label(
  1747. left,
  1748. text=meta_line,
  1749. font=get_font(FONT_SIZE_META),
  1750. bg=BG_ROW,
  1751. fg="#222222",
  1752. anchor=tk.W,
  1753. justify=tk.LEFT,
  1754. wraplength=LEFT_COL_WIDTH_PX - 8,
  1755. )
  1756. meta_lbl.pack(anchor=tk.W)
  1757. code_col = tk.Frame(row, bg=BG_ROW, width=CODE_COL_WIDTH_PX)
  1758. code_col.pack_propagate(False)
  1759. code_col.pack(side=tk.LEFT, anchor=tk.NW, fill=tk.Y, padx=(6, 2))
  1760. code_lbl = tk.Label(
  1761. code_col,
  1762. text=item_code,
  1763. font=get_font(FONT_SIZE_ITEM_CODE),
  1764. bg=BG_ROW,
  1765. fg="black",
  1766. wraplength=ITEM_CODE_WRAP,
  1767. justify=tk.LEFT,
  1768. anchor=tk.NW,
  1769. )
  1770. code_lbl.pack(anchor=tk.NW)
  1771. name_col = tk.Frame(row, bg=BG_ROW)
  1772. name_col.pack(side=tk.LEFT, fill=tk.BOTH, expand=True, anchor=tk.NW)
  1773. name_lbl = tk.Label(
  1774. name_col,
  1775. text=item_name or "—",
  1776. font=get_font(FONT_SIZE_ITEM_NAME),
  1777. bg=BG_ROW,
  1778. fg="black",
  1779. wraplength=ITEM_NAME_WRAP,
  1780. justify=tk.LEFT,
  1781. anchor=tk.NW,
  1782. )
  1783. name_lbl.pack(anchor=tk.NW)
  1784. def _on_click(e, j=jo, b=batch, r=row):
  1785. if selected_row_holder[0] is not None:
  1786. set_row_highlight(selected_row_holder[0], False)
  1787. set_row_highlight(r, True)
  1788. selected_row_holder[0] = r
  1789. selected_jo_id_ref[0] = j.get("id")
  1790. if printer_var.get() == "打袋機 DataFlex":
  1791. ip = (settings.get("dabag_ip") or "").strip()
  1792. port_str = (settings.get("dabag_port") or "3008").strip()
  1793. try:
  1794. port = int(port_str)
  1795. except ValueError:
  1796. port = 3008
  1797. if not ip:
  1798. messagebox.showerror("打袋機", "請在設定中填寫打袋機 DataFlex 的 IP。")
  1799. else:
  1800. bag_ans = ask_bag_count(root)
  1801. if bag_ans is not None:
  1802. n, continuous = bag_ans
  1803. item_code = j.get("itemCode") or "—"
  1804. item_name = j.get("itemName") or "—"
  1805. item_id = j.get("itemId")
  1806. stock_in_line_id = j.get("stockInLineId")
  1807. lot_no = j.get("lotNo")
  1808. zpl = generate_zpl_dataflex(
  1809. b,
  1810. item_code,
  1811. item_name,
  1812. item_id=item_id,
  1813. stock_in_line_id=stock_in_line_id,
  1814. lot_no=lot_no,
  1815. )
  1816. label_text = (lot_no or b).strip()
  1817. if continuous:
  1818. stop_ev = threading.Event()
  1819. stop_win = open_dataflex_stop_window(
  1820. root, stop_ev, dataflex_stop_win_ref
  1821. )
  1822. def dflex_worker() -> None:
  1823. with dataflex_lock:
  1824. if dataflex_busy_ref[0]:
  1825. root.after(
  1826. 0,
  1827. lambda: messagebox.showwarning(
  1828. "打袋機",
  1829. "請等待目前列印完成或先停止連續列印。",
  1830. ),
  1831. )
  1832. return
  1833. dataflex_busy_ref[0] = True
  1834. printed = 0
  1835. error_shown = False
  1836. try:
  1837. # One TCP job per bag (not one endless stream). Persistent socket
  1838. # caused E1005 over-qty on some DataFlex units after a few labels.
  1839. send_dataflex_job_counter_reset(ip, port)
  1840. while not stop_ev.is_set():
  1841. send_zpl_to_dataflex(ip, port, zpl)
  1842. printed += 1
  1843. _sleep_interruptible(
  1844. stop_ev,
  1845. DATAFLEX_INTER_LABEL_DELAY_SEC,
  1846. )
  1847. except ConnectionRefusedError:
  1848. error_shown = True
  1849. root.after(
  1850. 0,
  1851. lambda: set_status_message(
  1852. f"無法連線至 {ip}:{port},請確認印表機已開機且 IP 正確。",
  1853. is_error=True,
  1854. ),
  1855. )
  1856. except socket.timeout:
  1857. error_shown = True
  1858. root.after(
  1859. 0,
  1860. lambda: set_status_message(
  1861. f"連線逾時 ({ip}:{port}),請檢查網路與連接埠。",
  1862. is_error=True,
  1863. ),
  1864. )
  1865. except OSError as err:
  1866. error_shown = True
  1867. root.after(
  1868. 0,
  1869. lambda e=err: set_status_message(
  1870. f"列印失敗:{e}",
  1871. is_error=True,
  1872. ),
  1873. )
  1874. finally:
  1875. with dataflex_lock:
  1876. dataflex_busy_ref[0] = False
  1877. def _done() -> None:
  1878. dataflex_stop_win_ref[0] = None
  1879. try:
  1880. if os.name == "nt":
  1881. stop_win.attributes("-topmost", False)
  1882. except tk.TclError:
  1883. pass
  1884. try:
  1885. stop_win.destroy()
  1886. except tk.TclError:
  1887. pass
  1888. if printed > 0:
  1889. set_status_message(
  1890. f"連續列印結束:批次 {label_text},已印 {printed} 張",
  1891. is_error=False,
  1892. )
  1893. jo_id = j.get("id")
  1894. if jo_id is not None:
  1895. try:
  1896. submit_job_order_print_submit(
  1897. base_url_ref[0],
  1898. int(jo_id),
  1899. printed,
  1900. "DATAFLEX",
  1901. )
  1902. load_job_orders(from_user_date_change=False)
  1903. except requests.RequestException as ex:
  1904. messagebox.showwarning(
  1905. "打袋機",
  1906. f"已印 {printed} 張,但伺服器記錄失敗:{ex}",
  1907. )
  1908. elif not error_shown:
  1909. set_status_message(
  1910. "連續列印未印出或已取消",
  1911. is_error=True,
  1912. )
  1913. root.after(0, _done)
  1914. threading.Thread(target=dflex_worker, daemon=True).start()
  1915. else:
  1916. run_dataflex_fixed_qty_thread(
  1917. root=root,
  1918. dataflex_lock=dataflex_lock,
  1919. dataflex_busy_ref=dataflex_busy_ref,
  1920. ip=ip,
  1921. port=port,
  1922. n=n,
  1923. zpl=zpl,
  1924. label_text=label_text,
  1925. jo_id=j.get("id"),
  1926. base_url=base_url_ref[0],
  1927. set_status_message=set_status_message,
  1928. on_recorded=lambda: load_job_orders(
  1929. from_user_date_change=False
  1930. ),
  1931. )
  1932. elif printer_var.get() == "標簽機":
  1933. com = (settings.get("label_com") or "").strip()
  1934. if not com:
  1935. messagebox.showerror("標簽機", "請在設定中填寫標簽機名稱 (例如:TSC TTP-246M Pro)。")
  1936. else:
  1937. count = ask_label_count(root)
  1938. lift_dataflex_stop_if_running()
  1939. if count is not None:
  1940. item_code = j.get("itemCode") or "—"
  1941. item_name = j.get("itemName") or "—"
  1942. item_id = j.get("itemId")
  1943. stock_in_line_id = j.get("stockInLineId")
  1944. lot_no = j.get("lotNo")
  1945. n = count
  1946. try:
  1947. # Always render to image (Chinese OK), then send as ZPL graphic (^GFA).
  1948. # This is more reliable than Windows GDI and works for both Windows printer name and COM.
  1949. if not _HAS_PIL_QR:
  1950. raise RuntimeError("請先安裝 Pillow + qrcode(pip install Pillow qrcode[pil])。")
  1951. label_img = render_label_to_image(
  1952. b, item_code, item_name,
  1953. item_id=item_id, stock_in_line_id=stock_in_line_id,
  1954. lot_no=lot_no,
  1955. )
  1956. zpl_img = _image_to_zpl_gfa(label_img)
  1957. run_label_print_batch_thread(
  1958. root=root,
  1959. label_lock=label_lock,
  1960. label_busy_ref=label_busy_ref,
  1961. com=com,
  1962. zpl_img=zpl_img,
  1963. n=n,
  1964. jo_id=j.get("id"),
  1965. base_url=base_url_ref[0],
  1966. set_status_message=set_status_message,
  1967. on_recorded=lambda: load_job_orders(
  1968. from_user_date_change=False
  1969. ),
  1970. )
  1971. except Exception as err:
  1972. messagebox.showerror("標簽機", f"列印失敗:{err}")
  1973. elif printer_var.get() == "激光機":
  1974. ip = (settings.get("laser_ip") or "").strip()
  1975. port_str = (settings.get("laser_port") or "45678").strip()
  1976. try:
  1977. port = int(port_str)
  1978. except ValueError:
  1979. port = 45678
  1980. if not ip:
  1981. set_status_message("請在設定中填寫激光機的 IP。", is_error=True)
  1982. else:
  1983. item_id = j.get("itemId")
  1984. stock_in_line_id = j.get("stockInLineId")
  1985. item_code_val = j.get("itemCode") or ""
  1986. item_name_val = j.get("itemName") or ""
  1987. run_laser_row_send_thread(
  1988. root=root,
  1989. laser_conn_ref=laser_conn_ref,
  1990. laser_busy_ref=laser_send_busy_ref,
  1991. ip=ip,
  1992. port=port,
  1993. item_id=item_id,
  1994. stock_in_line_id=stock_in_line_id,
  1995. item_code=item_code_val,
  1996. item_name=item_name_val,
  1997. set_status_message=set_status_message,
  1998. base_url=base_url_ref[0],
  1999. job_order_id=j.get("id"),
  2000. on_recorded=lambda: load_job_orders(from_user_date_change=False),
  2001. )
  2002. for w in (
  2003. row,
  2004. left,
  2005. batch_lbl,
  2006. meta_lbl,
  2007. code_col,
  2008. code_lbl,
  2009. name_col,
  2010. name_lbl,
  2011. ):
  2012. w.bind("<Button-1>", _on_click)
  2013. w.bind("<MouseWheel>", _on_mousewheel)
  2014. w.bind("<Button-4>", _on_mousewheel)
  2015. w.bind("<Button-5>", _on_mousewheel)
  2016. if preserve_selection and selected_id is not None and jo.get("id") == selected_id:
  2017. found_row = row
  2018. if found_row is not None:
  2019. set_row_highlight(found_row, True)
  2020. selected_row_holder[0] = found_row
  2021. def refresh_visible_list() -> None:
  2022. """Re-apply search filter to last fetched rows without hitting the API."""
  2023. raw = last_data_ref[0]
  2024. if raw is None:
  2025. return
  2026. ps = last_plan_start_ref[0]
  2027. needle = search_var.get().strip()
  2028. shown = _filter_job_orders_by_search(raw, needle) if needle else raw
  2029. for w in inner.winfo_children():
  2030. w.destroy()
  2031. _build_list_from_data(shown, ps, preserve_selection=True)
  2032. search_entry.bind("<KeyRelease>", lambda e: refresh_visible_list())
  2033. def load_job_orders(from_user_date_change: bool = False) -> None:
  2034. if after_id_ref[0] is not None:
  2035. root.after_cancel(after_id_ref[0])
  2036. after_id_ref[0] = None
  2037. date_str = date_var.get().strip()
  2038. try:
  2039. plan_start = date.fromisoformat(date_str)
  2040. except ValueError:
  2041. messagebox.showerror("日期錯誤", f"請使用 yyyy-MM-dd 格式。目前:{date_str}")
  2042. return
  2043. if from_user_date_change:
  2044. selected_row_holder[0] = None
  2045. selected_jo_id_ref[0] = None
  2046. try:
  2047. data = fetch_job_orders(base_url_ref[0], plan_start)
  2048. except requests.RequestException:
  2049. set_status_error()
  2050. after_id_ref[0] = root.after(RETRY_MS, lambda: load_job_orders(from_user_date_change=False))
  2051. return
  2052. set_status_ok()
  2053. old_data = last_data_ref[0]
  2054. last_data_ref[0] = data
  2055. last_plan_start_ref[0] = plan_start
  2056. data_changed = not _data_equal(old_data, data)
  2057. if data_changed or from_user_date_change:
  2058. # Rebuild list: clear and rebuild from current data (last_data_ref already updated)
  2059. for w in inner.winfo_children():
  2060. w.destroy()
  2061. preserve = not from_user_date_change
  2062. needle = search_var.get().strip()
  2063. shown = _filter_job_orders_by_search(data, needle) if needle else data
  2064. _build_list_from_data(shown, plan_start, preserve_selection=preserve)
  2065. if from_user_date_change:
  2066. canvas.yview_moveto(0)
  2067. after_id_ref[0] = root.after(REFRESH_MS, lambda: load_job_orders(from_user_date_change=False))
  2068. # Load default (today) on start; then start printer connection check
  2069. root.after(100, lambda: load_job_orders(from_user_date_change=True))
  2070. root.after(300, check_printer)
  2071. root.mainloop()
  2072. if __name__ == "__main__":
  2073. main()