| @@ -0,0 +1,621 @@ | |||
| #!/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": "9100", | |||
| "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 | |||
| 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("<KeyPress>", 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("<Configure>", _on_inner_configure) | |||
| canvas.bind("<Configure>", _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("<MouseWheel>", _on_mousewheel) | |||
| inner.bind("<MouseWheel>", _on_mousewheel) | |||
| canvas.bind("<Button-4>", _on_mousewheel) | |||
| canvas.bind("<Button-5>", _on_mousewheel) | |||
| inner.bind("<Button-4>", _on_mousewheel) | |||
| inner.bind("<Button-5>", _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() == "標簽機": | |||
| 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("<Button-1>", _on_click) | |||
| w.bind("<MouseWheel>", _on_mousewheel) | |||
| w.bind("<Button-4>", _on_mousewheel) | |||
| w.bind("<Button-5>", _on_mousewheel) | |||
| if qty_lbl is not None: | |||
| qty_lbl.bind("<Button-1>", _on_click) | |||
| qty_lbl.bind("<MouseWheel>", _on_mousewheel) | |||
| qty_lbl.bind("<Button-4>", _on_mousewheel) | |||
| qty_lbl.bind("<Button-5>", _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() | |||
| @@ -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, | |||
| ) | |||
| @@ -0,0 +1,53 @@ | |||
| # 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` | | |||
| ## 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. | |||
| @@ -0,0 +1,9 @@ | |||
| { | |||
| "api_ip": "127.0.0.1", | |||
| "api_port": "8090", | |||
| "dabag_ip": "192.168.17.27", | |||
| "dabag_port": "9100", | |||
| "laser_ip": "192.168.7.77", | |||
| "laser_port": "9100", | |||
| "label_com": "COM2" | |||
| } | |||
| @@ -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 | |||
| @@ -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() | |||
| @@ -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 | |||
| @@ -0,0 +1,3 @@ | |||
| # Python dependencies for FPSMS backend integration | |||
| requests>=2.28.0 | |||
| pyserial>=3.5 | |||
| @@ -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 | |||
| @@ -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<Long>() { | |||
| /** 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 | |||
| } | |||
| @@ -0,0 +1,6 @@ | |||
| package com.ffii.fpsms.m18.entity | |||
| import com.ffii.core.support.AbstractRepository | |||
| interface M18GoodsReceiptNoteLogRepository : AbstractRepository<M18GoodsReceiptNoteLog, Long> { | |||
| } | |||
| @@ -21,6 +21,7 @@ data class GoodsReceiptNoteMainanValue( | |||
| val rate: Number, | |||
| val flowTypeId: Int, | |||
| val staffId: Int, | |||
| val virDeptId: Int? = null, | |||
| ) | |||
| data class GoodsReceiptNoteAnt( | |||
| @@ -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<GoodsReceiptNoteResponse>( | |||
| 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) | |||
| } | |||
| } | |||
| } | |||
| @@ -118,6 +118,8 @@ interface JobOrderRepository : AbstractRepository<JobOrder, Long> { | |||
| fun findJobOrderDetailByCode(code: String): JobOrderDetailWithJsonString?; | |||
| fun findAllByDeletedFalse(): List<JobOrder> | |||
| fun findByDeletedFalseAndPlanStartBetweenOrderByIdAsc(planStartStart: LocalDateTime, planStartEnd: LocalDateTime): List<JobOrder> | |||
| @Query(""" | |||
| SELECT jo FROM JobOrder jo | |||
| @@ -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<StockInLine, Long, StockInLineRepository>(jdbcDao, stockInLineRepository) { | |||
| private val inventoryRepository: InventoryRepository, | |||
| private val m18GoodsReceiptNoteService: M18GoodsReceiptNoteService, | |||
| private val m18GoodsReceiptNoteLogRepository: M18GoodsReceiptNoteLogRepository, | |||
| ) : AbstractBaseEntityService<StockInLine, Long, StockInLineRepository>(jdbcDao, stockInLineRepository) { | |||
| private val logger = LoggerFactory.getLogger(StockInLineService::class.java) | |||
| open fun getStockInLineInfo(stockInLineId: Long): StockInLineInfo { | |||
| // Use Optional-returning repository method to avoid EmptyResultDataAccessException | |||
| @@ -187,6 +202,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, | |||
| @@ -381,6 +400,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<StockInLine>): 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) { | |||
| @@ -412,6 +473,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 { | |||
| @@ -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<List<PyJobOrderListItem>> { | |||
| val date = planStart ?: LocalDate.now() | |||
| val dayStart = date.atStartOfDay() | |||
| val dayEnd = date.plusDays(1).atStartOfDay() | |||
| val orders = jobOrderRepository.findByDeletedFalseAndPlanStartBetweenOrderByIdAsc(dayStart, dayEnd) | |||
| 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, | |||
| ) | |||
| } | |||
| } | |||
| @@ -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?, | |||
| ) | |||
| @@ -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; | |||