diff --git a/python/Bag1.py b/python/Bag1.py new file mode 100644 index 0000000..fb1518f --- /dev/null +++ b/python/Bag1.py @@ -0,0 +1,690 @@ +#!/usr/bin/env python3 +""" +Bag1 – GUI to show FPSMS job orders by plan date. +Uses the public API GET /py/job-orders (no login required). +UI tuned for aged users: larger font, Traditional Chinese labels, prev/next date. + +Run: python Bag1.py +""" + +import json +import os +import socket +import sys +import tkinter as tk +from datetime import date, timedelta +from tkinter import messagebox, ttk +from typing import Optional + +import requests + +try: + import serial +except ImportError: + serial = None # type: ignore + +DEFAULT_BASE_URL = os.environ.get("FPSMS_BASE_URL", "http://localhost:8090/api") +# When run as PyInstaller exe, save settings next to the exe; otherwise next to script +if getattr(sys, "frozen", False): + _SETTINGS_DIR = os.path.dirname(sys.executable) +else: + _SETTINGS_DIR = os.path.dirname(os.path.abspath(__file__)) +SETTINGS_FILE = os.path.join(_SETTINGS_DIR, "bag1_settings.json") + +DEFAULT_SETTINGS = { + "api_ip": "localhost", + "api_port": "8090", + "dabag_ip": "", + "dabag_port": "3008", + "laser_ip": "", + "laser_port": "9100", + "label_com": "COM3", +} + + +def load_settings() -> dict: + """Load settings from JSON file; return defaults if missing or invalid.""" + try: + if os.path.isfile(SETTINGS_FILE): + with open(SETTINGS_FILE, "r", encoding="utf-8") as f: + data = json.load(f) + return {**DEFAULT_SETTINGS, **data} + except Exception: + pass + return dict(DEFAULT_SETTINGS) + + +def save_settings(settings: dict) -> None: + """Save settings to JSON file.""" + with open(SETTINGS_FILE, "w", encoding="utf-8") as f: + json.dump(settings, f, indent=2, ensure_ascii=False) + + +def build_base_url(api_ip: str, api_port: str) -> str: + ip = (api_ip or "localhost").strip() + port = (api_port or "8090").strip() + return f"http://{ip}:{port}/api" + + +def try_printer_connection(printer_name: str, sett: dict) -> bool: + """Try to connect to the selected printer (TCP IP:port or COM). Returns True if OK.""" + if printer_name == "打袋機 DataFlex": + ip = (sett.get("dabag_ip") or "").strip() + port_str = (sett.get("dabag_port") or "9100").strip() + if not ip: + return False + try: + port = int(port_str) + s = socket.create_connection((ip, port), timeout=PRINTER_SOCKET_TIMEOUT) + s.close() + return True + except (socket.error, ValueError, OSError): + return False + if printer_name == "激光機": + ip = (sett.get("laser_ip") or "").strip() + port_str = (sett.get("laser_port") or "9100").strip() + if not ip: + return False + try: + port = int(port_str) + s = socket.create_connection((ip, port), timeout=PRINTER_SOCKET_TIMEOUT) + s.close() + return True + except (socket.error, ValueError, OSError): + return False + if printer_name == "標簽機": + if serial is None: + return False + com = (sett.get("label_com") or "").strip() + if not com: + return False + try: + ser = serial.Serial(com, timeout=1) + ser.close() + return True + except (serial.SerialException, OSError): + return False + return False + +# Larger font for aged users (point size) +FONT_SIZE = 16 +FONT_SIZE_BUTTONS = 15 +FONT_SIZE_QTY = 12 # smaller for 數量 under batch no. +FONT_SIZE_ITEM = 20 # item code and item name (larger for readability) +FONT_FAMILY = "Microsoft JhengHei UI" # Traditional Chinese; fallback to TkDefaultFont +# Column widths: item code own column; item name at least double, wraps in its column +ITEM_CODE_WRAP = 140 # item code column width (long codes wrap under code only) +ITEM_NAME_WRAP = 640 # item name column (double width), wraps under name only + +# Light blue theme (softer than pure grey) +BG_TOP = "#E8F4FC" +BG_LIST = "#D4E8F7" +BG_ROOT = "#E1F0FF" +BG_ROW = "#C5E1F5" +BG_ROW_SELECTED = "#6BB5FF" # highlighted when selected (for printing) +# Connection status bar +BG_STATUS_ERROR = "#FFCCCB" # red background when disconnected +FG_STATUS_ERROR = "#B22222" # red text +BG_STATUS_OK = "#90EE90" # light green when connected +FG_STATUS_OK = "#006400" # green text +RETRY_MS = 30 * 1000 # 30 seconds reconnect +REFRESH_MS = 60 * 1000 # 60 seconds refresh when connected +PRINTER_CHECK_MS = 60 * 1000 # 1 minute when printer OK +PRINTER_RETRY_MS = 30 * 1000 # 30 seconds when printer failed +PRINTER_SOCKET_TIMEOUT = 3 +DATAFLEX_SEND_TIMEOUT = 10 # seconds when sending ZPL to DataFlex + + +def _zpl_escape(s: str) -> str: + """Escape text for ZPL ^FD...^FS (backslash and caret).""" + return s.replace("\\", "\\\\").replace("^", "\\^") + + +def generate_zpl_dataflex( + batch_no: str, + item_code: str, + item_name: str, + font_regular: str = "E:STXihei.ttf", + font_bold: str = "E:STXihei.ttf", +) -> str: + """ + Generate ZPL for DataFlex label (53 mm media, 90° rotated). + Uses UTF-8 (^CI28) and configurable .TTF fonts for Chinese (e.g. E:STXihei.ttf). + """ + desc = _zpl_escape((item_name or "—").strip()) + code = _zpl_escape((item_code or "—").strip()) + batch = _zpl_escape(batch_no.strip()) + return f"""^XA +^PW420 +^LL780 +^PO N +^CI28 +^FO70,70 +^A@R,60,60,{font_regular}^FD{desc}^FS +^FO220,70 +^A@R,50,50,{font_bold}^FD{code}^FS +^FO310,70 +^A@R,45,45,{font_bold}^FD批次: {batch}^FS +^FO150,420 +^BQN,2,6^FDQA,{batch_no}^FS +^XZ""" + + +def send_zpl_to_dataflex(ip: str, port: int, zpl: str) -> None: + """Send ZPL to DataFlex printer via TCP. Raises on connection/send error.""" + sock = socket.socket(socket.AF_INET, socket.SOCK_STREAM) + sock.settimeout(DATAFLEX_SEND_TIMEOUT) + try: + sock.connect((ip, port)) + sock.sendall(zpl.encode("utf-8")) + finally: + sock.close() + + +def format_qty(val) -> str: + """Format quantity: integer without .0, with thousand separator.""" + if val is None: + return "—" + try: + n = float(val) + if n == int(n): + return f"{int(n):,}" + return f"{n:,.2f}".rstrip("0").rstrip(".") + except (TypeError, ValueError): + return str(val) + + +def batch_no(year: int, job_order_id: int) -> str: + """Batch no.: B + 4-digit year + jobOrderId zero-padded to 6 digits.""" + return f"B{year}{job_order_id:06d}" + + +def get_font(size: int = FONT_SIZE, bold: bool = False) -> tuple: + try: + return (FONT_FAMILY, size, "bold" if bold else "normal") + except Exception: + return ("TkDefaultFont", size, "bold" if bold else "normal") + + +def fetch_job_orders(base_url: str, plan_start: date) -> list: + """Call GET /py/job-orders and return the JSON list.""" + url = f"{base_url.rstrip('/')}/py/job-orders" + params = {"planStart": plan_start.isoformat()} + resp = requests.get(url, params=params, timeout=30) + resp.raise_for_status() + return resp.json() + + +def set_row_highlight(row_frame: tk.Frame, selected: bool) -> None: + """Set row and all its child widgets to selected or normal background.""" + bg = BG_ROW_SELECTED if selected else BG_ROW + row_frame.configure(bg=bg) + for w in row_frame.winfo_children(): + if isinstance(w, (tk.Frame, tk.Label)): + w.configure(bg=bg) + for c in w.winfo_children(): + if isinstance(c, tk.Label): + c.configure(bg=bg) + + +def on_job_order_click(jo: dict, batch: str) -> None: + """Show message and highlight row (keeps printing to selected printer).""" + item_code = jo.get("itemCode") or "—" + item_name = jo.get("itemName") or "—" + messagebox.showinfo( + "工單", + f'已點選:批次 {batch}\n品號 {item_code} {item_name}', + ) + + +def ask_label_count(parent: tk.Tk) -> Optional[str]: + """ + When printer is 標簽機, ask how many labels to print. + Returns "1", "10", "50", "100", "C" (continuous), or None if cancelled. + """ + result = [None] # mutable so inner callback can set it + + win = tk.Toplevel(parent) + win.title("標簽列印數量") + win.geometry("360x180") + win.transient(parent) + win.grab_set() + win.configure(bg=BG_TOP) + ttk.Label(win, text="列印多少張標簽?", font=get_font(FONT_SIZE)).pack(pady=(16, 12)) + btn_frame = tk.Frame(win, bg=BG_TOP) + btn_frame.pack(pady=8) + for label, value in [("1", "1"), ("10", "10"), ("50", "50"), ("100", "100"), ("連續 (C)", "C")]: + def make_cmd(v): + def cmd(): + result[0] = v + win.destroy() + return cmd + ttk.Button(btn_frame, text=label, command=make_cmd(value), width=10).pack(side=tk.LEFT, padx=4) + win.protocol("WM_DELETE_WINDOW", win.destroy) + win.wait_window() + return result[0] + + +def main() -> None: + settings = load_settings() + base_url_ref = [build_base_url(settings["api_ip"], settings["api_port"])] + + root = tk.Tk() + root.title("FP-MTMS Bag v1.1 打袋機") + root.geometry("1120x960") + root.minsize(480, 360) + root.configure(bg=BG_ROOT) + + # Style: larger font for aged users; light blue theme + style = ttk.Style() + try: + style.configure(".", font=get_font(FONT_SIZE), background=BG_TOP) + style.configure("TButton", font=get_font(FONT_SIZE_BUTTONS), background=BG_TOP) + style.configure("TLabel", font=get_font(FONT_SIZE), background=BG_TOP) + style.configure("TEntry", font=get_font(FONT_SIZE)) + style.configure("TFrame", background=BG_TOP) + except tk.TclError: + pass + + # Status bar at top: connection state (no popup on error) + status_frame = tk.Frame(root, bg=BG_STATUS_ERROR, padx=12, pady=6) + status_frame.pack(fill=tk.X) + status_lbl = tk.Label( + status_frame, + text="連接不到服務器", + font=get_font(FONT_SIZE_BUTTONS), + bg=BG_STATUS_ERROR, + fg=FG_STATUS_ERROR, + anchor=tk.CENTER, + ) + status_lbl.pack(fill=tk.X) + + def set_status_ok(): + status_frame.configure(bg=BG_STATUS_OK) + status_lbl.configure(text="連接正常", bg=BG_STATUS_OK, fg=FG_STATUS_OK) + + def set_status_error(): + status_frame.configure(bg=BG_STATUS_ERROR) + status_lbl.configure(text="連接不到服務器", bg=BG_STATUS_ERROR, fg=FG_STATUS_ERROR) + + # Top: left [前一天] [date] [後一天] | right [printer dropdown] + top = tk.Frame(root, padx=12, pady=12, bg=BG_TOP) + top.pack(fill=tk.X) + + date_var = tk.StringVar(value=date.today().isoformat()) + printer_options = ["打袋機 DataFlex", "標簽機", "激光機"] + printer_var = tk.StringVar(value=printer_options[0]) + + def go_prev_day() -> None: + try: + d = date.fromisoformat(date_var.get().strip()) + date_var.set((d - timedelta(days=1)).isoformat()) + load_job_orders(from_user_date_change=True) + except ValueError: + date_var.set(date.today().isoformat()) + load_job_orders(from_user_date_change=True) + + def go_next_day() -> None: + try: + d = date.fromisoformat(date_var.get().strip()) + date_var.set((d + timedelta(days=1)).isoformat()) + load_job_orders(from_user_date_change=True) + except ValueError: + date_var.set(date.today().isoformat()) + load_job_orders(from_user_date_change=True) + + # 前一天 (previous day) with left arrow icon + btn_prev = ttk.Button(top, text="◀ 前一天", command=go_prev_day) + btn_prev.pack(side=tk.LEFT, padx=(0, 8)) + + # Date field (no "日期:" label); shorter width + date_entry = tk.Entry( + top, + textvariable=date_var, + font=get_font(FONT_SIZE), + width=10, + bg="white", + ) + date_entry.pack(side=tk.LEFT, padx=(0, 8), ipady=4) + + # 後一天 (next day) with right arrow icon + btn_next = ttk.Button(top, text="後一天 ▶", command=go_next_day) + btn_next.pack(side=tk.LEFT, padx=(0, 8)) + + # Top right: Setup button + printer selection + right_frame = tk.Frame(top, bg=BG_TOP) + right_frame.pack(side=tk.RIGHT) + ttk.Button(right_frame, text="設定", command=lambda: open_setup_window(root, settings, base_url_ref)).pack( + side=tk.LEFT, padx=(0, 12) + ) + # 列印機 label: green when printer connected, red when not (checked periodically) + printer_status_lbl = tk.Label( + right_frame, + text="列印機:", + font=get_font(FONT_SIZE), + bg=BG_STATUS_ERROR, + fg="black", + padx=6, + pady=2, + ) + printer_status_lbl.pack(side=tk.LEFT, padx=(0, 4)) + printer_combo = ttk.Combobox( + right_frame, + textvariable=printer_var, + values=printer_options, + state="readonly", + width=14, + font=get_font(FONT_SIZE), + ) + printer_combo.pack(side=tk.LEFT) + + printer_after_ref = [None] + + def set_printer_status_ok(): + printer_status_lbl.configure(bg=BG_STATUS_OK, fg=FG_STATUS_OK) + + def set_printer_status_error(): + printer_status_lbl.configure(bg=BG_STATUS_ERROR, fg=FG_STATUS_ERROR) + + def check_printer() -> None: + if printer_after_ref[0] is not None: + root.after_cancel(printer_after_ref[0]) + printer_after_ref[0] = None + ok = try_printer_connection(printer_var.get(), settings) + if ok: + set_printer_status_ok() + printer_after_ref[0] = root.after(PRINTER_CHECK_MS, check_printer) + else: + set_printer_status_error() + printer_after_ref[0] = root.after(PRINTER_RETRY_MS, check_printer) + + def on_printer_selection_changed(*args) -> None: + check_printer() + + printer_var.trace_add("write", on_printer_selection_changed) + + def open_setup_window(parent_win: tk.Tk, sett: dict, base_url_ref_list: list) -> None: + """Modal setup: API IP/port, 打袋機/激光機 IP+port, 標簽機 COM port.""" + d = tk.Toplevel(parent_win) + d.title("設定") + d.geometry("440x520") + d.transient(parent_win) + d.grab_set() + d.configure(bg=BG_TOP) + f = tk.Frame(d, padx=16, pady=16, bg=BG_TOP) + f.pack(fill=tk.BOTH, expand=True) + grid_row = [0] # use list so inner function can update + + def _ensure_dot_in_entry(entry: tk.Entry) -> None: + """Allow typing dot (.) in Entry when IME or layout blocks it (e.g. 192.168.17.27).""" + def on_key(event): + if event.keysym in ("period", "decimal"): + pos = entry.index(tk.INSERT) + entry.insert(tk.INSERT, ".") + return "break" + entry.bind("", on_key) + + def add_section(label_text: str, key_ip: str | None, key_port: str | None, key_single: str | None): + out = [] + ttk.Label(f, text=label_text, font=get_font(FONT_SIZE_BUTTONS)).grid( + row=grid_row[0], column=0, columnspan=2, sticky=tk.W, pady=(8, 2) + ) + grid_row[0] += 1 + if key_single: + ttk.Label(f, text="COM:").grid(row=grid_row[0], column=0, sticky=tk.W, pady=2) + var = tk.StringVar(value=sett.get(key_single, "")) + e = tk.Entry(f, textvariable=var, width=14, font=get_font(FONT_SIZE), bg="white") + e.grid(row=grid_row[0], column=1, sticky=tk.W, pady=2) + _ensure_dot_in_entry(e) + grid_row[0] += 1 + return [(key_single, var)] + if key_ip: + ttk.Label(f, text="IP:").grid(row=grid_row[0], column=0, sticky=tk.W, pady=2) + var_ip = tk.StringVar(value=sett.get(key_ip, "")) + e_ip = tk.Entry(f, textvariable=var_ip, width=22, font=get_font(FONT_SIZE), bg="white") + e_ip.grid(row=grid_row[0], column=1, sticky=tk.W, pady=2) + _ensure_dot_in_entry(e_ip) + grid_row[0] += 1 + out.append((key_ip, var_ip)) + if key_port: + ttk.Label(f, text="Port:").grid(row=grid_row[0], column=0, sticky=tk.W, pady=2) + var_port = tk.StringVar(value=sett.get(key_port, "")) + e_port = tk.Entry(f, textvariable=var_port, width=12, font=get_font(FONT_SIZE), bg="white") + e_port.grid(row=grid_row[0], column=1, sticky=tk.W, pady=2) + _ensure_dot_in_entry(e_port) + grid_row[0] += 1 + out.append((key_port, var_port)) + return out + + all_vars = [] + all_vars.extend(add_section("API 伺服器", "api_ip", "api_port", None)) + all_vars.extend(add_section("打袋機 DataFlex", "dabag_ip", "dabag_port", None)) + all_vars.extend(add_section("激光機", "laser_ip", "laser_port", None)) + all_vars.extend(add_section("標簽機 COM 埠", None, None, "label_com")) + + def on_save(): + for key, var in all_vars: + sett[key] = var.get().strip() + save_settings(sett) + base_url_ref_list[0] = build_base_url(sett["api_ip"], sett["api_port"]) + d.destroy() + + btn_f = tk.Frame(d, bg=BG_TOP) + btn_f.pack(pady=12) + ttk.Button(btn_f, text="儲存", command=on_save).pack(side=tk.LEFT, padx=4) + ttk.Button(btn_f, text="取消", command=d.destroy).pack(side=tk.LEFT, padx=4) + d.wait_window() + + job_orders_frame = tk.Frame(root, bg=BG_LIST) + job_orders_frame.pack(fill=tk.BOTH, expand=True, padx=12, pady=12) + + # Scrollable area for buttons + canvas = tk.Canvas(job_orders_frame, highlightthickness=0, bg=BG_LIST) + scrollbar = ttk.Scrollbar(job_orders_frame, orient=tk.VERTICAL, command=canvas.yview) + inner = tk.Frame(canvas, bg=BG_LIST) + + win_id = canvas.create_window((0, 0), window=inner, anchor=tk.NW) + canvas.configure(yscrollcommand=scrollbar.set) + + def _on_inner_configure(event): + canvas.configure(scrollregion=canvas.bbox("all")) + + def _on_canvas_configure(event): + canvas.itemconfig(win_id, width=event.width) + + inner.bind("", _on_inner_configure) + canvas.bind("", _on_canvas_configure) + + # Mouse wheel: make scroll work when hovering over canvas or the list (inner/buttons) + def _on_mousewheel(event): + if getattr(event, "delta", None) is not None: + canvas.yview_scroll(int(-1 * (event.delta / 120)), "units") + elif event.num == 5: + canvas.yview_scroll(1, "units") + elif event.num == 4: + canvas.yview_scroll(-1, "units") + + canvas.bind("", _on_mousewheel) + inner.bind("", _on_mousewheel) + canvas.bind("", _on_mousewheel) + canvas.bind("", _on_mousewheel) + inner.bind("", _on_mousewheel) + inner.bind("", _on_mousewheel) + + scrollbar.pack(side=tk.RIGHT, fill=tk.Y) + canvas.pack(side=tk.LEFT, fill=tk.BOTH, expand=True) + + # Track which row is highlighted (selected for printing) and which job id + selected_row_holder = [None] # [tk.Frame | None] + selected_jo_id_ref = [None] # [int | None] job order id for selection preservation + last_data_ref = [None] # [list | None] last successful fetch for current date + after_id_ref = [None] # [str | None] root.after id to cancel retry/refresh + + def _data_equal(a: Optional[list], b: Optional[list]) -> bool: + if a is None or b is None: + return a is b + if len(a) != len(b): + return False + ids_a = [x.get("id") for x in a] + ids_b = [x.get("id") for x in b] + return ids_a == ids_b + + def _build_list_from_data(data: list, plan_start: date, preserve_selection: bool) -> None: + selected_row_holder[0] = None + year = plan_start.year + selected_id = selected_jo_id_ref[0] if preserve_selection else None + found_row = None + for jo in data: + jo_id = jo.get("id") + batch = batch_no(year, jo_id) if jo_id is not None else "—" + item_code = jo.get("itemCode") or "—" + item_name = jo.get("itemName") or "—" + req_qty = jo.get("reqQty") + qty_str = format_qty(req_qty) + # Three columns: batch+數量 | item code (own column) | item name (≥2× width, wraps in column) + row = tk.Frame(inner, bg=BG_ROW, relief=tk.RAISED, bd=2, cursor="hand2", padx=12, pady=10) + row.pack(fill=tk.X, pady=4) + + left = tk.Frame(row, bg=BG_ROW) + left.pack(side=tk.LEFT, anchor=tk.NW) + batch_lbl = tk.Label( + left, + text=batch, + font=get_font(FONT_SIZE_BUTTONS), + bg=BG_ROW, + fg="black", + ) + batch_lbl.pack(anchor=tk.W) + qty_lbl = None + if qty_str != "—": + qty_lbl = tk.Label( + left, + text=f"數量:{qty_str}", + font=get_font(FONT_SIZE_QTY), + bg=BG_ROW, + fg="black", + ) + qty_lbl.pack(anchor=tk.W) + + # Column 2: item code only, bigger font, wraps in its own column + code_lbl = tk.Label( + row, + text=item_code, + font=get_font(FONT_SIZE_ITEM), + bg=BG_ROW, + fg="black", + wraplength=ITEM_CODE_WRAP, + justify=tk.LEFT, + anchor=tk.NW, + ) + code_lbl.pack(side=tk.LEFT, anchor=tk.NW, padx=(12, 8)) + + # Column 3: item name only, same bigger font, at least double width, wraps under its own column + name_lbl = tk.Label( + row, + text=item_name or "—", + font=get_font(FONT_SIZE_ITEM), + bg=BG_ROW, + fg="black", + wraplength=ITEM_NAME_WRAP, + justify=tk.LEFT, + anchor=tk.NW, + ) + name_lbl.pack(side=tk.LEFT, fill=tk.BOTH, expand=True, anchor=tk.NW) + + def _on_click(e, j=jo, b=batch, r=row): + if selected_row_holder[0] is not None: + set_row_highlight(selected_row_holder[0], False) + set_row_highlight(r, True) + selected_row_holder[0] = r + selected_jo_id_ref[0] = j.get("id") + if printer_var.get() == "打袋機 DataFlex": + ip = (settings.get("dabag_ip") or "").strip() + port_str = (settings.get("dabag_port") or "3008").strip() + try: + port = int(port_str) + except ValueError: + port = 3008 + if not ip: + messagebox.showerror("打袋機", "請在設定中填寫打袋機 DataFlex 的 IP。") + else: + item_code = j.get("itemCode") or "—" + item_name = j.get("itemName") or "—" + zpl = generate_zpl_dataflex(b, item_code, item_name) + try: + send_zpl_to_dataflex(ip, port, zpl) + messagebox.showinfo("打袋機", f"已送出列印:批次 {b}") + except ConnectionRefusedError: + messagebox.showerror("打袋機", f"無法連線至 {ip}:{port},請確認印表機已開機且 IP 正確。") + except socket.timeout: + messagebox.showerror("打袋機", f"連線逾時 ({ip}:{port}),請檢查網路與連接埠。") + except OSError as err: + messagebox.showerror("打袋機", f"列印失敗:{err}") + elif printer_var.get() == "標簽機": + count = ask_label_count(root) + if count is not None: + if count == "C": + msg = "已選擇連續列印標簽" + else: + msg = f"將列印 {count} 張標簽" + messagebox.showinfo("標簽機", msg) + on_job_order_click(j, b) + + for w in (row, left, batch_lbl, code_lbl, name_lbl): + w.bind("", _on_click) + w.bind("", _on_mousewheel) + w.bind("", _on_mousewheel) + w.bind("", _on_mousewheel) + if qty_lbl is not None: + qty_lbl.bind("", _on_click) + qty_lbl.bind("", _on_mousewheel) + qty_lbl.bind("", _on_mousewheel) + qty_lbl.bind("", _on_mousewheel) + if preserve_selection and selected_id is not None and jo.get("id") == selected_id: + found_row = row + if found_row is not None: + set_row_highlight(found_row, True) + selected_row_holder[0] = found_row + + def load_job_orders(from_user_date_change: bool = False) -> None: + if after_id_ref[0] is not None: + root.after_cancel(after_id_ref[0]) + after_id_ref[0] = None + date_str = date_var.get().strip() + try: + plan_start = date.fromisoformat(date_str) + except ValueError: + messagebox.showerror("日期錯誤", f"請使用 yyyy-MM-dd 格式。目前:{date_str}") + return + if from_user_date_change: + selected_row_holder[0] = None + selected_jo_id_ref[0] = None + try: + data = fetch_job_orders(base_url_ref[0], plan_start) + except requests.RequestException: + set_status_error() + after_id_ref[0] = root.after(RETRY_MS, lambda: load_job_orders(from_user_date_change=False)) + return + set_status_ok() + old_data = last_data_ref[0] + last_data_ref[0] = data + data_changed = not _data_equal(old_data, data) + if data_changed or from_user_date_change: + # Rebuild list: clear and rebuild from current data (last_data_ref already updated) + for w in inner.winfo_children(): + w.destroy() + preserve = not from_user_date_change + _build_list_from_data(data, plan_start, preserve_selection=preserve) + if from_user_date_change: + canvas.yview_moveto(0) + after_id_ref[0] = root.after(REFRESH_MS, lambda: load_job_orders(from_user_date_change=False)) + + # Load default (today) on start; then start printer connection check + root.after(100, lambda: load_job_orders(from_user_date_change=True)) + root.after(300, check_printer) + + root.mainloop() + + +if __name__ == "__main__": + main() diff --git a/python/Bag1.spec b/python/Bag1.spec new file mode 100644 index 0000000..8a784ef --- /dev/null +++ b/python/Bag1.spec @@ -0,0 +1,38 @@ +# -*- mode: python ; coding: utf-8 -*- + + +a = Analysis( + ['Bag1.py'], + pathex=[], + binaries=[], + datas=[], + hiddenimports=[], + hookspath=[], + hooksconfig={}, + runtime_hooks=[], + excludes=[], + noarchive=False, + optimize=0, +) +pyz = PYZ(a.pure) + +exe = EXE( + pyz, + a.scripts, + a.binaries, + a.datas, + [], + name='Bag1', + debug=False, + bootloader_ignore_signals=False, + strip=False, + upx=True, + upx_exclude=[], + runtime_tmpdir=None, + console=False, + disable_windowed_traceback=False, + argv_emulation=False, + target_arch=None, + codesign_identity=None, + entitlements_file=None, +) diff --git a/python/README.md b/python/README.md new file mode 100644 index 0000000..22871e4 --- /dev/null +++ b/python/README.md @@ -0,0 +1,54 @@ +# Python scripts for FPSMS backend + +This folder holds Python programs that integrate with the FPSMS backend (e.g. calling the public `/py` API). + +## Setup + +```bash +cd python +pip install -r requirements.txt +``` + +## Configuration + +Set the backend base URL (optional, default below): + +- Environment: `FPSMS_BASE_URL` (default: `http://localhost:8090/api` — includes context path `/api`) +- Or edit the default in each script. + +## Scripts + +| Script | Description | +|--------|-------------| +| `Bag1.py` | **GUI**: date selector (default today) and job orders as buttons; click shows "Clicked on Job Order code XXXX item xxxx". Run: `python Bag1.py` | +| `fetch_job_orders.py` | CLI: fetches job orders by plan date from `GET /py/job-orders` | +| `label_zpl.py` | ZPL label generator (90° rotated, UTF-8 Chinese, QR). `generate_zpl(batch_no, item_code, chinese_desc)`, `send_zpl(zpl, host, port)`. Run: `python label_zpl.py` to print one test label. | + +## Building Bag1 as a standalone .exe + +To distribute Bag1 to customer PCs **without giving them source code or requiring Python**: + +1. On your development PC (with Python installed), open a terminal in the `python` folder. +2. Install build dependencies: + + ```bash + pip install -r requirements-build.txt + ``` + +3. Run the build script: + + ```bash + build_exe.bat + ``` + + Or run PyInstaller directly: + + ```bash + pyinstaller --onefile --windowed --name Bag1 Bag1.py + ``` + +4. The executable is created at `dist\Bag1.exe`. Copy **only** `Bag1.exe` to the customer computer and run it; no Python or source code is needed. The app will save its settings (`bag1_settings.json`) in the same folder as the exe. + +## Adding new scripts + +Add new `.py` files here and list them in this README. Use `requirements.txt` for any new dependencies. diff --git a/python/bag1_settings.json b/python/bag1_settings.json new file mode 100644 index 0000000..37f71ef --- /dev/null +++ b/python/bag1_settings.json @@ -0,0 +1,9 @@ +{ + "api_ip": "127.0.0.1", + "api_port": "8090", + "dabag_ip": "192.168.17.27", + "dabag_port": "3008", + "laser_ip": "192.168.7.77", + "laser_port": "9100", + "label_com": "COM2" +} \ No newline at end of file diff --git a/python/build_exe.bat b/python/build_exe.bat new file mode 100644 index 0000000..6ccd061 --- /dev/null +++ b/python/build_exe.bat @@ -0,0 +1,20 @@ +@echo off +REM Build Bag1.exe (single file, no console window). +REM Run from this folder: build_exe.bat +REM Requires: pip install -r requirements-build.txt + +set SCRIPT=Bag1.py +set NAME=Bag1 + +if not exist "%SCRIPT%" ( + echo Error: %SCRIPT% not found. Run from the python folder. + exit /b 1 +) + +pip install -r requirements-build.txt +pyinstaller --onefile --windowed --name "%NAME%" "%SCRIPT%" + +echo. +echo Done. Exe is in: dist\%NAME%.exe +echo Copy dist\%NAME%.exe to the customer PC and run it (no Python needed). +pause diff --git a/python/fetch_job_orders.py b/python/fetch_job_orders.py new file mode 100644 index 0000000..7703cb5 --- /dev/null +++ b/python/fetch_job_orders.py @@ -0,0 +1,80 @@ +#!/usr/bin/env python3 +""" +Fetch job orders from FPSMS backend by plan date. +Uses the public API GET /py/job-orders (no login required). + +Usage: + python fetch_job_orders.py # today + python fetch_job_orders.py 2026-02-24 # specific date + python fetch_job_orders.py --date 2026-02-24 +""" + +import argparse +import os +import sys +from datetime import date +from typing import List, Optional + +import requests + +DEFAULT_BASE_URL = os.environ.get("FPSMS_BASE_URL", "http://localhost:8090/api") + + +def fetch_job_orders(base_url: str, plan_start: Optional[date]) -> List[dict]: + """Call GET /py/job-orders and return the JSON list.""" + url = f"{base_url.rstrip('/')}/py/job-orders" + params = {} + if plan_start is not None: + params["planStart"] = plan_start.isoformat() + resp = requests.get(url, params=params, timeout=30) + resp.raise_for_status() + return resp.json() + + +def main() -> None: + parser = argparse.ArgumentParser(description="Fetch job orders from FPSMS by plan date") + parser.add_argument( + "date", + nargs="?", + type=str, + default=None, + help="Plan date (yyyy-MM-dd). Default: today", + ) + parser.add_argument( + "--date", + dest="date_alt", + type=str, + default=None, + help="Plan date (yyyy-MM-dd)", + ) + parser.add_argument( + "--base-url", + type=str, + default=DEFAULT_BASE_URL, + help=f"Backend base URL (default: {DEFAULT_BASE_URL})", + ) + args = parser.parse_args() + + plan_str = args.date_alt or args.date + if plan_str: + try: + plan_start = date.fromisoformat(plan_str) + except ValueError: + print(f"Invalid date: {plan_str}. Use yyyy-MM-dd.", file=sys.stderr) + sys.exit(1) + else: + plan_start = date.today() + + try: + data = fetch_job_orders(args.base_url, plan_start) + except requests.RequestException as e: + print(f"Request failed: {e}", file=sys.stderr) + sys.exit(1) + + print(f"Job orders for planStart={plan_start} ({len(data)} items)") + for jo in data: + print(f" id={jo.get('id')} code={jo.get('code')} itemCode={jo.get('itemCode')} itemName={jo.get('itemName')} reqQty={jo.get('reqQty')}") + + +if __name__ == "__main__": + main() diff --git a/python/label_zpl.py b/python/label_zpl.py new file mode 100644 index 0000000..0544660 --- /dev/null +++ b/python/label_zpl.py @@ -0,0 +1,97 @@ +#!/usr/bin/env python3 +""" +ZPL label generator and TCP send for Zebra label printer (標簽機). +- Rotated 90° clockwise for ~53 mm media width +- UTF-8 (^CI28) for Chinese +- Large fonts, QR code mag 6 + +Standalone: python label_zpl.py +Or import generate_zpl() / send_zpl() and call from Bag1. +""" + +import socket + +# Default printer (override via args or Bag1 settings) +DEFAULT_PRINTER_IP = "192.168.17.27" +DEFAULT_PRINTER_PORT = 3008 +SOCKET_TIMEOUT = 10 + + +def generate_zpl( + batch_no: str, + item_code: str = "PP2238-02", + chinese_desc: str = "(餐廳用)凍咖啡底P+10(0.91L包)", +) -> str: + """ + Generates ZPL label: + - Rotated 90° clockwise to fit ~53 mm media width + - UTF-8 mode (^CI28) for correct Chinese display + - Larger fonts + - Bigger QR code (mag 6) + """ + return f"""^XA +^PW420 ^# Fits ~53 mm width (~420 dots @ 203 dpi) +^LL780 ^# Taller label after rotation + bigger elements +^PO N ^# Normal — change to ^POI if upside-down + +^CI28 ^# Enable UTF-8 / Unicode for Chinese (critical fix for boxes) + +^FO70,70 +^A@R,60,60,E:SIMSUN.FNT^FD{chinese_desc}^FS + ^# Very large Chinese text, rotated + +^FO220,70 +^A0R,50,50^FD{item_code}^FS + ^# Larger item code + +^FO310,70 +^A0R,45,45^FDBatch: {batch_no}^FS + ^# Larger batch text + +^FO150,420 +^BQN,2,6^FDQA,{batch_no}^FS + ^# Bigger QR code (magnification 6), lower position for space + +^XZ""" + + +def send_zpl( + zpl: str, + host: str = DEFAULT_PRINTER_IP, + port: int = DEFAULT_PRINTER_PORT, + timeout: float = SOCKET_TIMEOUT, +) -> None: + """Send ZPL to printer over TCP. Raises on connection/send error.""" + sock = socket.socket(socket.AF_INET, socket.SOCK_STREAM) + sock.settimeout(timeout) + sock.connect((host, port)) + sock.sendall(zpl.encode("utf-8")) + sock.close() + + +# ──────────────────────────────────────────────── +# Example usage — prints one label +# ──────────────────────────────────────────────── + +if __name__ == "__main__": + test_batch = "2025121209" + zpl = generate_zpl(test_batch) + + print("Sending ZPL (90° rotated, UTF-8 Chinese, bigger QR):") + print("-" * 90) + print(zpl) + print("-" * 90) + + try: + send_zpl(zpl) + print("Label sent successfully!") + print("→ Check Chinese — should show real characters (not 口口 or symbols)") + print("→ QR is now bigger (mag 6) — test scan with phone") + print("→ If upside-down: edit ^PO N → ^POI") + print("→ If still boxes: SimSun font may be missing — reinstall via Zebra Setup Utilities") + except ConnectionRefusedError: + print(f"Cannot connect to {DEFAULT_PRINTER_IP}:{DEFAULT_PRINTER_PORT} — printer off or wrong IP?") + except socket.timeout: + print("Connection timeout — check printer/network/port") + except Exception as e: + print(f"Error: {e}") diff --git a/python/requirements-build.txt b/python/requirements-build.txt new file mode 100644 index 0000000..f3bfc9b --- /dev/null +++ b/python/requirements-build.txt @@ -0,0 +1,4 @@ +# Install with: pip install -r requirements-build.txt +# Used only for building the .exe (PyInstaller). +-r requirements.txt +pyinstaller>=6.0.0 diff --git a/python/requirements.txt b/python/requirements.txt new file mode 100644 index 0000000..23eb05b --- /dev/null +++ b/python/requirements.txt @@ -0,0 +1,3 @@ +# Python dependencies for FPSMS backend integration +requests>=2.28.0 +pyserial>=3.5 diff --git a/src/main/java/com/ffii/fpsms/config/security/SecurityConfig.java b/src/main/java/com/ffii/fpsms/config/security/SecurityConfig.java index 15b4bb0..3f950db 100644 --- a/src/main/java/com/ffii/fpsms/config/security/SecurityConfig.java +++ b/src/main/java/com/ffii/fpsms/config/security/SecurityConfig.java @@ -35,7 +35,8 @@ public class SecurityConfig { public static final String[] URL_WHITELIST = { INDEX_URL, LOGIN_URL, - LDAP_LOGIN_URL + LDAP_LOGIN_URL, + "/py/**" }; @Lazy diff --git a/src/main/java/com/ffii/fpsms/m18/entity/M18GoodsReceiptNoteLog.kt b/src/main/java/com/ffii/fpsms/m18/entity/M18GoodsReceiptNoteLog.kt new file mode 100644 index 0000000..ed85ad3 --- /dev/null +++ b/src/main/java/com/ffii/fpsms/m18/entity/M18GoodsReceiptNoteLog.kt @@ -0,0 +1,53 @@ +package com.ffii.fpsms.m18.entity + +import com.ffii.core.entity.BaseEntity +import jakarta.persistence.Column +import jakarta.persistence.Entity +import jakarta.persistence.Table +import jakarta.validation.constraints.NotNull +import jakarta.validation.constraints.Size + +/** + * Logs the result of creating a Goods Receipt Note (AN) in M18. + * One row per stock-in line included in the GRN, so we can trace + * m18RecordId back to stockInLineId, purchaseOrderLineId, and poCode. + */ +@Entity +@Table(name = "m18_goods_receipt_note_log") +open class M18GoodsReceiptNoteLog : BaseEntity() { + + /** M18 Goods Receipt Note record id (from M18 API response). */ + @NotNull + @Column(name = "m18_record_id", nullable = false) + open var m18RecordId: Long? = null + + /** Stock-in line this GRN line was created from. */ + @NotNull + @Column(name = "stock_in_line_id", nullable = false) + open var stockInLineId: Long? = null + + /** Purchase order line. */ + @NotNull + @Column(name = "purchase_order_line_id", nullable = false) + open var purchaseOrderLineId: Long? = null + + /** Purchase order. */ + @NotNull + @Column(name = "purchase_order_id", nullable = false) + open var purchaseOrderId: Long? = null + + /** Purchase order code (e.g. PO-xxx). */ + @Size(max = 60) + @Column(name = "po_code", length = 60) + open var poCode: String? = null + + /** Whether M18 API returned success. */ + @NotNull + @Column(name = "status", nullable = false) + open var status: Boolean? = null + + /** Optional message from M18 (e.g. error or warning). */ + @Size(max = 1000) + @Column(name = "message", length = 1000) + open var message: String? = null +} diff --git a/src/main/java/com/ffii/fpsms/m18/entity/M18GoodsReceiptNoteLogRepository.kt b/src/main/java/com/ffii/fpsms/m18/entity/M18GoodsReceiptNoteLogRepository.kt new file mode 100644 index 0000000..776edc0 --- /dev/null +++ b/src/main/java/com/ffii/fpsms/m18/entity/M18GoodsReceiptNoteLogRepository.kt @@ -0,0 +1,6 @@ +package com.ffii.fpsms.m18.entity + +import com.ffii.core.support.AbstractRepository + +interface M18GoodsReceiptNoteLogRepository : AbstractRepository { +} diff --git a/src/main/java/com/ffii/fpsms/m18/model/GoodsReceiptNoteRequest.kt b/src/main/java/com/ffii/fpsms/m18/model/GoodsReceiptNoteRequest.kt index e12222b..d285a4e 100644 --- a/src/main/java/com/ffii/fpsms/m18/model/GoodsReceiptNoteRequest.kt +++ b/src/main/java/com/ffii/fpsms/m18/model/GoodsReceiptNoteRequest.kt @@ -21,6 +21,7 @@ data class GoodsReceiptNoteMainanValue( val rate: Number, val flowTypeId: Int, val staffId: Int, + val virDeptId: Int? = null, ) data class GoodsReceiptNoteAnt( diff --git a/src/main/java/com/ffii/fpsms/m18/service/M18GoodsReceiptNoteService.kt b/src/main/java/com/ffii/fpsms/m18/service/M18GoodsReceiptNoteService.kt index 0a85ce2..3341eb3 100644 --- a/src/main/java/com/ffii/fpsms/m18/service/M18GoodsReceiptNoteService.kt +++ b/src/main/java/com/ffii/fpsms/m18/service/M18GoodsReceiptNoteService.kt @@ -4,6 +4,7 @@ import com.ffii.fpsms.api.service.ApiCallerService import com.ffii.fpsms.m18.M18Config import com.ffii.fpsms.m18.model.GoodsReceiptNoteRequest import com.ffii.fpsms.m18.model.GoodsReceiptNoteResponse +import com.google.gson.Gson import org.slf4j.Logger import org.slf4j.LoggerFactory import org.springframework.stereotype.Service @@ -55,18 +56,24 @@ open class M18GoodsReceiptNoteService( add("menuCode", MENU_CODE_AN) param?.let { add("param", it) } } + val queryString = queryParams.entries.joinToString("&") { (k, v) -> "$k=$v" } + val fullUrl = "${m18Config.BASE_URL}$M18_SAVE_GOODS_RECEIPT_NOTE_API?$queryString" + val requestJson = Gson().toJson(request) + logger.info("[M18 GRN API] call=PUT url=$fullUrl queryParams=$queryParams body=$requestJson") return apiCallerService.put( urlPath = M18_SAVE_GOODS_RECEIPT_NOTE_API, queryParams = queryParams, body = request, ).doOnSuccess { response -> + val responseJson = Gson().toJson(response) + logger.info("[M18 GRN API] response (all): $responseJson") if (response.status) { logger.info("Goods receipt note created in M18. recordId=${response.recordId}") } else { logger.warn("M18 save AN returned status=false. recordId=${response.recordId}, messages=${response.messages}") } }.doOnError { e -> - logger.error("Failed to create goods receipt note in M18: ${e.message}") + logger.error("[M18 GRN API] call failed: $fullUrl error=${e.message}", e) } } } diff --git a/src/main/java/com/ffii/fpsms/m18/service/M18PurchaseOrderService.kt b/src/main/java/com/ffii/fpsms/m18/service/M18PurchaseOrderService.kt index a847b6f..8d61bcc 100644 --- a/src/main/java/com/ffii/fpsms/m18/service/M18PurchaseOrderService.kt +++ b/src/main/java/com/ffii/fpsms/m18/service/M18PurchaseOrderService.kt @@ -324,7 +324,6 @@ open class M18PurchaseOrderService( } // purchase_order_line + m18_data_log - // TODO: check deleted po line? if (pot != null) { // Loop for Purchase Order Lines (pot) pot.forEach { line -> @@ -426,6 +425,15 @@ open class M18PurchaseOrderService( // logger.error("${poLineRefType}: M18 Data Log Updated! Please see the error. ID: ${saveM18PurchaseOrderLineLog.id}") } } + // Mark as deleted any local PO lines that no longer exist in M18 (removed there) + if (purchaseOrderId != null) { + val m18LineIds = pot.map { it.id }.toSet() + val markedDeleted = + purchaseOrderLineService.markDeletedLinesNotInM18(purchaseOrderId, m18LineIds) + if (markedDeleted > 0) { + logger.info("${poLineRefType}: Marked $markedDeleted line(s) as deleted (not in M18). PO ID: $purchaseOrderId | M18 PO ID: ${purchaseOrder.id}") + } + } } else { // pot // logger.error("${poLineRefType}: Saving Failure!") diff --git a/src/main/java/com/ffii/fpsms/modules/jobOrder/entity/JobOrderRepository.kt b/src/main/java/com/ffii/fpsms/modules/jobOrder/entity/JobOrderRepository.kt index 60eae3c..607e27a 100644 --- a/src/main/java/com/ffii/fpsms/modules/jobOrder/entity/JobOrderRepository.kt +++ b/src/main/java/com/ffii/fpsms/modules/jobOrder/entity/JobOrderRepository.kt @@ -118,6 +118,21 @@ interface JobOrderRepository : AbstractRepository { fun findJobOrderDetailByCode(code: String): JobOrderDetailWithJsonString?; fun findAllByDeletedFalse(): List + fun findByDeletedFalseAndPlanStartBetweenOrderByIdAsc(planStartStart: LocalDateTime, planStartEnd: LocalDateTime): List + + @Query( + """ + SELECT jo FROM JobOrder jo + WHERE jo.deleted = false + AND jo.planStart >= :planStartFrom + AND jo.planStart < :planStartToExclusive + ORDER BY jo.id ASC + """ + ) + fun findByDeletedFalseAndPlanStartFromBeforeExclusiveOrderByIdAsc( + planStartFrom: LocalDateTime, + planStartToExclusive: LocalDateTime, + ): List @Query(""" SELECT jo FROM JobOrder jo diff --git a/src/main/java/com/ffii/fpsms/modules/purchaseOrder/entity/PurchaseOrderLineRepository.kt b/src/main/java/com/ffii/fpsms/modules/purchaseOrder/entity/PurchaseOrderLineRepository.kt index 4de0519..2fb8f16 100644 --- a/src/main/java/com/ffii/fpsms/modules/purchaseOrder/entity/PurchaseOrderLineRepository.kt +++ b/src/main/java/com/ffii/fpsms/modules/purchaseOrder/entity/PurchaseOrderLineRepository.kt @@ -11,6 +11,7 @@ interface PurchaseOrderLineRepository : AbstractRepository fun findAllByPurchaseOrderIdAndDeletedIsFalse(purchaseOrderId: Long): List + fun findAllByPurchaseOrderIdAndDeletedIsFalseAndM18DataLogIsNotNull(purchaseOrderId: Long): List fun findAllByPurchaseOrderIdAndStatusNotAndDeletedIsFalse(purchaseOrderId: Long, status: PurchaseOrderLineStatus): List // fun findAllByPurchaseOrderIdAndDeletedIsFalse(purchaseOrderId: Long): List // fun find diff --git a/src/main/java/com/ffii/fpsms/modules/purchaseOrder/service/PurchaseOrderLineService.kt b/src/main/java/com/ffii/fpsms/modules/purchaseOrder/service/PurchaseOrderLineService.kt index d20d6a6..583d455 100644 --- a/src/main/java/com/ffii/fpsms/modules/purchaseOrder/service/PurchaseOrderLineService.kt +++ b/src/main/java/com/ffii/fpsms/modules/purchaseOrder/service/PurchaseOrderLineService.kt @@ -34,6 +34,26 @@ open class PurchaseOrderLineService( return purchaseOrderLineRepository.findByM18DataLogIdAndDeletedIsFalse(m18DataLogId) } + /** + * Mark as deleted any local PO lines for this PO that were synced from M18 but whose M18 line id + * is not in the given set (i.e. the line was deleted in M18). + * @return number of lines marked as deleted + */ + open fun markDeletedLinesNotInM18(purchaseOrderId: Long, m18LineIds: Set): Int { + val linesFromM18 = + purchaseOrderLineRepository.findAllByPurchaseOrderIdAndDeletedIsFalseAndM18DataLogIsNotNull(purchaseOrderId) + var count = 0 + linesFromM18.forEach { line -> + val m18Id = line.m18DataLog?.m18Id ?: return@forEach + if (m18Id !in m18LineIds) { + line.deleted = true + purchaseOrderLineRepository.saveAndFlush(line) + count++ + } + } + return count + } + open fun findAllPoLineInfoByPoId(poId: Long): List { return purchaseOrderLineRepository.findAllPurchaseOrderLineInfoByPurchaseOrderIdAndDeletedIsFalse(poId) } diff --git a/src/main/java/com/ffii/fpsms/modules/stock/service/StockInLineService.kt b/src/main/java/com/ffii/fpsms/modules/stock/service/StockInLineService.kt index 6a73472..46d0716 100644 --- a/src/main/java/com/ffii/fpsms/modules/stock/service/StockInLineService.kt +++ b/src/main/java/com/ffii/fpsms/modules/stock/service/StockInLineService.kt @@ -54,6 +54,17 @@ import com.ffii.fpsms.modules.stock.entity.InventoryRepository import org.springframework.http.HttpStatus import org.springframework.web.server.ResponseStatusException import kotlin.text.toDouble +import com.ffii.fpsms.m18.model.GoodsReceiptNoteRequest +import com.ffii.fpsms.m18.model.GoodsReceiptNoteMainan +import com.ffii.fpsms.m18.model.GoodsReceiptNoteMainanValue +import com.ffii.fpsms.m18.model.GoodsReceiptNoteAnt +import com.ffii.fpsms.m18.model.GoodsReceiptNoteAntValue +import com.ffii.fpsms.m18.entity.M18GoodsReceiptNoteLog +import com.ffii.fpsms.m18.entity.M18GoodsReceiptNoteLogRepository +import com.ffii.fpsms.m18.service.M18GoodsReceiptNoteService +import com.ffii.fpsms.m18.utils.CommonUtils +import com.google.gson.Gson +import org.slf4j.LoggerFactory @Serializable data class QrContent(val itemId: Long, val stockInLineId: Long) @@ -81,8 +92,12 @@ open class StockInLineService( private val inventoryLotLineService: InventoryLotLineService, private val deliveryOrderRepository: DeliveryOrderRepository, private val stockLedgerRepository: StockLedgerRepository, - private val inventoryRepository: InventoryRepository -): AbstractBaseEntityService(jdbcDao, stockInLineRepository) { + private val inventoryRepository: InventoryRepository, + private val m18GoodsReceiptNoteService: M18GoodsReceiptNoteService, + private val m18GoodsReceiptNoteLogRepository: M18GoodsReceiptNoteLogRepository, +) : AbstractBaseEntityService(jdbcDao, stockInLineRepository) { + + private val logger = LoggerFactory.getLogger(StockInLineService::class.java) open fun getStockInLineInfo(stockInLineId: Long): StockInLineInfo { // Use Optional-returning repository method to avoid EmptyResultDataAccessException @@ -196,6 +211,10 @@ open class StockInLineService( status = StockInLineStatus.PENDING.status } val savedSIL = saveAndFlush(stockInLine) + if (pol != null) { + //logger.info("[create] Stock-in line created with PO, running PO/GRN update for stockInLine id=${savedSIL.id}") + //tryUpdatePurchaseOrderAndCreateGrnIfCompleted(savedSIL) + } val lineInfo = stockInLineRepository.findStockInLineInfoByIdAndDeletedFalse(savedSIL.id!!) return MessageResponse( id = savedSIL.id, @@ -390,6 +409,48 @@ open class StockInLineService( } return poRepository.saveAndFlush(po) } + + /** + * Builds M18 Goods Receipt Note (AN) request from completed PO and its completed stock-in lines. + */ + private fun buildGoodsReceiptNoteRequest(po: PurchaseOrder, stockInLines: List): GoodsReceiptNoteRequest { + val mainan = GoodsReceiptNoteMainan( + values = listOf( + GoodsReceiptNoteMainanValue( + beId = po.m18BeId!!.toInt(), + code = po.code!!, + venId = (po.supplier?.m18Id ?: 0L).toInt(), + curId = (po.currency?.m18Id ?: 0L).toInt(), + rate = 1, + flowTypeId = 1, // TODO temp for M18 API + staffId = 232, // TODO temp for M18 API; revert to config/default when done + virDeptId = 117, // TODO temp for M18 API + ) + ) + ) + val sourceId = po.m18DataLog?.m18Id ?: 0L + val antValues = stockInLines.map { sil -> + val pol = sil.purchaseOrderLine!! + GoodsReceiptNoteAntValue( + sourceType = "po", + sourceId = sourceId, + sourceLot = sil.lotNo ?: "", + proId = (sil.item?.m18Id ?: 0L).toInt(), + locId = 39, // TODO temp for M18 API + unitId = (pol.uom?.m18Id ?: 0L).toInt(), + qty = sil.acceptedQty?.toDouble() ?: 0.0, + up = pol.up?.toDouble() ?: 0.0, + amt = CommonUtils.getAmt( + up = pol.up ?: BigDecimal.ZERO, + discount = pol.m18Discount ?: BigDecimal.ZERO, + qty = sil.acceptedQty ?: BigDecimal.ZERO + ) + ) + } + val ant = GoodsReceiptNoteAnt(values = antValues) + return GoodsReceiptNoteRequest(mainan = mainan, ant = ant) + } + @Throws(IOException::class) @Transactional fun updatePurchaseOrderLineStatus(pol: PurchaseOrderLine) { @@ -421,6 +482,75 @@ open class StockInLineService( } polRepository.saveAndFlush(pol) } + + /** + * Updates purchase order status from its lines and, when PO becomes COMPLETED, + * creates M18 Goods Receipt Note. Called after saving stock-in line for both + * RECEIVED and PENDING/ESCALATED status flows. + */ + private fun tryUpdatePurchaseOrderAndCreateGrnIfCompleted(savedStockInLine: StockInLine) { + if (savedStockInLine.purchaseOrderLine == null) return + val pol = savedStockInLine.purchaseOrderLine ?: return + updatePurchaseOrderLineStatus(pol) + val savedPo = updatePurchaseOrderStatus(pol.purchaseOrder!!) + logger.info("[updatePurchaseOrderStatus] savedPo id=${savedPo.id}, status=${savedPo.status}") + // TODO: For test only - normally check savedPo.status == PurchaseOrderStatus.COMPLETED and use only COMPLETE lines + try { + val allLines = stockInLineRepository.findAllByPurchaseOrderIdAndDeletedFalse(savedPo.id!!).orElse(emptyList()) + val linesForGrn = allLines // TODO test: use all lines; normally .filter { it.status == StockInLineStatus.COMPLETE.status } + if (linesForGrn.isEmpty()) { + logger.info("[tryUpdatePurchaseOrderAndCreateGrnIfCompleted] DEBUG: Skipping M18 GRN - no stock-in lines for PO id=${savedPo.id} code=${savedPo.code}") + return + } + if (savedPo.m18BeId == null || savedPo.supplier?.m18Id == null || savedPo.currency?.m18Id == null) { + logger.info("[tryUpdatePurchaseOrderAndCreateGrnIfCompleted] DEBUG: Skipping M18 GRN - missing M18 ids for PO id=${savedPo.id} code=${savedPo.code}. m18BeId=${savedPo.m18BeId}, supplier.m18Id=${savedPo.supplier?.m18Id}, currency.m18Id=${savedPo.currency?.m18Id}") + return + } + val grnRequest = buildGoodsReceiptNoteRequest(savedPo, linesForGrn) + val grnRequestJson = Gson().toJson(grnRequest) + logger.info("[tryUpdatePurchaseOrderAndCreateGrnIfCompleted] M18 GRN API request (for discussion with M18) PO id=${savedPo.id} code=${savedPo.code}: $grnRequestJson") + val grnResponse = m18GoodsReceiptNoteService.createGoodsReceiptNote(grnRequest) + if (grnResponse == null) { + logger.warn("[tryUpdatePurchaseOrderAndCreateGrnIfCompleted] DEBUG: M18 API returned null for PO id=${savedPo.id} code=${savedPo.code}. API call may have failed or timed out.") + return + } + if (grnResponse.status == true) { + logger.info("M18 Goods Receipt Note created for PO ${savedPo.code}, goodsReceiptNote id (recordId)=${grnResponse.recordId}") + linesForGrn.forEach { sil -> + val linePol = sil.purchaseOrderLine!! + val logEntry = M18GoodsReceiptNoteLog().apply { + m18RecordId = grnResponse.recordId + stockInLineId = sil.id + purchaseOrderLineId = linePol.id + purchaseOrderId = savedPo.id + poCode = savedPo.code + status = true + message = null + } + m18GoodsReceiptNoteLogRepository.save(logEntry) + } + } else { + logger.warn("M18 Goods Receipt Note save returned status=false for PO ${savedPo.code}, recordId=${grnResponse?.recordId}, messages=${grnResponse?.messages}. Request sent (for M18 discussion): $grnRequestJson") + val msg = grnResponse?.messages?.joinToString { it.msgDetail ?: it.msgCode ?: "" } ?: "" + linesForGrn.forEach { sil -> + val linePol = sil.purchaseOrderLine!! + val logEntry = M18GoodsReceiptNoteLog().apply { + m18RecordId = grnResponse?.recordId ?: 0L + stockInLineId = sil.id + purchaseOrderLineId = linePol.id + purchaseOrderId = savedPo.id + poCode = savedPo.code + status = false + message = msg.take(1000) + } + m18GoodsReceiptNoteLogRepository.save(logEntry) + } + } + } catch (e: Exception) { + logger.error("[tryUpdatePurchaseOrderAndCreateGrnIfCompleted] DEBUG: M18 API call failed for PO id=${pol.purchaseOrder?.id} code=${pol.purchaseOrder?.code}: ${e.message}", e) + } + } + @Throws(IOException::class) @Transactional open fun update(request: SaveStockInLineRequest): MessageResponse { diff --git a/src/main/java/com/ffii/fpsms/py/PyController.kt b/src/main/java/com/ffii/fpsms/py/PyController.kt new file mode 100644 index 0000000..dd8d988 --- /dev/null +++ b/src/main/java/com/ffii/fpsms/py/PyController.kt @@ -0,0 +1,54 @@ +package com.ffii.fpsms.py + +import com.ffii.fpsms.modules.jobOrder.entity.JobOrder +import com.ffii.fpsms.modules.jobOrder.entity.JobOrderRepository +import org.springframework.format.annotation.DateTimeFormat +import org.springframework.http.ResponseEntity +import org.springframework.web.bind.annotation.GetMapping +import org.springframework.web.bind.annotation.RequestMapping +import org.springframework.web.bind.annotation.RequestParam +import org.springframework.web.bind.annotation.RestController +import java.time.LocalDate +import java.time.LocalDateTime + +/** + * Public API for Python clients. No login required. + * Base path: /py + */ +@RestController +@RequestMapping("/py") +open class PyController( + private val jobOrderRepository: JobOrderRepository, +) { + + /** + * List job orders by planStart date. + * GET /py/job-orders?planStart=yyyy-MM-dd + * @param planStart Date to filter by (default: today). Format: yyyy-MM-dd + * @return List of job orders with id, code, planStart, itemCode, itemName, reqQty + */ + @GetMapping("/job-orders") + open fun listJobOrders( + @RequestParam(required = false) @DateTimeFormat(iso = DateTimeFormat.ISO.DATE) planStart: LocalDate?, + ): ResponseEntity> { + val date = planStart ?: LocalDate.now() + val dayStart = date.atStartOfDay() + val dayEndExclusive = date.plusDays(1).atStartOfDay() + val orders = jobOrderRepository.findByDeletedFalseAndPlanStartFromBeforeExclusiveOrderByIdAsc(dayStart, dayEndExclusive) + val list = orders.map { jo -> toListItem(jo) } + return ResponseEntity.ok(list) + } + + private fun toListItem(jo: JobOrder): PyJobOrderListItem { + val itemCode = jo.bom?.item?.code ?: jo.bom?.code + val itemName = jo.bom?.name ?: jo.bom?.item?.name + return PyJobOrderListItem( + id = jo.id!!, + code = jo.code, + planStart = jo.planStart, + itemCode = itemCode, + itemName = itemName, + reqQty = jo.reqQty, + ) + } +} diff --git a/src/main/java/com/ffii/fpsms/py/PyJobOrderListItem.kt b/src/main/java/com/ffii/fpsms/py/PyJobOrderListItem.kt new file mode 100644 index 0000000..a8b6b39 --- /dev/null +++ b/src/main/java/com/ffii/fpsms/py/PyJobOrderListItem.kt @@ -0,0 +1,17 @@ +package com.ffii.fpsms.py + +import java.math.BigDecimal +import java.time.LocalDateTime + +/** + * Job order list item for Python API (/py/job-orders). + * No login required. + */ +data class PyJobOrderListItem( + val id: Long, + val code: String?, + val planStart: LocalDateTime?, + val itemCode: String?, + val itemName: String?, + val reqQty: BigDecimal?, +) diff --git a/src/main/resources/db/changelog/changes/20260324_01_fai/01_create_m18_goods_receipt_note_log.sql b/src/main/resources/db/changelog/changes/20260324_01_fai/01_create_m18_goods_receipt_note_log.sql new file mode 100644 index 0000000..50ccb5d --- /dev/null +++ b/src/main/resources/db/changelog/changes/20260324_01_fai/01_create_m18_goods_receipt_note_log.sql @@ -0,0 +1,22 @@ +--liquibase formatted sql +--changeset fai:create_m18_goods_receipt_note_log + +CREATE TABLE IF NOT EXISTS `m18_goods_receipt_note_log` ( + `id` BIGINT NOT NULL AUTO_INCREMENT, + `created` DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP, + `createdBy` VARCHAR(30) NULL DEFAULT NULL, + `version` INT NOT NULL DEFAULT '0', + `modified` DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP, + `modifiedBy` VARCHAR(30) NULL DEFAULT NULL, + `deleted` TINYINT(1) NOT NULL DEFAULT '0', + + `m18_record_id` BIGINT NOT NULL COMMENT 'M18 Goods Receipt Note record id', + `stock_in_line_id` BIGINT NOT NULL COMMENT 'Stock-in line this GRN line was created from', + `purchase_order_line_id` BIGINT NOT NULL COMMENT 'Purchase order line id', + `purchase_order_id` BIGINT NOT NULL COMMENT 'Purchase order id', + `po_code` VARCHAR(60) NULL DEFAULT NULL COMMENT 'Purchase order code', + `status` TINYINT(1) NOT NULL COMMENT 'Whether M18 API returned success', + `message` VARCHAR(1000) NULL DEFAULT NULL COMMENT 'Optional message from M18', + + CONSTRAINT pk_m18_goods_receipt_note_log PRIMARY KEY (`id`) +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci;