Selaa lähdekoodia

added GRN and python UI for bag machines; Mark not exist po line as deleted when sync

master
vluk@2fi-solutions.com.hk 2 päivää sitten
vanhempi
commit
ac975460d1
18 muutettua tiedostoa jossa 1125 lisäystä ja 4 poistoa
  1. +621
    -0
      python/Bag1.py
  2. +38
    -0
      python/Bag1.spec
  3. +53
    -0
      python/README.md
  4. +9
    -0
      python/bag1_settings.json
  5. +20
    -0
      python/build_exe.bat
  6. +80
    -0
      python/fetch_job_orders.py
  7. +4
    -0
      python/requirements-build.txt
  8. +3
    -0
      python/requirements.txt
  9. +2
    -1
      src/main/java/com/ffii/fpsms/config/security/SecurityConfig.java
  10. +53
    -0
      src/main/java/com/ffii/fpsms/m18/entity/M18GoodsReceiptNoteLog.kt
  11. +6
    -0
      src/main/java/com/ffii/fpsms/m18/entity/M18GoodsReceiptNoteLogRepository.kt
  12. +1
    -0
      src/main/java/com/ffii/fpsms/m18/model/GoodsReceiptNoteRequest.kt
  13. +8
    -1
      src/main/java/com/ffii/fpsms/m18/service/M18GoodsReceiptNoteService.kt
  14. +2
    -0
      src/main/java/com/ffii/fpsms/modules/jobOrder/entity/JobOrderRepository.kt
  15. +132
    -2
      src/main/java/com/ffii/fpsms/modules/stock/service/StockInLineService.kt
  16. +54
    -0
      src/main/java/com/ffii/fpsms/py/PyController.kt
  17. +17
    -0
      src/main/java/com/ffii/fpsms/py/PyJobOrderListItem.kt
  18. +22
    -0
      src/main/resources/db/changelog/changes/20260324_01_fai/01_create_m18_goods_receipt_note_log.sql

+ 621
- 0
python/Bag1.py Näytä tiedosto

@@ -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()

+ 38
- 0
python/Bag1.spec Näytä tiedosto

@@ -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,
)

+ 53
- 0
python/README.md Näytä tiedosto

@@ -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.

+ 9
- 0
python/bag1_settings.json Näytä tiedosto

@@ -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"
}

+ 20
- 0
python/build_exe.bat Näytä tiedosto

@@ -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

+ 80
- 0
python/fetch_job_orders.py Näytä tiedosto

@@ -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()

+ 4
- 0
python/requirements-build.txt Näytä tiedosto

@@ -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

+ 3
- 0
python/requirements.txt Näytä tiedosto

@@ -0,0 +1,3 @@
# Python dependencies for FPSMS backend integration
requests>=2.28.0
pyserial>=3.5

+ 2
- 1
src/main/java/com/ffii/fpsms/config/security/SecurityConfig.java Näytä tiedosto

@@ -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


+ 53
- 0
src/main/java/com/ffii/fpsms/m18/entity/M18GoodsReceiptNoteLog.kt Näytä tiedosto

@@ -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
}

+ 6
- 0
src/main/java/com/ffii/fpsms/m18/entity/M18GoodsReceiptNoteLogRepository.kt Näytä tiedosto

@@ -0,0 +1,6 @@
package com.ffii.fpsms.m18.entity

import com.ffii.core.support.AbstractRepository

interface M18GoodsReceiptNoteLogRepository : AbstractRepository<M18GoodsReceiptNoteLog, Long> {
}

+ 1
- 0
src/main/java/com/ffii/fpsms/m18/model/GoodsReceiptNoteRequest.kt Näytä tiedosto

@@ -21,6 +21,7 @@ data class GoodsReceiptNoteMainanValue(
val rate: Number,
val flowTypeId: Int,
val staffId: Int,
val virDeptId: Int? = null,
)

data class GoodsReceiptNoteAnt(


+ 8
- 1
src/main/java/com/ffii/fpsms/m18/service/M18GoodsReceiptNoteService.kt Näytä tiedosto

@@ -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)
}
}
}

+ 2
- 0
src/main/java/com/ffii/fpsms/modules/jobOrder/entity/JobOrderRepository.kt Näytä tiedosto

@@ -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


+ 132
- 2
src/main/java/com/ffii/fpsms/modules/stock/service/StockInLineService.kt Näytä tiedosto

@@ -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 {


+ 54
- 0
src/main/java/com/ffii/fpsms/py/PyController.kt Näytä tiedosto

@@ -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,
)
}
}

+ 17
- 0
src/main/java/com/ffii/fpsms/py/PyJobOrderListItem.kt Näytä tiedosto

@@ -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?,
)

+ 22
- 0
src/main/resources/db/changelog/changes/20260324_01_fai/01_create_m18_goods_receipt_note_log.sql Näytä tiedosto

@@ -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;

Ladataan…
Peruuta
Tallenna