| @@ -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 = { | public static final String[] URL_WHITELIST = { | ||||
| INDEX_URL, | INDEX_URL, | ||||
| LOGIN_URL, | LOGIN_URL, | ||||
| LDAP_LOGIN_URL | |||||
| LDAP_LOGIN_URL, | |||||
| "/py/**" | |||||
| }; | }; | ||||
| @Lazy | @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 rate: Number, | ||||
| val flowTypeId: Int, | val flowTypeId: Int, | ||||
| val staffId: Int, | val staffId: Int, | ||||
| val virDeptId: Int? = null, | |||||
| ) | ) | ||||
| data class GoodsReceiptNoteAnt( | 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.M18Config | ||||
| import com.ffii.fpsms.m18.model.GoodsReceiptNoteRequest | import com.ffii.fpsms.m18.model.GoodsReceiptNoteRequest | ||||
| import com.ffii.fpsms.m18.model.GoodsReceiptNoteResponse | import com.ffii.fpsms.m18.model.GoodsReceiptNoteResponse | ||||
| import com.google.gson.Gson | |||||
| import org.slf4j.Logger | import org.slf4j.Logger | ||||
| import org.slf4j.LoggerFactory | import org.slf4j.LoggerFactory | ||||
| import org.springframework.stereotype.Service | import org.springframework.stereotype.Service | ||||
| @@ -55,18 +56,24 @@ open class M18GoodsReceiptNoteService( | |||||
| add("menuCode", MENU_CODE_AN) | add("menuCode", MENU_CODE_AN) | ||||
| param?.let { add("param", it) } | 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>( | return apiCallerService.put<GoodsReceiptNoteResponse>( | ||||
| urlPath = M18_SAVE_GOODS_RECEIPT_NOTE_API, | urlPath = M18_SAVE_GOODS_RECEIPT_NOTE_API, | ||||
| queryParams = queryParams, | queryParams = queryParams, | ||||
| body = request, | body = request, | ||||
| ).doOnSuccess { response -> | ).doOnSuccess { response -> | ||||
| val responseJson = Gson().toJson(response) | |||||
| logger.info("[M18 GRN API] response (all): $responseJson") | |||||
| if (response.status) { | if (response.status) { | ||||
| logger.info("Goods receipt note created in M18. recordId=${response.recordId}") | logger.info("Goods receipt note created in M18. recordId=${response.recordId}") | ||||
| } else { | } else { | ||||
| logger.warn("M18 save AN returned status=false. recordId=${response.recordId}, messages=${response.messages}") | logger.warn("M18 save AN returned status=false. recordId=${response.recordId}, messages=${response.messages}") | ||||
| } | } | ||||
| }.doOnError { e -> | }.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 findJobOrderDetailByCode(code: String): JobOrderDetailWithJsonString?; | ||||
| fun findAllByDeletedFalse(): List<JobOrder> | fun findAllByDeletedFalse(): List<JobOrder> | ||||
| fun findByDeletedFalseAndPlanStartBetweenOrderByIdAsc(planStartStart: LocalDateTime, planStartEnd: LocalDateTime): List<JobOrder> | |||||
| @Query(""" | @Query(""" | ||||
| SELECT jo FROM JobOrder jo | 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.http.HttpStatus | ||||
| import org.springframework.web.server.ResponseStatusException | import org.springframework.web.server.ResponseStatusException | ||||
| import kotlin.text.toDouble | 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 | @Serializable | ||||
| data class QrContent(val itemId: Long, val stockInLineId: Long) | data class QrContent(val itemId: Long, val stockInLineId: Long) | ||||
| @@ -81,8 +92,12 @@ open class StockInLineService( | |||||
| private val inventoryLotLineService: InventoryLotLineService, | private val inventoryLotLineService: InventoryLotLineService, | ||||
| private val deliveryOrderRepository: DeliveryOrderRepository, | private val deliveryOrderRepository: DeliveryOrderRepository, | ||||
| private val stockLedgerRepository: StockLedgerRepository, | 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 { | open fun getStockInLineInfo(stockInLineId: Long): StockInLineInfo { | ||||
| // Use Optional-returning repository method to avoid EmptyResultDataAccessException | // Use Optional-returning repository method to avoid EmptyResultDataAccessException | ||||
| @@ -187,6 +202,10 @@ open class StockInLineService( | |||||
| status = StockInLineStatus.PENDING.status | status = StockInLineStatus.PENDING.status | ||||
| } | } | ||||
| val savedSIL = saveAndFlush(stockInLine) | 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!!) | val lineInfo = stockInLineRepository.findStockInLineInfoByIdAndDeletedFalse(savedSIL.id!!) | ||||
| return MessageResponse( | return MessageResponse( | ||||
| id = savedSIL.id, | id = savedSIL.id, | ||||
| @@ -381,6 +400,48 @@ open class StockInLineService( | |||||
| } | } | ||||
| return poRepository.saveAndFlush(po) | 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) | @Throws(IOException::class) | ||||
| @Transactional | @Transactional | ||||
| fun updatePurchaseOrderLineStatus(pol: PurchaseOrderLine) { | fun updatePurchaseOrderLineStatus(pol: PurchaseOrderLine) { | ||||
| @@ -412,6 +473,75 @@ open class StockInLineService( | |||||
| } | } | ||||
| polRepository.saveAndFlush(pol) | 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) | @Throws(IOException::class) | ||||
| @Transactional | @Transactional | ||||
| open fun update(request: SaveStockInLineRequest): MessageResponse { | 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; | |||||